Better separation between FOSS and full version. Chromecast playback, basic queue management and playback controls are functional.

This commit is contained in:
Antoine POPINEAU 2020-06-23 21:21:56 +02:00
parent 3654e28c0c
commit 9ed7eab761
No known key found for this signature in database
GPG Key ID: A78AC64694F84063
15 changed files with 302 additions and 46 deletions

View File

@ -2,10 +2,18 @@ package com.github.apognu.otter
import android.content.Context
import android.view.Menu
import com.github.apognu.otter.utils.log
import com.github.apognu.otter.playback.PlayerService
import com.github.apognu.otter.utils.CastInterface
object Cast {
fun init(context: Context) {}
fun setupButton(context: Context, menu: Menu?) {}
class Cast(val context: Context, val switchListener: PlayerService.OnPlayerSwitchListener, playerEventListener: PlayerService.PlayerEventListener) : CastInterface {
companion object {
fun init(context: Context) {}
fun setupButton(context: Context, menu: Menu?) {}
fun get(
context: Context,
playerSwitchListener: PlayerService.OnPlayerSwitchListener,
playerEventListener: PlayerService.PlayerEventListener
): Cast? = null
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/nav_search"
android:icon="@drawable/search"
android:title="@string/toolbar_search"
app:showAsAction="ifRoom" />
<item
android:id="@+id/nav_only_my_music"
android:checkable="true"
android:title="@string/only_my_music"
app:showAsAction="never" />
<item
android:id="@+id/settings"
android:icon="@drawable/settings"
android:iconTint="@android:color/white"
android:title="@string/title_settings"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/nav_queue"
android:icon="@drawable/queue"
android:title="@string/playback_queue"
app:showAsAction="always" />
<item
android:id="@+id/nav_search"
android:icon="@drawable/search"
android:title="@string/toolbar_search"
app:showAsAction="always" />
<item
android:id="@+id/nav_only_my_music"
android:checkable="true"
android:title="@string/only_my_music"
app:showAsAction="never" />
<item
android:id="@+id/nav_downloads"
android:icon="@drawable/downloads"
android:title="@string/title_downloads"
app:showAsAction="never" />
<item
android:id="@+id/settings"
android:icon="@drawable/settings"
android:title="@string/title_settings"
app:showAsAction="never" />
</menu>

View File

@ -1,17 +1,142 @@
package com.github.apognu.otter
import android.content.Context
import android.net.Uri
import android.view.Menu
import com.github.apognu.otter.playback.PlayerService
import com.github.apognu.otter.utils.AppContext
import com.github.apognu.otter.utils.CastInterface
import com.github.apognu.otter.utils.Track
import com.github.apognu.otter.utils.mustNormalizeUrl
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Timeline
import com.google.android.exoplayer2.ext.cast.CastPlayer
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.images.WebImage
import com.preference.PowerPreference
object Cast {
fun init(context: Context) {
CastContext.getSharedInstance(context)
fun Player.onCast(): CastPlayer? {
return if (this is CastPlayer) this
else null
}
class Cast(val context: Context, val switchListener: PlayerService.OnPlayerSwitchListener, playerEventListener: PlayerService.PlayerEventListener) : CastInterface {
companion object {
fun init(context: Context) {
CastContext.getSharedInstance(context)
}
fun setupButton(context: Context, menu: Menu?) {
CastButtonFactory.setUpMediaRouteButton(context, menu, R.id.cast)
}
fun get(
context: Context,
playerSwitchListener: PlayerService.OnPlayerSwitchListener,
playerEventListener: PlayerService.PlayerEventListener
): Cast = Cast(context, playerSwitchListener, playerEventListener)
}
fun setupButton(context: Context, menu: Menu?) {
CastButtonFactory.setUpMediaRouteButton(context, menu, R.id.cast)
private val player: Player
init {
player = CastPlayer(CastContext.getSharedInstance(context)).apply {
addListener(playerEventListener)
setSessionAvailabilityListener(CastSessionListener())
}
}
override fun getPlayer(context: Context): Player = player
override fun replaceQueue(tracks: List<Track>) {
player.onCast()?.let { castPlayer ->
tracks
.map { track -> buildMediaQueueItem(track) }
.apply {
castPlayer.loadItems(this.toTypedArray(), 0, 0, Player.REPEAT_MODE_OFF)
castPlayer.playWhenReady = true
}
}
}
override fun addToQueue(tracks: List<Track>) {
player.onCast()?.let { castPlayer ->
tracks
.map { track -> buildMediaQueueItem(track) }
.forEach {
castPlayer.addItems(it)
}
}
}
override fun insertNext(track: Track, current: Int) {
player.onCast()?.let { castPlayer ->
val period = Timeline.Period().run {
player.currentTimeline.getPeriod(current + 1, this)
}
castPlayer.addItems(period.id.toString().toInt(), buildMediaQueueItem(track))
}
}
override fun remove(index: Int) {
player.onCast()?.let { castPlayer ->
val period = Timeline.Period().run {
player.currentTimeline.getPeriod(index, this)
}
castPlayer.removeItem(period.id.toString().toInt())
}
}
override fun move(oldPosition: Int, newPosition: Int) {
player.onCast()?.let { castPlayer ->
val period = Timeline.Period().run {
player.currentTimeline.getPeriod(oldPosition, this)
}
castPlayer.moveItem(period.id.toString().toInt(), newPosition)
}
}
private fun buildMediaQueueItem(track: Track): MediaQueueItem {
val listenUrl = mustNormalizeUrl(track.bestUpload()?.listen_url ?: "")
val token = PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).getString("listen_token", "")
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply {
putString(MediaMetadata.KEY_ARTIST, track.artist.name)
putString(MediaMetadata.KEY_ALBUM_TITLE, track.album.title)
putString(MediaMetadata.KEY_TITLE, track.title)
addImage(WebImage(Uri.parse(mustNormalizeUrl(track.album.cover()))))
}
val url = Uri.parse(listenUrl)
.buildUpon()
.appendQueryParameter("token", token)
.build()
.toString()
val mediaInfo = MediaInfo.Builder(url)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
.build()
return MediaQueueItem.Builder(mediaInfo).build()
}
inner class CastSessionListener : SessionAvailabilityListener {
override fun onCastSessionAvailable() {
switchListener.switchToRemote()
}
override fun onCastSessionUnavailable() {
switchListener.switchToLocal()
}
}
}

View File

@ -2,18 +2,17 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- <item
<item
android:id="@+id/cast"
android:iconTint="@android:color/white"
android:title="@string/toolbar_cast"
android:title="Cast!"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="ifRoom" /> -->
app:showAsAction="always" />
<item
android:id="@+id/nav_search"
android:icon="@drawable/search"
android:title="@string/toolbar_search"
app:showAsAction="ifRoom" />
app:showAsAction="always" />
<item
android:id="@+id/nav_only_my_music"
@ -24,8 +23,7 @@
<item
android:id="@+id/settings"
android:icon="@drawable/settings"
android:iconTint="@android:color/white"
android:title="@string/title_settings"
app:showAsAction="never" />
</menu>
</menu>

View File

@ -0,0 +1,13 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="AppTheme.Base">
<item name="mediaRouteTheme">@style/AppTheme.MediaRouteTheme</item>
</style>
<style name="AppTheme.MediaRouteTheme" parent="Theme.MediaRouter">
<item name="mediaRouteButtonStyle">@style/AppTheme.MediaRouteTheme.ButtonStyle</item>
</style>
<style name="AppTheme.MediaRouteTheme.ButtonStyle" parent="Widget.MediaRouter.Light.MediaRouteButton">
<item name="mediaRouteButtonTint">@android:color/white</item>
</style>
</resources>

View File

@ -12,6 +12,7 @@ import android.os.Build
import android.os.IBinder
import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent
import com.github.apognu.otter.Cast
import com.github.apognu.otter.R
import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.C
@ -30,6 +31,11 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class PlayerService : Service() {
interface OnPlayerSwitchListener {
fun switchToLocal()
fun switchToRemote()
}
private lateinit var queue: QueueManager
private val jobs = mutableListOf<Job>()
@ -40,7 +46,10 @@ class PlayerService : Service() {
private lateinit var mediaControlsManager: MediaControlsManager
private lateinit var mediaSession: MediaSessionCompat
private lateinit var player: SimpleExoPlayer
private lateinit var player: Player
private lateinit var localPlayer: SimpleExoPlayer
private var cast: Cast? = null
private lateinit var playerEventListener: PlayerEventListener
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
@ -58,7 +67,6 @@ class PlayerService : Service() {
override fun onCreate() {
super.onCreate()
queue = QueueManager(this)
radioPlayer = RadioPlayer(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
@ -85,13 +93,15 @@ class PlayerService : Service() {
mediaControlsManager = MediaControlsManager(this, mediaSession)
player = SimpleExoPlayer.Builder(this).build().apply {
localPlayer = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false
playerEventListener = PlayerEventListener().also {
addListener(it)
}
cast = Cast.get(this@PlayerService, PlayerSwitchListener(), playerEventListener)
MediaSessionConnector(mediaSession).also {
it.setPlayer(this)
it.setMediaButtonEventHandler { player, _, mediaButtonEvent ->
@ -111,8 +121,11 @@ class PlayerService : Service() {
}
}
player = if (cast?.isCastSessionAvailable() == true) cast!!.getPlayer(this) else localPlayer
queue = QueueManager(this, cast)
if (queue.current > -1) {
player.prepare(queue.datasources, true, true)
player.onLocal()?.prepare(queue.datasources, true, true)
Cache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong())
@ -143,7 +156,7 @@ class PlayerService : Service() {
if (!command.fromRadio) radioPlayer.stop()
queue.replace(command.queue)
player.prepare(queue.datasources, true, true)
player.onLocal()?.prepare(queue.datasources, true, true)
state(true)
@ -262,7 +275,7 @@ class PlayerService : Service() {
}
if (state && player.playbackState == Player.STATE_IDLE) {
player.prepare(queue.datasources)
player.onLocal()?.prepare(queue.datasources)
}
var allowed = !state
@ -394,7 +407,7 @@ class PlayerService : Service() {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
queue.current++
player.prepare(queue.datasources, true, true)
player.onLocal()?.prepare(queue.datasources, true, true)
player.seekTo(queue.current, 0)
player.playWhenReady = true
@ -406,7 +419,7 @@ class PlayerService : Service() {
override fun onAudioFocusChange(focus: Int) {
when (focus) {
AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1f
player.onLocal()?.volume = 1f
state(stateWhenLostFocus)
stateWhenLostFocus = false
@ -424,9 +437,21 @@ class PlayerService : Service() {
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
stateWhenLostFocus = player.playWhenReady
player.volume = 0.3f
player.onLocal()?.volume = 0.3f
}
}
}
}
inner class PlayerSwitchListener : OnPlayerSwitchListener {
override fun switchToLocal() {
player = localPlayer
}
override fun switchToRemote() {
cast?.let { cast ->
player = cast.getPlayer(this@PlayerService)
}
}
}
}

View File

@ -15,7 +15,7 @@ import com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory
import com.google.android.exoplayer2.util.Util
import com.google.gson.Gson
class QueueManager(val context: Context) {
class QueueManager(val context: Context, val cast: CastInterface?) {
var metadata: MutableList<Track> = mutableListOf()
val datasources = ConcatenatingMediaSource()
var current = -1
@ -84,6 +84,8 @@ class QueueManager(val context: Context) {
datasources.clear()
datasources.addMediaSources(sources)
cast?.replaceQueue(tracks)
persist()
EventBus.send(Event.QueueChanged)
@ -102,6 +104,8 @@ class QueueManager(val context: Context) {
metadata.addAll(tracks)
datasources.addMediaSources(sources)
cast?.addToQueue(tracks)
persist()
EventBus.send(Event.QueueChanged)
@ -120,25 +124,29 @@ class QueueManager(val context: Context) {
move(metadata.indexOf(track), current + 1)
}
cast?.insertNext(track, current)
persist()
EventBus.send(Event.QueueChanged)
}
fun remove(track: Track) {
metadata.indexOf(track).let {
if (it < 0) {
metadata.indexOf(track).let { trackIndex ->
if (trackIndex < 0) {
return
}
datasources.removeMediaSource(it)
metadata.removeAt(it)
datasources.removeMediaSource(trackIndex)
metadata.removeAt(trackIndex)
if (it == current) {
cast?.remove(trackIndex)
if (trackIndex == current) {
CommandBus.send(Command.NextTrack)
}
if (it < current) {
if (trackIndex < current) {
current--
}
}
@ -156,6 +164,8 @@ class QueueManager(val context: Context) {
datasources.moveMediaSource(oldPosition, newPosition)
metadata.add(newPosition, metadata.removeAt(oldPosition))
cast?.move(oldPosition, newPosition)
persist()
}

View File

@ -68,7 +68,6 @@ sealed class Response {
object EventBus {
fun send(event: Event) {
GlobalScope.launch(IO) {
Otter.get().eventBus.log()
Otter.get().eventBus.offer(event)
}
}

View File

@ -0,0 +1,16 @@
package com.github.apognu.otter.utils
import android.content.Context
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
interface CastInterface {
fun isCastSessionAvailable(): Boolean = false
fun getPlayer(context: Context): Player = SimpleExoPlayer.Builder(context).build()
fun replaceQueue(tracks: List<Track>) {}
fun addToQueue(tracks: List<Track>) {}
fun insertNext(track: Track, current: Int) {}
fun remove(index: Int) {}
fun move(oldPosition: Int, newPosition: Int) {}
}

View File

@ -6,6 +6,8 @@ import com.github.apognu.otter.R
import com.github.apognu.otter.fragments.BrowseFragment
import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.offline.Download
import com.google.gson.Gson
import com.squareup.picasso.Picasso
@ -77,3 +79,8 @@ fun Request.authorize(): Request {
}
fun Download.getMetadata(): DownloadInfo? = Gson().fromJson(String(this.request.data), DownloadInfo::class.java)
fun Player.onLocal(): SimpleExoPlayer? {
return if (this is SimpleExoPlayer) this
else null
}

View File

@ -4,9 +4,12 @@ import com.google.android.exoplayer2.offline.Download
import com.preference.PowerPreference
data class User(
val full_username: String
val full_username: String,
val tokens: UserTokens
)
data class UserTokens(val listen: String)
sealed class CacheItem<D : Any>(val data: List<D>)
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)

View File

@ -20,6 +20,7 @@ object Userinfo {
PowerPreference.getFileByName(AppContext.PREFS_CREDENTIALS).apply {
setString("actor_username", user.full_username)
setString("listen_token", user.tokens.listen)
}
user

View File

@ -1,6 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<style name="AppTheme" parent="AppTheme.Base"></style>
<style name="AppTheme.Base" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:forceDarkAllowed" tools:targetApi="q">true</item>
<item name="colorPrimary">@color/colorPrimary</item>
@ -16,8 +18,6 @@
<item name="buttonBarNegativeButtonStyle">@style/AppTheme.DialogButtonStyle</item>
<item name="buttonBarPositiveButtonStyle">@style/AppTheme.DialogButtonStyle</item>
<item name="mediaRouteTheme">@style/AppTheme.MediaRouteTheme</item>
</style>
<style name="AppTheme.Fragment"></style>
@ -101,12 +101,4 @@
<item name="android:textColor">@android:color/white</item>
</style>
<style name="AppTheme.MediaRouteTheme" parent="Theme.MediaRouter">
<item name="mediaRouteButtonStyle">@style/AppTheme.MediaRouteTheme.ButtonStyle</item>
</style>
<style name="AppTheme.MediaRouteTheme.ButtonStyle" parent="Widget.MediaRouter.Light.MediaRouteButton">
<item name="mediaRouteButtonTint">@android:color/white</item>
</style>
</resources>