All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.malliina.audio.javasound.JavaSoundPlayer.scala Maven / Gradle / Ivy

The newest version!
package com.malliina.audio.javasound

import java.io.InputStream

import com.malliina.audio.{ExecutionContexts, PlaybackEvents}
import PlaybackEvents.TimeUpdated
import com.malliina.audio._
import com.malliina.audio.javasound.JavaSoundPlayer.{DefaultRwBufferSize, log}
import com.malliina.audio.meta.OneShotStream
import com.malliina.storage.{StorageInt, StorageLong, StorageSize}
import org.slf4j.LoggerFactory
import rx.lang.scala.subjects.BehaviorSubject
import rx.lang.scala.{Observable, Subject, Subscription}
import scala.concurrent.duration.{Duration, DurationInt}
import scala.concurrent.{ExecutionContext, Future}

/** A music player. Plays one media source. To change source, for example to change track, create a new player.
  *
  * The user needs to provide the media length and size to enable seek functionality. Seeking streams which cannot be
  * reopened is only supported if InputStream.markSupported() of `media.stream` is true, and even then the support is
  * buggy. markSupported() is true at least for [[java.io.BufferedInputStream]]s.
  *
  * The stream provided in `media` is not by default closed when the player is closed, but if you wish to do so,
  * subclass this player and override `close()` accordingly or mix in trait [[SourceClosing]].
  *
  * @see [[FileJavaSoundPlayer]]
  * @see [[UriJavaSoundPlayer]]
  * @param media media info to play
  */
class JavaSoundPlayer(val media: OneShotStream,
                      readWriteBufferSize: StorageSize = DefaultRwBufferSize)(implicit val ec: ExecutionContext = ExecutionContexts.defaultPlaybackContext)
  extends IPlayer
    with JavaSoundPlayerBase
    with StateAwarePlayer
    with AutoCloseable {

  def this(stream: InputStream,
           duration: Duration,
           size: StorageSize,
           readWriteBufferSize: StorageSize) =
    this(OneShotStream(stream, duration, size), readWriteBufferSize)

  val bufferSize = readWriteBufferSize.toBytes.toInt
  protected var stream = media.stream
  tryMarkStream()
  private val subject = BehaviorSubject[PlayerStates.PlayerState](PlayerStates.Closed)
  /** I use a Subject because the audio line might change and it seems easier then to keep one subject
    * instead of reacting to each audio line change in each observable (in addition to its events).
    */
  private val playbackSubject = BehaviorSubject[PlaybackEvents.PlaybackEvent](PlaybackEvents.Closed)
  private val pollingObservable = Observable.interval(500.millis)
  protected var lineData: LineData = newLine(stream, subject)
  private var active = false
  private var playThread: Option[Future[Unit]] = None

  /**
    * @return the current player state and any future states
    */
  def events: Observable[PlayerStates.PlayerState] = subject

  /** A stream of time update events. Emits the current playback position, then emits at least one event per second
    * provided that the playback position changes. If there is no progress, for example if playback is stopped, no
    * events are emitted.
    *
    * @return time update events
    */
  def timeUpdates: Observable[PlaybackEvents.TimeUpdated] = Observable(subscriber => {
    var pollSubscription: Option[Subscription] = None
    var latestPosition = position
    subscriber onNext TimeUpdated(latestPosition)
    val timeSubscription = events.subscribe(state => {
      if (state == PlayerStates.Started && pollSubscription.isEmpty) {
        pollSubscription = Some(pollingObservable.subscribe(_ => {
          if (latestPosition != position) {
            latestPosition = position
            subscriber onNext TimeUpdated(position)
          }
        }))
      } else {
        pollSubscription.foreach(_.unsubscribe())
        pollSubscription = None
      }
    })
    pollSubscription.foreach(subscriber.add)
    subscriber add timeSubscription
  })

  def playbackEvents: Observable[PlaybackEvents.PlaybackEvent] = playbackSubject

  def audioLine = lineData.line

  def controlDescriptions = audioLine.getControls.map(_.toString)

  def newLine(source: InputStream, subject: Subject[PlayerStates.PlayerState]): LineData =
    LineData fromStream(source, subject)

  def supportsSeek = stream.markSupported()

  def play() {
    lineData.state match {
      case PlayerStates.Started =>
        log info "Start playback issued but playback already started: doing nothing"
      case PlayerStates.Closed =>
        log warn "Cannot start playback of a closed track."
      // After end of media, the InputStream is closed and cannot be reused. Therefore this player cannot be used.
      // It's incorrect to call methods on a closed player. In principle we should throw an exception here, but I try
      // to resist the path of the IllegalStateException.
      case anythingElse =>
        startPlayback()
    }
  }

  def stop() {
    active = false
    audioLine.stop()
  }

  /** Regardless of whether the user seeks backwards or forwards, here is what we do:
    *
    * Reset the stream to its initial position. Skip bytes from the beginning. (Optionally continue playback.)
    *
    * The stream needs to support mark so that we can mark the initial position (constructor). Subsequent calls to
    * reset will therefore go to the initial position. Then we can skip the sufficient amount of bytes and arrive at
    * the correct position. Otherwise seeking would just skip bytes forward every time, relative to the current
    * position.
    *
    * This can still be spectacularly inaccurate if a VBR file is seeked but that is a secondary problem.
    *
    * @param pos position to seek to
    */
  def seek(pos: Duration): Unit = {
    seekProblem.map(problem => log.warn(problem)).getOrElse {
      val bytes = timeToBytes(pos)
      val skippedBytes = seekBytes(bytes)
      startedFromMicros = bytesToTime(skippedBytes).toMicros
    }
  }

  def seekProblem: Option[String] =
    if (lineData.state == PlayerStates.Closed) Some(s"Cannot seek a stream of a closed track.")
    else if (!stream.markSupported()) Some("Cannot seek because the media stream does not support marking; see InputStream.markSupported() for more details")
    else None

  override def onEndOfMedia(): Unit = {
    super.onEndOfMedia()
    subject onNext PlayerStates.EndOfMedia
  }

  def close(): Unit = {
    closeLine()
    subject.onCompleted()
  }

  def onPlaybackException(e: Exception) = onEndOfMedia()

  def reset() {
    closeLine()
    stream = resetStream(stream)
    lineData = newLine(stream, subject)
  }

  /** Returns a stream of the media reset to its initial read position. Helper method for seeking.
    *
    * The default implementation merely calls `reset()` on the [[InputStream]] and returns the same instance. If
    * possible, override this method, close and open a new stream instead.
    *
    * @see [[BasicJavaSoundPlayer]]
    * @return a stream of the media reset to its initial read position
    */
  protected def resetStream(oldStream: InputStream): InputStream = {
    oldStream.reset()
    oldStream
  }

  private def closeLine() {
    active = false
    lineData.close()
    startedFromMicros = 0L
  }

  /** Closes the current line, starts from the beginning and then skips to the specified byte count.
    *
    * @param byteCount bytes to skip from start of track
    * @return actual bytes skipped from the beginning of the media
    */
  private def seekBytes(byteCount: StorageSize): StorageSize = {
    // saves state
    val wasPlaying = lineData.state == PlayerStates.Started
    val wasMute = mute
    // seeks
    reset()
    val bytesSkipped = (lineData skip byteCount.toBytes).bytes
    // restores state
    if (wasPlaying) {
      play()
    }
    mute(wasMute)
    bytesSkipped
  }

  private def startPlayback() {
    active = true
    audioLine.start()
    //    log.info(s"Starting playback of ${media.uri}")
    playThread = Some(Future(startPlayThread()).recover({
      // javazoom lib may throw at arbitrary playback moments
      case e: ArrayIndexOutOfBoundsException =>
        log warn(e.getClass.getName, e)
        closeLine()
        onPlaybackException(e)
    }))
  }

  private def startPlayThread(): Unit = {
    val data = new Array[Byte](bufferSize)
    val END_OF_STREAM = -1
    var bytesRead = 0
    while (bytesRead != END_OF_STREAM && active) {
      // blocks until audio data is available
      bytesRead = lineData.read(data)
      if (bytesRead != END_OF_STREAM) {
        audioLine.write(data, 0, bytesRead)
      } else {
        // cleanup
        closeLine()
        // -1 bytes read means "end of stream has been reached"
        onEndOfMedia()
      }
    }
  }

  def state = lineData.state

  private def tryMarkStream() {
    if (stream.markSupported()) {
      val markLimit = math.min(Integer.MAX_VALUE.toLong, 2 * media.size.toBytes).toInt
      stream mark markLimit
      //      log.info(s"Mark limit is: $markLimit")
    }
  }
}

object JavaSoundPlayer {
  private val log = LoggerFactory.getLogger(getClass)
  val DefaultRwBufferSize = 4096.bytes
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy