V5 Push
Here's a list of changes/features: https://github.com/RetroMusicPlayer/RetroMusicPlayer/releases/tag/v5.0 Internal Changes: 1) Migrated to ViewBinding 2) Migrated to Glide V4 3) Migrated to kotlin version of Material Dialogs
This commit is contained in:
parent
fc42767031
commit
bce6dbfa27
421 changed files with 13285 additions and 5757 deletions
|
@ -0,0 +1,83 @@
|
|||
package code.name.monkey.retromusic.cast
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import code.name.monkey.retromusic.cast.RetroWebServer.Companion.MIME_TYPE_AUDIO
|
||||
import code.name.monkey.retromusic.cast.RetroWebServer.Companion.PART_COVER_ART
|
||||
import code.name.monkey.retromusic.cast.RetroWebServer.Companion.PART_SONG
|
||||
import code.name.monkey.retromusic.model.Song
|
||||
import code.name.monkey.retromusic.util.RetroUtil
|
||||
import com.google.android.gms.cast.*
|
||||
import com.google.android.gms.cast.MediaInfo.STREAM_TYPE_BUFFERED
|
||||
import com.google.android.gms.cast.MediaMetadata.*
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
|
||||
object CastHelper {
|
||||
|
||||
private const val CAST_MUSIC_METADATA_ID = "metadata_id"
|
||||
private const val CAST_MUSIC_METADATA_ALBUM_ID = "metadata_album_id"
|
||||
private const val CAST_URL_PROTOCOL = "http"
|
||||
|
||||
fun castSong(castSession: CastSession, song: Song) {
|
||||
try {
|
||||
val remoteMediaClient = castSession.remoteMediaClient
|
||||
val mediaLoadOptions = MediaLoadOptions.Builder().apply {
|
||||
setPlayPosition(0)
|
||||
setAutoplay(true)
|
||||
}.build()
|
||||
remoteMediaClient?.load(song.toMediaInfo()!!, mediaLoadOptions)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun castQueue(castSession: CastSession, songs: List<Song>, position: Int, progress: Long) {
|
||||
try {
|
||||
val remoteMediaClient = castSession.remoteMediaClient
|
||||
remoteMediaClient?.queueLoad(
|
||||
songs.toMediaInfoList(),
|
||||
position,
|
||||
MediaStatus.REPEAT_MODE_REPEAT_OFF,
|
||||
progress,
|
||||
null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Song>.toMediaInfoList(): Array<MediaQueueItem> {
|
||||
return map { MediaQueueItem.Builder(it.toMediaInfo()!!).build() }.toTypedArray()
|
||||
}
|
||||
|
||||
private fun Song.toMediaInfo(): MediaInfo? {
|
||||
val song = this
|
||||
val baseUrl: URL
|
||||
try {
|
||||
baseUrl = URL(CAST_URL_PROTOCOL, RetroUtil.getIpAddress(true), SERVER_PORT, "")
|
||||
} catch (e: MalformedURLException) {
|
||||
return null
|
||||
}
|
||||
|
||||
val songUrl = "$baseUrl/$PART_SONG?id=${song.id}"
|
||||
val albumArtUrl = "$baseUrl/$PART_COVER_ART?id=${song.albumId}"
|
||||
val musicMetadata = MediaMetadata(MEDIA_TYPE_MUSIC_TRACK).apply {
|
||||
putInt(CAST_MUSIC_METADATA_ID, song.id.toInt())
|
||||
putInt(CAST_MUSIC_METADATA_ALBUM_ID, song.albumId.toInt())
|
||||
putString(KEY_TITLE, song.title)
|
||||
putString(KEY_ARTIST, song.artistName)
|
||||
putString(KEY_ALBUM_TITLE, song.albumName)
|
||||
putInt(KEY_TRACK_NUMBER, song.trackNumber)
|
||||
addImage(WebImage(albumArtUrl.toUri()))
|
||||
}
|
||||
return MediaInfo.Builder(songUrl).apply {
|
||||
setStreamType(STREAM_TYPE_BUFFERED)
|
||||
setContentType(MIME_TYPE_AUDIO)
|
||||
setMetadata(musicMetadata)
|
||||
setStreamDuration(song.duration)
|
||||
}.build()
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package code.name.monkey.retromusic.cast
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.framework.CastOptions
|
||||
import com.google.android.gms.cast.framework.OptionsProvider
|
||||
import com.google.android.gms.cast.framework.SessionProvider
|
||||
import com.google.android.gms.cast.framework.media.CastMediaOptions
|
||||
import com.google.android.gms.cast.framework.media.MediaIntentReceiver
|
||||
import com.google.android.gms.cast.framework.media.NotificationOptions
|
||||
import java.util.*
|
||||
|
||||
|
||||
class CastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
val buttonActions: MutableList<String> = ArrayList()
|
||||
buttonActions.add(MediaIntentReceiver.ACTION_SKIP_PREV)
|
||||
buttonActions.add(MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK)
|
||||
buttonActions.add(MediaIntentReceiver.ACTION_SKIP_NEXT)
|
||||
buttonActions.add(MediaIntentReceiver.ACTION_STOP_CASTING)
|
||||
val compatButtonActionsIndices = intArrayOf(1, 3)
|
||||
val notificationOptions = NotificationOptions.Builder()
|
||||
.setActions(buttonActions, compatButtonActionsIndices)
|
||||
.setTargetActivityClassName(ExpandedControlsActivity::class.java.name)
|
||||
.build()
|
||||
|
||||
val mediaOptions = CastMediaOptions.Builder()
|
||||
.setNotificationOptions(notificationOptions)
|
||||
.setExpandedControllerActivityClassName(ExpandedControlsActivity::class.java.name)
|
||||
.build()
|
||||
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
|
||||
.setCastMediaOptions(mediaOptions)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getAdditionalSessionProviders(context: Context?): List<SessionProvider>? {
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package code.name.monkey.retromusic.cast
|
||||
|
||||
|
||||
import android.view.Menu
|
||||
import code.name.monkey.retromusic.R
|
||||
|
||||
import com.google.android.gms.cast.framework.CastButtonFactory
|
||||
|
||||
import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity
|
||||
|
||||
|
||||
class ExpandedControlsActivity : ExpandedControllerActivity() {
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.menu_cast, menu)
|
||||
CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.action_cast)
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package code.name.monkey.retromusic.cast
|
||||
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||
|
||||
interface RetroSessionManager : SessionManagerListener<CastSession> {
|
||||
override fun onSessionResuming(p0: CastSession, p1: String) {
|
||||
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(p0: CastSession, p1: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun onSessionResumeFailed(p0: CastSession, p1: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun onSessionEnding(p0: CastSession) {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
package code.name.monkey.retromusic.cast
|
||||
|
||||
import android.content.Context
|
||||
import code.name.monkey.retromusic.util.MusicUtil
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import fi.iki.elonen.NanoHTTPD.Response.Status
|
||||
import java.io.*
|
||||
|
||||
|
||||
const val SERVER_PORT = 9090
|
||||
|
||||
class RetroWebServer(val context: Context) : NanoHTTPD(SERVER_PORT) {
|
||||
companion object {
|
||||
private const val MIME_TYPE_IMAGE = "image/jpg"
|
||||
const val MIME_TYPE_AUDIO = "audio/mp3"
|
||||
|
||||
const val PART_COVER_ART = "coverart"
|
||||
const val PART_SONG = "song"
|
||||
const val PARAM_ID = "id"
|
||||
var mRetroWebServer: RetroWebServer? = null
|
||||
fun getInstance(context: Context): RetroWebServer {
|
||||
if (mRetroWebServer == null) {
|
||||
mRetroWebServer = RetroWebServer(context)
|
||||
}
|
||||
return mRetroWebServer!!
|
||||
}
|
||||
}
|
||||
|
||||
override fun serve(
|
||||
uri: String?,
|
||||
method: Method?,
|
||||
headers: MutableMap<String, String>?,
|
||||
parms: MutableMap<String, String>?,
|
||||
files: MutableMap<String, String>?
|
||||
): Response {
|
||||
if (uri?.contains(PART_COVER_ART) == true) {
|
||||
val albumId = parms?.get(PARAM_ID) ?: return errorResponse()
|
||||
val albumArtUri = MusicUtil.getMediaStoreAlbumCoverUri(albumId.toLong())
|
||||
val fis: InputStream?
|
||||
try {
|
||||
fis = context.contentResolver.openInputStream(albumArtUri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
return errorResponse()
|
||||
}
|
||||
return newChunkedResponse(Status.OK, MIME_TYPE_IMAGE, fis)
|
||||
} else if (uri?.contains(PART_SONG) == true) {
|
||||
val songId = parms?.get(PARAM_ID) ?: return errorResponse()
|
||||
val songUri = MusicUtil.getSongFileUri(songId.toLong())
|
||||
val songPath = MusicUtil.getSongFilePath(context, songUri)
|
||||
val song = File(songPath)
|
||||
return serveFile(headers!!, song, MIME_TYPE_AUDIO)
|
||||
}
|
||||
return newFixedLengthResponse(Status.NOT_FOUND, MIME_PLAINTEXT, "Not Found")
|
||||
}
|
||||
|
||||
private fun serveFile(
|
||||
header: MutableMap<String, String>, file: File,
|
||||
mime: String
|
||||
): Response {
|
||||
var res: Response
|
||||
try {
|
||||
// Support (simple) skipping:
|
||||
var startFrom: Long = 0
|
||||
var endAt: Long = -1
|
||||
// The value of header range will be bytes=0-1024 something like this
|
||||
// We get the value of from Bytes i.e. startFrom and toBytes i.e. endAt below
|
||||
var range = header["range"]
|
||||
if (range != null) {
|
||||
if (range.startsWith("bytes=")) {
|
||||
range = range.substring("bytes=".length)
|
||||
val minus = range.indexOf('-')
|
||||
try {
|
||||
if (minus > 0) {
|
||||
startFrom = range
|
||||
.substring(0, minus).toLong()
|
||||
endAt = range.substring(minus + 1).toLong()
|
||||
}
|
||||
} catch (ignored: NumberFormatException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chunked Response is used when serving audio file
|
||||
// Change return code and add Content-Range header when skipping is
|
||||
// requested
|
||||
val fileLen = file.length()
|
||||
if (range != null && startFrom >= 0) {
|
||||
if (startFrom >= fileLen) {
|
||||
res = newFixedLengthResponse(
|
||||
Status.RANGE_NOT_SATISFIABLE,
|
||||
MIME_PLAINTEXT, ""
|
||||
)
|
||||
res.addHeader("Content-Range", "bytes 0-0/$fileLen")
|
||||
} else {
|
||||
if (endAt < 0) {
|
||||
endAt = fileLen - 1
|
||||
}
|
||||
var newLen = endAt - startFrom + 1
|
||||
if (newLen < 0) {
|
||||
newLen = 0
|
||||
}
|
||||
val dataLen = newLen
|
||||
val fis: FileInputStream = object : FileInputStream(file) {
|
||||
@Throws(IOException::class)
|
||||
override fun available(): Int {
|
||||
return dataLen.toInt()
|
||||
}
|
||||
}
|
||||
fis.skip(startFrom)
|
||||
res = newChunkedResponse(
|
||||
Status.PARTIAL_CONTENT, mime,
|
||||
fis
|
||||
)
|
||||
res.addHeader("Content-Length", "" + dataLen)
|
||||
res.addHeader(
|
||||
"Content-Range", "bytes " + startFrom + "-"
|
||||
+ endAt + "/" + fileLen
|
||||
)
|
||||
}
|
||||
} else {
|
||||
res = newFixedLengthResponse(
|
||||
Status.OK, mime,
|
||||
FileInputStream(file), file.length()
|
||||
)
|
||||
res.addHeader("Accept-Ranges", "bytes")
|
||||
res.addHeader("Content-Length", "" + fileLen)
|
||||
}
|
||||
} catch (ioe: IOException) {
|
||||
res = newFixedLengthResponse(
|
||||
Status.FORBIDDEN,
|
||||
MIME_PLAINTEXT, "FORBIDDEN: Reading file failed."
|
||||
)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
private fun errorResponse(message: String = "Error Occurred"): Response {
|
||||
return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, message)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue