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.content.Context
import android.view.Menu 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 { class Cast(val context: Context, val switchListener: PlayerService.OnPlayerSwitchListener, playerEventListener: PlayerService.PlayerEventListener) : CastInterface {
fun init(context: Context) {} companion object {
fun setupButton(context: Context, menu: Menu?) {} 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 package com.github.apognu.otter
import android.content.Context import android.content.Context
import android.net.Uri
import android.view.Menu 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.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.images.WebImage
import com.preference.PowerPreference
object Cast { fun Player.onCast(): CastPlayer? {
fun init(context: Context) { return if (this is CastPlayer) this
CastContext.getSharedInstance(context) 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?) { private val player: Player
CastButtonFactory.setUpMediaRouteButton(context, menu, R.id.cast)
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" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- <item <item
android:id="@+id/cast" android:id="@+id/cast"
android:iconTint="@android:color/white" android:title="Cast!"
android:title="@string/toolbar_cast"
app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider" app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider"
app:showAsAction="ifRoom" /> --> app:showAsAction="always" />
<item <item
android:id="@+id/nav_search" android:id="@+id/nav_search"
android:icon="@drawable/search" android:icon="@drawable/search"
android:title="@string/toolbar_search" android:title="@string/toolbar_search"
app:showAsAction="ifRoom" /> app:showAsAction="always" />
<item <item
android:id="@+id/nav_only_my_music" android:id="@+id/nav_only_my_music"
@ -24,8 +23,7 @@
<item <item
android:id="@+id/settings" android:id="@+id/settings"
android:icon="@drawable/settings" android:icon="@drawable/settings"
android:iconTint="@android:color/white"
android:title="@string/title_settings" android:title="@string/title_settings"
app:showAsAction="never" /> 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.os.IBinder
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.view.KeyEvent import android.view.KeyEvent
import com.github.apognu.otter.Cast
import com.github.apognu.otter.R import com.github.apognu.otter.R
import com.github.apognu.otter.utils.* import com.github.apognu.otter.utils.*
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
@ -30,6 +31,11 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PlayerService : Service() { class PlayerService : Service() {
interface OnPlayerSwitchListener {
fun switchToLocal()
fun switchToRemote()
}
private lateinit var queue: QueueManager private lateinit var queue: QueueManager
private val jobs = mutableListOf<Job>() private val jobs = mutableListOf<Job>()
@ -40,7 +46,10 @@ class PlayerService : Service() {
private lateinit var mediaControlsManager: MediaControlsManager private lateinit var mediaControlsManager: MediaControlsManager
private lateinit var mediaSession: MediaSessionCompat 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 lateinit var playerEventListener: PlayerEventListener
private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver() private val headphonesUnpluggedReceiver = HeadphonesUnpluggedReceiver()
@ -58,7 +67,6 @@ class PlayerService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
queue = QueueManager(this)
radioPlayer = RadioPlayer(this) radioPlayer = RadioPlayer(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
@ -85,13 +93,15 @@ class PlayerService : Service() {
mediaControlsManager = MediaControlsManager(this, mediaSession) mediaControlsManager = MediaControlsManager(this, mediaSession)
player = SimpleExoPlayer.Builder(this).build().apply { localPlayer = SimpleExoPlayer.Builder(this).build().apply {
playWhenReady = false playWhenReady = false
playerEventListener = PlayerEventListener().also { playerEventListener = PlayerEventListener().also {
addListener(it) addListener(it)
} }
cast = Cast.get(this@PlayerService, PlayerSwitchListener(), playerEventListener)
MediaSessionConnector(mediaSession).also { MediaSessionConnector(mediaSession).also {
it.setPlayer(this) it.setPlayer(this)
it.setMediaButtonEventHandler { player, _, mediaButtonEvent -> 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) { if (queue.current > -1) {
player.prepare(queue.datasources, true, true) player.onLocal()?.prepare(queue.datasources, true, true)
Cache.get(this, "progress")?.let { progress -> Cache.get(this, "progress")?.let { progress ->
player.seekTo(queue.current, progress.readLine().toLong()) player.seekTo(queue.current, progress.readLine().toLong())
@ -143,7 +156,7 @@ class PlayerService : Service() {
if (!command.fromRadio) radioPlayer.stop() if (!command.fromRadio) radioPlayer.stop()
queue.replace(command.queue) queue.replace(command.queue)
player.prepare(queue.datasources, true, true) player.onLocal()?.prepare(queue.datasources, true, true)
state(true) state(true)
@ -262,7 +275,7 @@ class PlayerService : Service() {
} }
if (state && player.playbackState == Player.STATE_IDLE) { if (state && player.playbackState == Player.STATE_IDLE) {
player.prepare(queue.datasources) player.onLocal()?.prepare(queue.datasources)
} }
var allowed = !state var allowed = !state
@ -394,7 +407,7 @@ class PlayerService : Service() {
EventBus.send(Event.PlaybackError(getString(R.string.error_playback))) EventBus.send(Event.PlaybackError(getString(R.string.error_playback)))
queue.current++ queue.current++
player.prepare(queue.datasources, true, true) player.onLocal()?.prepare(queue.datasources, true, true)
player.seekTo(queue.current, 0) player.seekTo(queue.current, 0)
player.playWhenReady = true player.playWhenReady = true
@ -406,7 +419,7 @@ class PlayerService : Service() {
override fun onAudioFocusChange(focus: Int) { override fun onAudioFocusChange(focus: Int) {
when (focus) { when (focus) {
AudioManager.AUDIOFOCUS_GAIN -> { AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1f player.onLocal()?.volume = 1f
state(stateWhenLostFocus) state(stateWhenLostFocus)
stateWhenLostFocus = false stateWhenLostFocus = false
@ -424,9 +437,21 @@ class PlayerService : Service() {
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
stateWhenLostFocus = player.playWhenReady 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.android.exoplayer2.util.Util
import com.google.gson.Gson import com.google.gson.Gson
class QueueManager(val context: Context) { class QueueManager(val context: Context, val cast: CastInterface?) {
var metadata: MutableList<Track> = mutableListOf() var metadata: MutableList<Track> = mutableListOf()
val datasources = ConcatenatingMediaSource() val datasources = ConcatenatingMediaSource()
var current = -1 var current = -1
@ -84,6 +84,8 @@ class QueueManager(val context: Context) {
datasources.clear() datasources.clear()
datasources.addMediaSources(sources) datasources.addMediaSources(sources)
cast?.replaceQueue(tracks)
persist() persist()
EventBus.send(Event.QueueChanged) EventBus.send(Event.QueueChanged)
@ -102,6 +104,8 @@ class QueueManager(val context: Context) {
metadata.addAll(tracks) metadata.addAll(tracks)
datasources.addMediaSources(sources) datasources.addMediaSources(sources)
cast?.addToQueue(tracks)
persist() persist()
EventBus.send(Event.QueueChanged) EventBus.send(Event.QueueChanged)
@ -120,25 +124,29 @@ class QueueManager(val context: Context) {
move(metadata.indexOf(track), current + 1) move(metadata.indexOf(track), current + 1)
} }
cast?.insertNext(track, current)
persist() persist()
EventBus.send(Event.QueueChanged) EventBus.send(Event.QueueChanged)
} }
fun remove(track: Track) { fun remove(track: Track) {
metadata.indexOf(track).let { metadata.indexOf(track).let { trackIndex ->
if (it < 0) { if (trackIndex < 0) {
return return
} }
datasources.removeMediaSource(it) datasources.removeMediaSource(trackIndex)
metadata.removeAt(it) metadata.removeAt(trackIndex)
if (it == current) { cast?.remove(trackIndex)
if (trackIndex == current) {
CommandBus.send(Command.NextTrack) CommandBus.send(Command.NextTrack)
} }
if (it < current) { if (trackIndex < current) {
current-- current--
} }
} }
@ -156,6 +164,8 @@ class QueueManager(val context: Context) {
datasources.moveMediaSource(oldPosition, newPosition) datasources.moveMediaSource(oldPosition, newPosition)
metadata.add(newPosition, metadata.removeAt(oldPosition)) metadata.add(newPosition, metadata.removeAt(oldPosition))
cast?.move(oldPosition, newPosition)
persist() persist()
} }

View File

@ -68,7 +68,6 @@ sealed class Response {
object EventBus { object EventBus {
fun send(event: Event) { fun send(event: Event) {
GlobalScope.launch(IO) { GlobalScope.launch(IO) {
Otter.get().eventBus.log()
Otter.get().eventBus.offer(event) 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.fragments.BrowseFragment
import com.github.apognu.otter.repositories.Repository import com.github.apognu.otter.repositories.Repository
import com.github.kittinunf.fuel.core.Request 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.android.exoplayer2.offline.Download
import com.google.gson.Gson import com.google.gson.Gson
import com.squareup.picasso.Picasso 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 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 import com.preference.PowerPreference
data class User( 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>) sealed class CacheItem<D : Any>(val data: List<D>)
class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data) class ArtistsCache(data: List<Artist>) : CacheItem<Artist>(data)
class AlbumsCache(data: List<Album>) : CacheItem<Album>(data) class AlbumsCache(data: List<Album>) : CacheItem<Album>(data)

View File

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

View File

@ -1,6 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <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="android:forceDarkAllowed" tools:targetApi="q">true</item>
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
@ -16,8 +18,6 @@
<item name="buttonBarNegativeButtonStyle">@style/AppTheme.DialogButtonStyle</item> <item name="buttonBarNegativeButtonStyle">@style/AppTheme.DialogButtonStyle</item>
<item name="buttonBarPositiveButtonStyle">@style/AppTheme.DialogButtonStyle</item> <item name="buttonBarPositiveButtonStyle">@style/AppTheme.DialogButtonStyle</item>
<item name="mediaRouteTheme">@style/AppTheme.MediaRouteTheme</item>
</style> </style>
<style name="AppTheme.Fragment"></style> <style name="AppTheme.Fragment"></style>
@ -101,12 +101,4 @@
<item name="android:textColor">@android:color/white</item> <item name="android:textColor">@android:color/white</item>
</style> </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> </resources>