play flavor converted

This commit is contained in:
Xilin Jia 2024-02-14 20:18:39 +01:00
parent 6baf126a0d
commit 82c498c466
37 changed files with 1550 additions and 1647 deletions

View File

@ -64,7 +64,7 @@ class UITestUtils(private val context: Context) {
@Throws(IOException::class)
fun hostFeed(feed: Feed): String {
val feedFile = File(hostedFeedDir, feed.title)
val feedFile = File(hostedFeedDir, feed.title?:"")
val out = FileOutputStream(feedFile)
val generator = Rss2Generator()
generator.writeFeed(feed, out, "UTF-8", 0)
@ -171,7 +171,7 @@ class UITestUtils(private val context: Context) {
for (feed in hostedFeeds) {
feed.setDownloaded(true)
if (downloadEpisodes) {
for (item in feed.items!!) {
for (item in feed.items) {
if (item.hasMedia()) {
val media = item.media
val fileId = StringUtils.substringAfter(media!!.download_url, "files/").toInt()
@ -181,9 +181,9 @@ class UITestUtils(private val context: Context) {
}
}
queue.add(feed.items!![0])
if (feed.items!![1].hasMedia()) {
feed.items!![1].media!!.setPlaybackCompletionDate(Date())
queue.add(feed.items[0])
if (feed.items[1].hasMedia()) {
feed.items[1].media!!.setPlaybackCompletionDate(Date())
}
}
localFeedDataAdded = true

View File

@ -11,10 +11,12 @@ import ac.mdiq.podvinci.core.storage.DBWriter
import ac.mdiq.podvinci.core.util.download.FeedUpdateManager.runOnce
import ac.mdiq.podvinci.databinding.EditTextDialogBinding
import ac.mdiq.podvinci.model.feed.Feed
import androidx.media3.common.util.UnstableApi
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.ExecutionException
@UnstableApi
abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed) {
private val activityRef = WeakReference(activity)
@ -33,7 +35,7 @@ abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed)
.show()
}
private fun onConfirmed(original: String, updated: String) {
@UnstableApi private fun onConfirmed(original: String, updated: String) {
try {
DBWriter.updateFeedDownloadURL(original, updated).get()
feed.download_url = updated
@ -45,7 +47,7 @@ abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed)
}
}
private fun showConfirmAlertDialog(url: String) {
@UnstableApi private fun showConfirmAlertDialog(url: String) {
val activity = activityRef.get()
val alertDialog = MaterialAlertDialogBuilder(activity!!)

View File

@ -1,6 +1,19 @@
package ac.mdiq.podvinci.fragment
import ac.mdiq.podvinci.R
import ac.mdiq.podvinci.activity.MainActivity
import ac.mdiq.podvinci.core.storage.DBReader
import ac.mdiq.podvinci.core.storage.DBTasks
import ac.mdiq.podvinci.core.util.IntentUtils
import ac.mdiq.podvinci.core.util.ShareUtils
import ac.mdiq.podvinci.core.util.syndication.HtmlToPlainText
import ac.mdiq.podvinci.dialog.EditUrlSettingsDialog
import ac.mdiq.podvinci.model.feed.Feed
import ac.mdiq.podvinci.model.feed.FeedFunding
import ac.mdiq.podvinci.ui.glide.FastBlurTransformation
import ac.mdiq.podvinci.ui.statistics.StatisticsFragment
import ac.mdiq.podvinci.ui.statistics.feed.FeedStatisticsFragment
import ac.mdiq.podvinci.view.ToolbarIconTintManager
import android.R.string
import android.app.Activity
import android.content.*
@ -31,23 +44,9 @@ import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podvinci.R
import ac.mdiq.podvinci.core.storage.DBReader
import ac.mdiq.podvinci.core.storage.DBTasks
import ac.mdiq.podvinci.core.util.IntentUtils
import ac.mdiq.podvinci.core.util.ShareUtils
import ac.mdiq.podvinci.core.util.syndication.HtmlToPlainText
import ac.mdiq.podvinci.dialog.EditUrlSettingsDialog
import ac.mdiq.podvinci.model.feed.Feed
import ac.mdiq.podvinci.model.feed.FeedFunding
import ac.mdiq.podvinci.ui.glide.FastBlurTransformation
import ac.mdiq.podvinci.ui.statistics.StatisticsFragment
import ac.mdiq.podvinci.ui.statistics.feed.FeedStatisticsFragment
import ac.mdiq.podvinci.view.ToolbarIconTintManager
import io.reactivex.Completable
import io.reactivex.Maybe
import io.reactivex.MaybeEmitter
import io.reactivex.MaybeOnSubscribe
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
@ -148,14 +147,14 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val feedId = requireArguments().getLong(EXTRA_FEED_ID)
disposable = Maybe.create<Feed>(MaybeOnSubscribe<Feed> { emitter: MaybeEmitter<Feed?> ->
disposable = Maybe.create { emitter: MaybeEmitter<Feed?> ->
val feed: Feed? = DBReader.getFeed(feedId)
if (feed != null) {
emitter.onSuccess(feed)
} else {
emitter.onComplete()
}
} as MaybeOnSubscribe<Feed?>?)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result: Feed? ->
@ -211,12 +210,12 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
txtvUrl?.text = feed!!.download_url
txtvUrl?.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0)
if (feed!!.paymentLinks == null || feed!!.paymentLinks!!.size == 0) {
if (feed!!.paymentLinks.isEmpty()) {
lblSupport?.visibility = View.GONE
txtvFundingUrl?.visibility = View.GONE
} else {
lblSupport?.visibility = View.VISIBLE
val fundingList: ArrayList<FeedFunding> = feed!!.paymentLinks!!
val fundingList: ArrayList<FeedFunding> = feed!!.paymentLinks
// Filter for duplicates, but keep items in the order that they have in the feed.
val i: MutableIterator<FeedFunding> = fundingList.iterator()
@ -311,8 +310,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Completable.fromAction {
requireActivity().contentResolver
.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val documentFile = DocumentFile.fromTreeUri(
requireContext(), uri)
val documentFile = DocumentFile.fromTreeUri(requireContext(), uri)
requireNotNull(documentFile) { "Unable to retrieve document tree" }
feed?.download_url = Feed.PREFIX_LOCAL_FOLDER + uri.toString()
DBTasks.updateFeed(requireContext(), feed!!, true)

View File

@ -1,123 +0,0 @@
package ac.mdiq.podvinci.dialog;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.VisibleForTesting;
import android.util.Log;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;
import com.google.android.play.core.review.ReviewInfo;
import com.google.android.play.core.review.ReviewManager;
import com.google.android.play.core.review.ReviewManagerFactory;
import com.google.android.play.core.tasks.Task;
import ac.mdiq.podvinci.BuildConfig;
public class RatingDialog {
private RatingDialog() {
}
private static final String TAG = RatingDialog.class.getSimpleName();
private static final int AFTER_DAYS = 14;
private static WeakReference<Context> mContext;
private static SharedPreferences mPreferences;
private static final String PREFS_NAME = "RatingPrefs";
private static final String KEY_RATED = "KEY_WAS_RATED";
private static final String KEY_FIRST_START_DATE = "KEY_FIRST_HIT_DATE";
private static final String KEY_NUMBER_OF_REVIEWS = "NUMBER_OF_REVIEW_ATTEMPTS";
public static void init(Context context) {
mContext = new WeakReference<>(context);
mPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
long firstDate = mPreferences.getLong(KEY_FIRST_START_DATE, 0);
if (firstDate == 0) {
resetStartDate();
}
}
public static void check() {
if (shouldShow()) {
try {
showInAppReview();
} catch (Exception e) {
Log.e(TAG, Log.getStackTraceString(e));
}
}
}
private static void showInAppReview() {
Context context = mContext.get();
if (context == null) {
return;
}
ReviewManager manager = ReviewManagerFactory.create(context);
Task<ReviewInfo> request = manager.requestReviewFlow();
request.addOnCompleteListener(task -> {
if (task.isSuccessful()) {
ReviewInfo reviewInfo = task.getResult();
Task<Void> flow = manager.launchReviewFlow((Activity) context, reviewInfo);
flow.addOnCompleteListener(task1 -> {
int previousAttempts = mPreferences.getInt(KEY_NUMBER_OF_REVIEWS, 0);
if (previousAttempts >= 3) {
saveRated();
} else {
resetStartDate();
mPreferences
.edit()
.putInt(KEY_NUMBER_OF_REVIEWS, previousAttempts + 1)
.apply();
}
Log.i("ReviewDialog", "Successfully finished in-app review");
})
.addOnFailureListener(error -> {
Log.i("ReviewDialog", "failed in reviewing process");
});
}
})
.addOnFailureListener(error -> {
Log.i("ReviewDialog", "failed to get in-app review request");
});
}
private static boolean rated() {
return mPreferences.getBoolean(KEY_RATED, false);
}
@VisibleForTesting
public static void saveRated() {
mPreferences
.edit()
.putBoolean(KEY_RATED, true)
.apply();
}
private static void resetStartDate() {
mPreferences
.edit()
.putLong(KEY_FIRST_START_DATE, System.currentTimeMillis())
.apply();
}
private static boolean shouldShow() {
if (rated() || BuildConfig.DEBUG) {
return false;
}
long now = System.currentTimeMillis();
long firstDate = mPreferences.getLong(KEY_FIRST_START_DATE, now);
long diff = now - firstDate;
long diffDays = TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS);
return diffDays >= AFTER_DAYS;
}
}

View File

@ -0,0 +1,111 @@
package ac.mdiq.podvinci.dialog
import ac.mdiq.podvinci.BuildConfig
import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.google.android.play.core.review.ReviewInfo
import com.google.android.play.core.review.ReviewManager
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.android.play.core.tasks.Task
import java.lang.ref.WeakReference
import java.util.concurrent.TimeUnit
object RatingDialog {
private val TAG: String = RatingDialog::class.java.simpleName
private const val AFTER_DAYS = 14
private var mContext: WeakReference<Context>? = null
private lateinit var mPreferences: SharedPreferences
private const val PREFS_NAME = "RatingPrefs"
private const val KEY_RATED = "KEY_WAS_RATED"
private const val KEY_FIRST_START_DATE = "KEY_FIRST_HIT_DATE"
private const val KEY_NUMBER_OF_REVIEWS = "NUMBER_OF_REVIEW_ATTEMPTS"
fun init(context: Context) {
mContext = WeakReference(context)
mPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val firstDate: Long = mPreferences.getLong(KEY_FIRST_START_DATE, 0)
if (firstDate == 0L) {
resetStartDate()
}
}
fun check() {
if (shouldShow()) {
try {
showInAppReview()
} catch (e: Exception) {
Log.e(TAG, Log.getStackTraceString(e))
}
}
}
private fun showInAppReview() {
val context = mContext!!.get() ?: return
val manager: ReviewManager = ReviewManagerFactory.create(context)
val request: Task<ReviewInfo> = manager.requestReviewFlow()
request.addOnCompleteListener { task: Task<ReviewInfo?> ->
if (task.isSuccessful) {
val reviewInfo: ReviewInfo = task.result
val flow: Task<Void?> = manager.launchReviewFlow(context as Activity, reviewInfo)
flow.addOnCompleteListener { task1: Task<Void?>? ->
val previousAttempts: Int = mPreferences.getInt(KEY_NUMBER_OF_REVIEWS, 0)
if (previousAttempts >= 3) {
saveRated()
} else {
resetStartDate()
mPreferences
.edit()
.putInt(KEY_NUMBER_OF_REVIEWS, previousAttempts + 1)
.apply()
}
Log.i("ReviewDialog", "Successfully finished in-app review")
}
.addOnFailureListener { error: Exception? ->
Log.i("ReviewDialog", "failed in reviewing process")
}
}
}
.addOnFailureListener { error: Exception? ->
Log.i("ReviewDialog", "failed to get in-app review request")
}
}
private fun rated(): Boolean {
return mPreferences.getBoolean(KEY_RATED, false)
}
@VisibleForTesting
fun saveRated() {
mPreferences
.edit()
.putBoolean(KEY_RATED, true)
.apply()
}
private fun resetStartDate() {
mPreferences
.edit()
.putLong(KEY_FIRST_START_DATE, System.currentTimeMillis())
.apply()
}
private fun shouldShow(): Boolean {
if (rated() || BuildConfig.DEBUG) {
return false
}
val now = System.currentTimeMillis()
val firstDate: Long = mPreferences.getLong(KEY_FIRST_START_DATE, now)
val diff = now - firstDate
val diffDays = TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS)
return diffDays >= AFTER_DAYS
}
}

View File

@ -43,7 +43,7 @@ object LocalFeedUpdater {
fun updateFeed(feed: Feed, context: Context,
updaterProgressListener: UpdaterProgressListener?
) {
if (feed.download_url == null) return
if (feed.download_url.isNullOrEmpty()) return
try {
val uriString = feed.download_url!!.replace(Feed.PREFIX_LOCAL_FOLDER, "")
val documentFolder = DocumentFile.fromTreeUri(context, Uri.parse(uriString))
@ -71,9 +71,6 @@ object LocalFeedUpdater {
updaterProgressListener: UpdaterProgressListener?
) {
var feed = feed
if (feed.items == null) {
feed.items = mutableListOf()
}
//make sure it is the latest 'version' of this feed from the db (all items etc)
feed = DBTasks.updateFeed(context, feed, false)?: feed

View File

@ -31,27 +31,29 @@ class BasicAuthorizationInterceptor : Interceptor {
newRequest.url(response.request.url)
val authorizationHeaders = request.headers.values(HEADER_AUTHORIZATION)
if (!authorizationHeaders.isEmpty() && !TextUtils.isEmpty(authorizationHeaders[0])) {
if (authorizationHeaders.isNotEmpty() && !TextUtils.isEmpty(authorizationHeaders[0])) {
// Call already had authorization headers. Try again with the same credentials.
newRequest.header(HEADER_AUTHORIZATION, authorizationHeaders[0])
return chain.proceed(newRequest.build())
}
}
var userInfo: String
var userInfo = ""
if (request.tag() is DownloadRequest) {
val downloadRequest = request.tag() as DownloadRequest?
userInfo = URIUtil.getURIFromRequestUrl(downloadRequest!!.source).userInfo
val downloadRequest = request.tag() as? DownloadRequest
if (downloadRequest?.source != null) {
userInfo = URIUtil.getURIFromRequestUrl(downloadRequest.source!!).userInfo
if (TextUtils.isEmpty(userInfo)
&& (!TextUtils.isEmpty(downloadRequest.username)
|| !TextUtils.isEmpty(downloadRequest.password))) {
userInfo = downloadRequest.username + ":" + downloadRequest.password
}
}
} else {
userInfo = DBReader.getImageAuthentication(request.url.toString())
}
if (TextUtils.isEmpty(userInfo)) {
if (userInfo.isEmpty()) {
Log.d(TAG, "no credentials for '" + request.url + "'")
return response
}

View File

@ -40,7 +40,7 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
ClientConfigurator.initialize(applicationContext)
newEpisodesNotification.loadCountersBeforeRefresh()
val toUpdate: MutableList<Feed?>
val toUpdate: MutableList<Feed>
val feedId = inputData.getLong(FeedUpdateManager.EXTRA_FEED_ID, -1)
var allAreLocal = true
var force = false
@ -49,7 +49,7 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
val itr = toUpdate.iterator()
while (itr.hasNext()) {
val feed = itr.next()
if (feed!!.preferences?.keepUpdated == false) {
if (feed.preferences?.keepUpdated == false) {
itr.remove()
}
if (!feed.isLocalFeed) {
@ -105,7 +105,7 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
return Futures.immediateFuture(ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)))
}
private fun refreshFeeds(toUpdate: MutableList<Feed?>, force: Boolean) {
@UnstableApi private fun refreshFeeds(toUpdate: MutableList<Feed>, force: Boolean) {
while (toUpdate.isNotEmpty()) {
if (isStopped) {
return
@ -124,13 +124,13 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate))
val feed = toUpdate[0]
try {
if (feed!!.isLocalFeed) {
if (feed.isLocalFeed) {
LocalFeedUpdater.updateFeed(feed, applicationContext, null)
} else {
refreshFeed(feed, force)
}
} catch (e: Exception) {
DBWriter.setFeedLastUpdateFailed(feed!!.id, true)
DBWriter.setFeedLastUpdateFailed(feed.id, true)
val status = DownloadResult(feed, feed.title?:"",
DownloadError.ERROR_IO_ERROR, false, e.message?:"")
DBWriter.addDownloadStatus(status)
@ -139,22 +139,21 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
}
}
@Throws(Exception::class)
fun refreshFeed(feed: Feed?, force: Boolean) {
@UnstableApi @Throws(Exception::class)
fun refreshFeed(feed: Feed, force: Boolean) {
val nextPage = (inputData.getBoolean(FeedUpdateManager.EXTRA_NEXT_PAGE, false)
&& feed!!.nextPageLink != null)
&& feed.nextPageLink != null)
if (nextPage) {
feed!!.pageNr = feed.pageNr + 1
feed.pageNr += 1
}
val builder = create(feed!!)
val builder = create(feed)
builder.setForce(force || feed.hasLastUpdateFailed())
if (nextPage) {
builder.source = feed.nextPageLink
}
val request = builder.build()
val downloader = DefaultDownloaderFactory().create(request)
?: throw Exception("Unable to create downloader")
val downloader = DefaultDownloaderFactory().create(request) ?: throw Exception("Unable to create downloader")
downloader.call()
@ -185,10 +184,10 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont
DBWriter.addDownloadStatus(feedSyncTask.downloadStatus)
}
newEpisodesNotification.showIfNeeded(applicationContext, feedSyncTask.savedFeed!!)
if (request.source != null) {
if (downloader.permanentRedirectUrl != null) {
if (!request.source.isNullOrEmpty()) {
if (!downloader.permanentRedirectUrl.isNullOrEmpty()) {
DBWriter.updateFeedDownloadURL(request.source!!, downloader.permanentRedirectUrl!!)
} else if (feedSyncTask.redirectUrl != null && feedSyncTask.redirectUrl != request.source) {
} else if (feedSyncTask.redirectUrl != request.source) {
DBWriter.updateFeedDownloadURL(request.source!!, feedSyncTask.redirectUrl)
}
}

View File

@ -36,11 +36,10 @@ object DownloadRequestCreator {
@JvmStatic
fun create(media: FeedMedia): DownloadRequest.Builder {
val partiallyDownloadedFileExists =
media.file_url != null && File(media.file_url).exists()
val partiallyDownloadedFileExists = media.file_url != null && File(media.file_url!!).exists()
var dest: File
dest = if (partiallyDownloadedFileExists) {
File(media.file_url)
File(media.file_url!!)
} else {
File(getMediafilePath(media), getMediafilename(media))
}
@ -60,8 +59,7 @@ object DownloadRequestCreator {
// find different name
var newDest: File? = null
for (i in 1 until Int.MAX_VALUE) {
val newName = (FilenameUtils.getBaseName(dest
.name)
val newName = (FilenameUtils.getBaseName(dest.name)
+ "-"
+ i
+ FilenameUtils.EXTENSION_SEPARATOR

View File

@ -25,9 +25,11 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.*
class HttpDownloader(request: DownloadRequest?) : Downloader(request!!) {
class HttpDownloader(request: DownloadRequest) : Downloader(request) {
override fun download() {
val destination = File(downloadRequest.destination)
if (downloadRequest.source == null || downloadRequest.destination == null) return
val destination = File(downloadRequest.destination!!)
val fileExists = destination.exists()
var out: RandomAccessFile? = null
@ -35,7 +37,7 @@ class HttpDownloader(request: DownloadRequest?) : Downloader(request!!) {
var responseBody: ResponseBody? = null
try {
val uri = getURIFromRequestUrl(downloadRequest.source)
val uri = getURIFromRequestUrl(downloadRequest.source!!)
val httpReq: Request.Builder = Request.Builder().url(uri.toURL())
httpReq.tag(downloadRequest)
httpReq.cacheControl(CacheControl.Builder().noStore().build())
@ -52,7 +54,7 @@ class HttpDownloader(request: DownloadRequest?) : Downloader(request!!) {
httpReq.addHeader("Upgrade-Insecure-Requests", "1")
}
if (!TextUtils.isEmpty(downloadRequest.lastModified)) {
if (!downloadRequest.lastModified.isNullOrEmpty()) {
val lastModified = downloadRequest.lastModified
val lastModifiedDate = parse(lastModified)
if (lastModifiedDate != null) {

View File

@ -82,7 +82,7 @@ import ac.mdiq.podvinci.playback.base.PlaybackServiceMediaPlayer
import ac.mdiq.podvinci.playback.base.PlaybackServiceMediaPlayer.PSMPCallback
import ac.mdiq.podvinci.playback.base.PlaybackServiceMediaPlayer.PSMPInfo
import ac.mdiq.podvinci.playback.base.PlayerStatus
import ac.mdiq.podvinci.playback.cast.CastPsmp.getInstanceIfConnected
import ac.mdiq.podvinci.playback.cast.CastPsmp
import ac.mdiq.podvinci.playback.cast.CastStateListener
import ac.mdiq.podvinci.storage.preferences.UserPreferences.allEpisodesSortOrder
import ac.mdiq.podvinci.storage.preferences.UserPreferences.downloadsSortedOrder
@ -221,7 +221,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
mediaPlayer!!.pause(true, false)
mediaPlayer!!.shutdown()
}
mediaPlayer = getInstanceIfConnected(this, mediaPlayerCallback)
mediaPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback)
if (mediaPlayer == null) {
mediaPlayer = LocalPSMP(this, mediaPlayerCallback) // Cast not supported or not connected
}
@ -1204,7 +1204,7 @@ class PlaybackService : MediaBrowserServiceCompat() {
)
}
WearMediaSession.mediaSessionSetExtraForWear(mediaSession)
WearMediaSession.mediaSessionSetExtraForWear(mediaSession!!)
mediaSession!!.setPlaybackState(sessionState.build())
}

View File

@ -116,7 +116,7 @@ import java.util.concurrent.TimeUnit
localDelete = true
} else if (media.getFile_url() != null) {
// delete downloaded media file
val mediaFile = File(media.getFile_url())
val mediaFile = File(media.getFile_url()!!)
if (mediaFile.exists() && !mediaFile.delete()) {
val evt = MessageEvent(context.getString(R.string.delete_failed))
EventBus.getDefault().post(evt)
@ -167,10 +167,10 @@ import java.util.concurrent.TimeUnit
return runOnDbThread {
val feed = getFeed(feedId) ?: return@runOnDbThread
// delete stored media files and mark them as read
if (feed.items == null) {
if (feed.items.isEmpty()) {
getFeedItemList(feed)
}
deleteFeedItemsSynchronous(context, feed.items!!)
deleteFeedItemsSynchronous(context, feed.items)
// delete feed
val adapter = getInstance()
@ -333,7 +333,6 @@ import java.util.concurrent.TimeUnit
val queue = getQueue(adapter).toMutableList()
val item: FeedItem?
if (queue != null) {
if (!itemListContains(queue, itemId)) {
item = getFeedItem(itemId)
if (item != null) {
@ -347,7 +346,6 @@ import java.util.concurrent.TimeUnit
}
}
}
}
adapter?.close()
if (performAutoDownload) {
@ -523,7 +521,6 @@ import java.util.concurrent.TimeUnit
adapter?.open()
val queue = getQueue(adapter).toMutableList()
if (queue != null) {
var queueModified = false
val events: MutableList<QueueEvent> = ArrayList()
val updatedItems: MutableList<FeedItem> = ArrayList()
@ -554,9 +551,6 @@ import java.util.concurrent.TimeUnit
} else {
Log.w(TAG, "Queue was not modified by call to removeQueueItem")
}
} else {
Log.e(TAG, "removeQueueItem: Could not load queue")
}
adapter?.close()
if (performAutoDownload) {
autodownloadUndownloadedItems(context)
@ -954,15 +948,11 @@ import java.util.concurrent.TimeUnit
adapter!!.open()
val queue = getQueue(adapter).toMutableList()
if (queue != null) {
permutor.reorder(queue)
adapter.setQueue(queue)
if (broadcastUpdate) {
EventBus.getDefault().post(QueueEvent.sorted(queue))
}
} else {
Log.e(TAG, "reorderQueue: Could not load queue")
}
adapter.close()
}
}

View File

@ -14,7 +14,7 @@ object URIUtil {
private const val TAG = "URIUtil"
@JvmStatic
fun getURIFromRequestUrl(source: String?): URI {
fun getURIFromRequestUrl(source: String): URI {
// try without encoding the URI
try {
return URI(source)
@ -25,8 +25,10 @@ object URIUtil {
val url = URL(source)
return URI(url.protocol, url.userInfo, url.host, url.port, url.path, url.query, url.ref)
} catch (e: MalformedURLException) {
Log.d(TAG, "source: $source")
throw IllegalArgumentException(e)
} catch (e: URISyntaxException) {
Log.d(TAG, "source: $source")
throw IllegalArgumentException(e)
}
}

View File

@ -1,25 +0,0 @@
package ac.mdiq.podvinci.core.service.playback;
import android.os.Bundle;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.support.wearable.media.MediaControlConstants;
public class WearMediaSession {
/**
* Take a custom action builder and make sure the custom action shows on Wear OS because this is the Play version
* of the app.
*/
static void addWearExtrasToAction(PlaybackStateCompat.CustomAction.Builder actionBuilder) {
Bundle actionExtras = new Bundle();
actionExtras.putBoolean(MediaControlConstants.EXTRA_CUSTOM_ACTION_SHOW_ON_WEAR, true);
actionBuilder.setExtras(actionExtras);
}
static void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) {
Bundle sessionExtras = new Bundle();
sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_PREVIOUS, false);
sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_NEXT, false);
mediaSession.setExtras(sessionExtras);
}
}

View File

@ -0,0 +1,25 @@
package ac.mdiq.podvinci.core.service.playback
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.support.wearable.media.MediaControlConstants
object WearMediaSession {
/**
* Take a custom action builder and make sure the custom action shows on Wear OS because this is the Play version
* of the app.
*/
fun addWearExtrasToAction(actionBuilder: PlaybackStateCompat.CustomAction.Builder) {
val actionExtras = Bundle()
actionExtras.putBoolean(MediaControlConstants.EXTRA_CUSTOM_ACTION_SHOW_ON_WEAR, true)
actionBuilder.setExtras(actionExtras)
}
fun mediaSessionSetExtraForWear(mediaSession: MediaSessionCompat) {
val sessionExtras = Bundle()
sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_PREVIOUS, false)
sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_NEXT, false)
mediaSession.setExtras(sessionExtras)
}
}

View File

@ -1,41 +0,0 @@
package android.text;
/**
* A slim-down version of standard {@link android.text.TextUtils} to be used in unit tests.
*/
public class TextUtils {
/**
* Returns true if a and b are equal, including if they are both null.
* <p><i>Note: In platform versions 1.1 and earlier, this method only worked well if
* both the arguments were instances of String.</i></p>
* @param a first CharSequence to check
* @param b second CharSequence to check
* @return true if a and b are equal
*/
public static boolean equals(CharSequence a, CharSequence b) {
if (a == b) return true;
int length;
if (a != null && b != null && (length = a.length()) == b.length()) {
if (a instanceof String && b instanceof String) {
return a.equals(b);
} else {
for (int i = 0; i < length; i++) {
if (a.charAt(i) != b.charAt(i)) return false;
}
return true;
}
}
return false;
}
/**
* Returns <code>true</code> if the string is <code>null</code> or has zero length.
*
* @param str The string to be examined, can be <code>null</code>.
* @return <code>true</code> if the string is <code>null</code> or has zero length.
*/
public static boolean isEmpty(CharSequence str) {
return str == null || str.length() == 0;
}
}

View File

@ -0,0 +1,41 @@
package android.text
/**
* A slim-down version of standard [android.text.TextUtils] to be used in unit tests.
*/
object TextUtils {
/**
* Returns true if a and b are equal, including if they are both null.
*
* *Note: In platform versions 1.1 and earlier, this method only worked well if
* both the arguments were instances of String.*
* @param a first CharSequence to check
* @param b second CharSequence to check
* @return true if a and b are equal
*/
fun equals(a: CharSequence?, b: CharSequence?): Boolean {
if (a === b) return true
var length: Int = 0
if ((a != null && b != null) && (a.length.also { length = it }) == b.length) {
if (a is String && b is String) {
return a == b
} else {
for (i in 0 until length) {
if (a[i] != b[i]) return false
}
return true
}
}
return false
}
/**
* Returns `true` if the string is `null` or has zero length.
*
* @param str The string to be examined, can be `null`.
* @return `true` if the string is `null` or has zero length.
*/
fun isEmpty(str: CharSequence?): Boolean {
return str.isNullOrEmpty()
}
}

View File

@ -1,246 +0,0 @@
package android.util;
import java.io.PrintWriter;
import java.io.StringWriter;
/**
* A stub for {@link android.util.Log} to be used in unit tests.
*
* It outputs the log statements to standard error.
*/
public final class Log {
/**
* Priority constant for the println method; use Log.v.
*/
public static final int VERBOSE = 2;
/**
* Priority constant for the println method; use Log.d.
*/
public static final int DEBUG = 3;
/**
* Priority constant for the println method; use Log.i.
*/
public static final int INFO = 4;
/**
* Priority constant for the println method; use Log.w.
*/
public static final int WARN = 5;
/**
* Priority constant for the println method; use Log.e.
*/
public static final int ERROR = 6;
/**
* Priority constant for the println method.
*/
public static final int ASSERT = 7;
private Log() {
}
/**
* Send a {@link #VERBOSE} log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
public static int v(String tag, String msg) {
return println_native(LOG_ID_MAIN, VERBOSE, tag, msg);
}
/**
* Send a {@link #VERBOSE} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
public static int v(String tag, String msg, Throwable tr) {
return printlns(LOG_ID_MAIN, VERBOSE, tag, msg, tr);
}
/**
* Send a {@link #DEBUG} log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
public static int d(String tag, String msg) {
return println_native(LOG_ID_MAIN, DEBUG, tag, msg);
}
/**
* Send a {@link #DEBUG} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
public static int d(String tag, String msg, Throwable tr) {
return printlns(LOG_ID_MAIN, DEBUG, tag, msg, tr);
}
/**
* Send an {@link #INFO} log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
public static int i(String tag, String msg) {
return println_native(LOG_ID_MAIN, INFO, tag, msg);
}
/**
* Send a {@link #INFO} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
public static int i(String tag, String msg, Throwable tr) {
return printlns(LOG_ID_MAIN, INFO, tag, msg, tr);
}
/**
* Send a {@link #WARN} log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
public static int w(String tag, String msg) {
return println_native(LOG_ID_MAIN, WARN, tag, msg);
}
/**
* Send a {@link #WARN} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
public static int w(String tag, String msg, Throwable tr) {
return printlns(LOG_ID_MAIN, WARN, tag, msg, tr);
}
/**
* Checks to see whether or not a log for the specified tag is loggable at the specified level.
*
* @return true in all cases (for unit test environment)
*/
public static boolean isLoggable(String tag, int level) {
return true;
}
/*
* Send a {@link #WARN} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param tr An exception to log
*/
public static int w(String tag, Throwable tr) {
return printlns(LOG_ID_MAIN, WARN, tag, "", tr);
}
/**
* Send an {@link #ERROR} log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
public static int e(String tag, String msg) {
return println_native(LOG_ID_MAIN, ERROR, tag, msg);
}
/**
* Send a {@link #ERROR} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
public static int e(String tag, String msg, Throwable tr) {
return printlns(LOG_ID_MAIN, ERROR, tag, msg, tr);
}
/**
* What a Terrible Failure: Report a condition that should never happen.
* The error will always be logged at level ASSERT with the call stack.
* Depending on system configuration, a report may be added to the
* {@link android.os.DropBoxManager} and/or the process may be terminated
* immediately with an error dialog.
* @param tag Used to identify the source of a log message.
* @param msg The message you would like logged.
*/
public static int wtf(String tag, String msg) {
return wtf(LOG_ID_MAIN, tag, msg, null, false, false);
}
/**
* Like {@link #wtf(String, String)}, but also writes to the log the full
* call stack.
* @hide
*/
public static int wtfStack(String tag, String msg) {
return wtf(LOG_ID_MAIN, tag, msg, null, true, false);
}
/**
* What a Terrible Failure: Report an exception that should never happen.
* Similar to {@link #wtf(String, String)}, with an exception to log.
* @param tag Used to identify the source of a log message.
* @param tr An exception to log.
*/
public static int wtf(String tag, Throwable tr) {
return wtf(LOG_ID_MAIN, tag, tr.getMessage(), tr, false, false);
}
/**
* What a Terrible Failure: Report an exception that should never happen.
* Similar to {@link #wtf(String, Throwable)}, with a message as well.
* @param tag Used to identify the source of a log message.
* @param msg The message you would like logged.
* @param tr An exception to log. May be null.
*/
public static int wtf(String tag, String msg, Throwable tr) {
return wtf(LOG_ID_MAIN, tag, msg, tr, false, false);
}
/**
* Priority Constant for wtf.
* Added for this custom Log implementation, not in android sources.
*/
private static final int WTF = 8;
static int wtf(int logId, String tag, String msg, Throwable tr, boolean localStack,
boolean system) {
return printlns(LOG_ID_MAIN, WTF, tag, msg, tr);
}
private static final int LOG_ID_MAIN = 0;
private static final String[] PRIORITY_ABBREV = { "0", "1", "V", "D", "I", "W", "E", "A", "WTF" };
private static int println_native(int bufID, int priority, String tag, String msg) {
String res = PRIORITY_ABBREV[priority] + "/" + tag + " " + msg + System.lineSeparator();
System.err.print(res);
return res.length();
}
private static int printlns(int bufID, int priority, String tag, String msg,
Throwable tr) {
StringWriter trSW = new StringWriter();
if (tr != null) {
trSW.append(" , Exception: ");
PrintWriter trPW = new PrintWriter(trSW);
tr.printStackTrace(trPW);
trPW.flush();
}
return println_native(bufID, priority, tag, msg + trSW.toString());
}
}

View File

@ -0,0 +1,243 @@
package android.util
import java.io.PrintWriter
import java.io.StringWriter
/**
* A stub for [android.util.Log] to be used in unit tests.
*
* It outputs the log statements to standard error.
*/
object Log {
/**
* Priority constant for the println method; use Log.v.
*/
const val VERBOSE: Int = 2
/**
* Priority constant for the println method; use Log.d.
*/
const val DEBUG: Int = 3
/**
* Priority constant for the println method; use Log.i.
*/
const val INFO: Int = 4
/**
* Priority constant for the println method; use Log.w.
*/
const val WARN: Int = 5
/**
* Priority constant for the println method; use Log.e.
*/
const val ERROR: Int = 6
/**
* Priority constant for the println method.
*/
const val ASSERT: Int = 7
/**
* Send a [.VERBOSE] log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
fun v(tag: String, msg: String): Int {
return println_native(LOG_ID_MAIN, VERBOSE, tag, msg)
}
/**
* Send a [.VERBOSE] log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
fun v(tag: String, msg: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, VERBOSE, tag, msg, tr)
}
/**
* Send a [.DEBUG] log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
fun d(tag: String, msg: String): Int {
return println_native(LOG_ID_MAIN, DEBUG, tag, msg)
}
/**
* Send a [.DEBUG] log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
fun d(tag: String, msg: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, DEBUG, tag, msg, tr)
}
/**
* Send an [.INFO] log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
fun i(tag: String, msg: String): Int {
return println_native(LOG_ID_MAIN, INFO, tag, msg)
}
/**
* Send a [.INFO] log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
fun i(tag: String, msg: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, INFO, tag, msg, tr)
}
/**
* Send a [.WARN] log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
fun w(tag: String, msg: String): Int {
return println_native(LOG_ID_MAIN, WARN, tag, msg)
}
/**
* Send a [.WARN] log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
fun w(tag: String, msg: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, WARN, tag, msg, tr)
}
/**
* Checks to see whether or not a log for the specified tag is loggable at the specified level.
*
* @return true in all cases (for unit test environment)
*/
fun isLoggable(tag: String?, level: Int): Boolean {
return true
}
/*
* Send a {@link #WARN} log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param tr An exception to log
*/
fun w(tag: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, WARN, tag, "", tr)
}
/**
* Send an [.ERROR] log message.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
*/
fun e(tag: String, msg: String): Int {
return println_native(LOG_ID_MAIN, ERROR, tag, msg)
}
/**
* Send a [.ERROR] log message and log the exception.
* @param tag Used to identify the source of a log message. It usually identifies
* the class or activity where the log call occurs.
* @param msg The message you would like logged.
* @param tr An exception to log
*/
fun e(tag: String, msg: String, tr: Throwable?): Int {
return printlns(LOG_ID_MAIN, ERROR, tag, msg, tr)
}
/**
* What a Terrible Failure: Report a condition that should never happen.
* The error will always be logged at level ASSERT with the call stack.
* Depending on system configuration, a report may be added to the
* [android.os.DropBoxManager] and/or the process may be terminated
* immediately with an error dialog.
* @param tag Used to identify the source of a log message.
* @param msg The message you would like logged.
*/
fun wtf(tag: String, msg: String): Int {
return wtf(LOG_ID_MAIN, tag, msg, null, false, false)
}
/**
* Like [.wtf], but also writes to the log the full
* call stack.
* @hide
*/
fun wtfStack(tag: String, msg: String): Int {
return wtf(LOG_ID_MAIN, tag, msg, null, true, false)
}
/**
* What a Terrible Failure: Report an exception that should never happen.
* Similar to [.wtf], with an exception to log.
* @param tag Used to identify the source of a log message.
* @param tr An exception to log.
*/
fun wtf(tag: String, tr: Throwable): Int {
return wtf(LOG_ID_MAIN, tag, tr.message?:"", tr, false, false)
}
/**
* What a Terrible Failure: Report an exception that should never happen.
* Similar to [.wtf], with a message as well.
* @param tag Used to identify the source of a log message.
* @param msg The message you would like logged.
* @param tr An exception to log. May be null.
*/
fun wtf(tag: String, msg: String, tr: Throwable?): Int {
return wtf(LOG_ID_MAIN, tag, msg, tr, false, false)
}
/**
* Priority Constant for wtf.
* Added for this custom Log implementation, not in android sources.
*/
private const val WTF = 8
fun wtf(logId: Int, tag: String, msg: String, tr: Throwable?, localStack: Boolean,
system: Boolean
): Int {
return printlns(LOG_ID_MAIN, WTF, tag, msg, tr)
}
private const val LOG_ID_MAIN = 0
private val PRIORITY_ABBREV = arrayOf("0", "1", "V", "D", "I", "W", "E", "A", "WTF")
private fun println_native(bufID: Int, priority: Int, tag: String, msg: String): Int {
val res: String = PRIORITY_ABBREV[priority] + "/" + tag + " " + msg + System.lineSeparator()
System.err.print(res)
return res.length
}
private fun printlns(bufID: Int, priority: Int, tag: String, msg: String,
tr: Throwable?
): Int {
val trSW = StringWriter()
if (tr != null) {
trSW.append(" , Exception: ")
val trPW = PrintWriter(trSW)
tr.printStackTrace(trPW)
trPW.flush()
}
return println_native(bufID, priority, tag, msg + trSW.toString())
}
}

View File

@ -232,11 +232,11 @@ class Feed : FeedFile {
* try to return the title. If the title is not given, it will use the link
* of the feed.
*/
get() = if (feedIdentifier != null && feedIdentifier!!.isNotEmpty()) {
get() = if (!feedIdentifier.isNullOrEmpty()) {
feedIdentifier
} else if (download_url != null && download_url!!.isNotEmpty()) {
} else if (!download_url.isNullOrEmpty()) {
download_url
} else if (feedTitle != null && feedTitle!!.isNotEmpty()) {
} else if (!feedTitle.isNullOrEmpty()) {
feedTitle
} else {
link

View File

@ -142,7 +142,7 @@ class DownloadRequest private constructor(@JvmField val destination: String?,
constructor(destination: String, media: FeedMedia) {
this.destination = destination
this.source = prepareUrl(media.download_url!!)
this.source = if (media.download_url != null) prepareUrl(media.download_url!!) else null
this.title = media.getHumanReadableIdentifier()
this.feedfileId = media.id
this.feedfileType = media.getTypeAsInt()
@ -150,7 +150,7 @@ class DownloadRequest private constructor(@JvmField val destination: String?,
constructor(destination: String, feed: Feed) {
this.destination = destination
this.source = if (feed.isLocalFeed) feed.download_url else prepareUrl(feed.download_url!!)
this.source = if (feed.isLocalFeed) feed.download_url else if (feed.download_url != null) prepareUrl(feed.download_url!!) else null
this.title = feed.getHumanReadableIdentifier()
this.feedfileId = feed.id
this.feedfileType = feed.getTypeAsInt()

View File

@ -1,20 +0,0 @@
package ac.mdiq.podvinci.net.ssl;
import android.content.Context;
import com.google.android.gms.common.GoogleApiAvailability;
import com.google.android.gms.common.GooglePlayServicesNotAvailableException;
import com.google.android.gms.common.GooglePlayServicesRepairableException;
import com.google.android.gms.security.ProviderInstaller;
public class SslProviderInstaller {
public static void install(Context context) {
try {
ProviderInstaller.installIfNeeded(context);
} catch (GooglePlayServicesRepairableException e) {
e.printStackTrace();
GoogleApiAvailability.getInstance().showErrorNotification(context, e.getConnectionStatusCode());
} catch (GooglePlayServicesNotAvailableException e) {
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,20 @@
package ac.mdiq.podvinci.net.ssl
import android.content.Context
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller
object SslProviderInstaller {
fun install(context: Context) {
try {
ProviderInstaller.installIfNeeded(context)
} catch (e: GooglePlayServicesRepairableException) {
e.printStackTrace()
GoogleApiAvailability.getInstance().showErrorNotification(context, e.connectionStatusCode)
} catch (e: GooglePlayServicesNotAvailableException) {
e.printStackTrace()
}
}
}

View File

@ -1,57 +0,0 @@
package ac.mdiq.podvinci.playback.base;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Tests for {@link RewindAfterPauseUtils}.
*/
public class RewindAfterPauseUtilTest {
@Test
public void testCalculatePositionWithRewindNoRewind() {
final int ORIGINAL_POSITION = 10000;
long lastPlayed = System.currentTimeMillis();
int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
assertEquals(ORIGINAL_POSITION, position);
}
@Test
public void testCalculatePositionWithRewindSmallRewind() {
final int ORIGINAL_POSITION = 10000;
long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_SHORT_REWIND - 1000;
int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.SHORT_REWIND, position);
}
@Test
public void testCalculatePositionWithRewindMediumRewind() {
final int ORIGINAL_POSITION = 10000;
long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_MEDIUM_REWIND - 1000;
int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.MEDIUM_REWIND, position);
}
@Test
public void testCalculatePositionWithRewindLongRewind() {
final int ORIGINAL_POSITION = 30000;
long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000;
int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.LONG_REWIND, position);
}
@Test
public void testCalculatePositionWithRewindNegativeNumber() {
final int ORIGINAL_POSITION = 100;
long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000;
int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed);
assertEquals(0, position);
}
}

View File

@ -0,0 +1,56 @@
package ac.mdiq.podvinci.playback.base
import ac.mdiq.podvinci.playback.base.RewindAfterPauseUtils.calculatePositionWithRewind
import org.junit.Assert
import org.junit.Test
/**
* Tests for [RewindAfterPauseUtils].
*/
class RewindAfterPauseUtilTest {
@Test
fun testCalculatePositionWithRewindNoRewind() {
val ORIGINAL_POSITION = 10000
val lastPlayed = System.currentTimeMillis()
val position = calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed)
Assert.assertEquals(ORIGINAL_POSITION.toLong(), position.toLong())
}
@Test
fun testCalculatePositionWithRewindSmallRewind() {
val ORIGINAL_POSITION = 10000
val lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_SHORT_REWIND - 1000
val position = calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed)
Assert.assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.SHORT_REWIND, position.toLong())
}
@Test
fun testCalculatePositionWithRewindMediumRewind() {
val ORIGINAL_POSITION = 10000
val lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_MEDIUM_REWIND - 1000
val position = calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed)
Assert.assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.MEDIUM_REWIND, position.toLong())
}
@Test
fun testCalculatePositionWithRewindLongRewind() {
val ORIGINAL_POSITION = 30000
val lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000
val position = calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed)
Assert.assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.LONG_REWIND, position.toLong())
}
@Test
fun testCalculatePositionWithRewindNegativeNumber() {
val ORIGINAL_POSITION = 100
val lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000
val position = calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed)
Assert.assertEquals(0, position.toLong())
}
}

View File

@ -1,39 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.os.Bundle;
import android.view.Menu;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.gms.cast.framework.CastButtonFactory;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
/**
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the
* network.
*/
public abstract class CastEnabledActivity extends AppCompatActivity {
private boolean canCast = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS;
if (canCast) {
try {
CastContext.getSharedInstance(this);
} catch (Exception e) {
e.printStackTrace();
canCast = false;
}
}
}
public void requestCastButton(Menu menu) {
if (!canCast) {
return;
}
getMenuInflater().inflate(R.menu.cast_button, menu);
CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, R.id.media_route_menu_item);
}
}

View File

@ -0,0 +1,38 @@
package ac.mdiq.podvinci.playback.cast
import android.os.Bundle
import android.view.Menu
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
/**
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the
* network.
*/
abstract class CastEnabledActivity : AppCompatActivity() {
private var canCast = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
if (canCast) {
try {
CastContext.getSharedInstance(this)
} catch (e: Exception) {
e.printStackTrace()
canCast = false
}
}
}
fun requestCastButton(menu: Menu?) {
if (!canCast) {
return
}
menuInflater.inflate(R.menu.cast_button, menu)
CastButtonFactory.setUpMediaRouteButton(applicationContext, menu!!, R.id.media_route_menu_item)
}
}

View File

@ -1,27 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
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 java.util.List;
@SuppressWarnings("unused")
@SuppressLint("VisibleForTests")
public class CastOptionsProvider implements OptionsProvider {
@Override
@NonNull
public CastOptions getCastOptions(@NonNull Context context) {
return new CastOptions.Builder()
.setReceiverApplicationId("BEBC1DB1")
.build();
}
@Override
public List<SessionProvider> getAdditionalSessionProviders(@NonNull Context context) {
return null;
}
}

View File

@ -0,0 +1,21 @@
package ac.mdiq.podvinci.playback.cast
import android.annotation.SuppressLint
import android.content.Context
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
@Suppress("unused")
@SuppressLint("VisibleForTests")
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
return CastOptions.Builder()
.setReceiverApplicationId("BEBC1DB1")
.build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}

View File

@ -1,551 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import android.util.Log;
import android.util.Pair;
import android.view.SurfaceHolder;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import androidx.annotation.Nullable;
import com.google.android.gms.cast.MediaError;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaLoadOptions;
import com.google.android.gms.cast.MediaLoadRequestData;
import com.google.android.gms.cast.MediaSeekOptions;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.cast.framework.CastState;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import ac.mdiq.podvinci.event.PlayerErrorEvent;
import ac.mdiq.podvinci.event.playback.BufferUpdateEvent;
import ac.mdiq.podvinci.model.feed.FeedMedia;
import ac.mdiq.podvinci.model.playback.MediaType;
import ac.mdiq.podvinci.model.playback.Playable;
import ac.mdiq.podvinci.model.playback.RemoteMedia;
import ac.mdiq.podvinci.playback.base.PlaybackServiceMediaPlayer;
import ac.mdiq.podvinci.playback.base.PlayerStatus;
import ac.mdiq.podvinci.playback.base.RewindAfterPauseUtils;
import org.greenrobot.eventbus.EventBus;
/**
* Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices.
*/
@SuppressLint("VisibleForTests")
public class CastPsmp extends PlaybackServiceMediaPlayer {
public static final String TAG = "CastPSMP";
private volatile Playable media;
private volatile MediaType mediaType;
private volatile MediaInfo remoteMedia;
private volatile int remoteState;
private final CastContext castContext;
private final RemoteMediaClient remoteMediaClient;
private final AtomicBoolean isBuffering;
private final AtomicBoolean startWhenPrepared;
@Nullable
public static PlaybackServiceMediaPlayer getInstanceIfConnected(@NonNull Context context,
@NonNull PSMPCallback callback) {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) {
return null;
}
try {
if (CastContext.getSharedInstance(context).getCastState() == CastState.CONNECTED) {
return new CastPsmp(context, callback);
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public CastPsmp(@NonNull Context context, @NonNull PSMPCallback callback) {
super(context, callback);
castContext = CastContext.getSharedInstance(context);
remoteMediaClient = castContext.getSessionManager().getCurrentCastSession().getRemoteMediaClient();
remoteMediaClient.registerCallback(remoteMediaClientCallback);
media = null;
mediaType = null;
startWhenPrepared = new AtomicBoolean(false);
isBuffering = new AtomicBoolean(false);
remoteState = MediaStatus.PLAYER_STATE_UNKNOWN;
}
private final RemoteMediaClient.Callback remoteMediaClientCallback = new RemoteMediaClient.Callback() {
@Override
public void onMetadataUpdated() {
super.onMetadataUpdated();
onRemoteMediaPlayerStatusUpdated();
}
@Override
public void onPreloadStatusUpdated() {
super.onPreloadStatusUpdated();
onRemoteMediaPlayerStatusUpdated();
}
@Override
public void onStatusUpdated() {
super.onStatusUpdated();
onRemoteMediaPlayerStatusUpdated();
}
@Override
public void onMediaError(@NonNull MediaError mediaError) {
EventBus.getDefault().post(new PlayerErrorEvent(mediaError.getReason()));
}
};
private void setBuffering(boolean buffering) {
if (buffering && isBuffering.compareAndSet(false, true)) {
EventBus.getDefault().post(BufferUpdateEvent.started());
} else if (!buffering && isBuffering.compareAndSet(true, false)) {
EventBus.getDefault().post(BufferUpdateEvent.ended());
}
}
private Playable localVersion(MediaInfo info) {
if (info == null || info.getMetadata() == null) {
return null;
}
if (CastUtils.matches(info, media)) {
return media;
}
String streamUrl = info.getMetadata().getString(CastUtils.KEY_STREAM_URL);
return streamUrl == null ? CastUtils.makeRemoteMedia(info) : callback.findMedia(streamUrl);
}
private MediaInfo remoteVersion(Playable playable) {
if (playable == null) {
return null;
}
if (CastUtils.matches(remoteMedia, playable)) {
return remoteMedia;
}
if (playable instanceof FeedMedia) {
return MediaInfoCreator.from((FeedMedia) playable);
}
if (playable instanceof RemoteMedia) {
return MediaInfoCreator.from((RemoteMedia) playable);
}
return null;
}
private void onRemoteMediaPlayerStatusUpdated() {
MediaStatus status = remoteMediaClient.getMediaStatus();
if (status == null) {
Log.d(TAG, "Received null MediaStatus");
return;
} else {
Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState());
}
int state = status.getPlayerState();
int oldState = remoteState;
remoteMedia = status.getMediaInfo();
boolean mediaChanged = !CastUtils.matches(remoteMedia, media);
boolean stateChanged = state != oldState;
if (!mediaChanged && !stateChanged) {
Log.d(TAG, "Both media and state haven't changed, so nothing to do");
return;
}
Playable currentMedia = mediaChanged ? localVersion(remoteMedia) : media;
Playable oldMedia = media;
int position = (int) status.getStreamPosition();
// check for incompatible states
if ((state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_PAUSED)
&& currentMedia == null) {
Log.w(TAG, "RemoteMediaPlayer returned playing or pausing state, but with no media");
state = MediaStatus.PLAYER_STATE_UNKNOWN;
stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN;
}
if (stateChanged) {
remoteState = state;
}
if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING
&& state != MediaStatus.PLAYER_STATE_IDLE) {
callback.onPlaybackPause(null, Playable.INVALID_TIME);
// We don't want setPlayerStatus to handle the onPlaybackPause callback
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
}
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING);
switch (state) {
case MediaStatus.PLAYER_STATE_PLAYING:
if (!stateChanged) {
//These steps are necessary because they won't be performed by setPlayerStatus()
if (position >= 0) {
currentMedia.setPosition(position);
}
currentMedia.onPlaybackStart();
}
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position);
break;
case MediaStatus.PLAYER_STATE_PAUSED:
setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position);
break;
case MediaStatus.PLAYER_STATE_BUFFERING:
setPlayerStatus((mediaChanged || playerStatus == PlayerStatus.PREPARING)
? PlayerStatus.PREPARING : PlayerStatus.SEEKING, currentMedia,
currentMedia != null ? currentMedia.getPosition() : Playable.INVALID_TIME);
break;
case MediaStatus.PLAYER_STATE_IDLE:
int reason = status.getIdleReason();
switch (reason) {
case MediaStatus.IDLE_REASON_CANCELED:
// Essentially means stopped at the request of a user
callback.onPlaybackEnded(null, true);
setPlayerStatus(PlayerStatus.STOPPED, currentMedia);
if (oldMedia != null) {
if (position >= 0) {
oldMedia.setPosition(position);
}
callback.onPostPlayback(oldMedia, false, false, false);
}
// onPlaybackEnded pretty much takes care of updating the UI
return;
case MediaStatus.IDLE_REASON_INTERRUPTED:
// Means that a request to load a different media was sent
// Not sure if currentMedia already reflects the to be loaded one
if (mediaChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING) {
callback.onPlaybackPause(null, Playable.INVALID_TIME);
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
}
setPlayerStatus(PlayerStatus.PREPARING, currentMedia);
break;
case MediaStatus.IDLE_REASON_NONE:
// This probably only happens when we connected but no command has been sent yet.
setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia);
break;
case MediaStatus.IDLE_REASON_FINISHED:
// This is our onCompletionListener...
if (mediaChanged && currentMedia != null) {
media = currentMedia;
}
endPlayback(true, false, true, true);
return;
case MediaStatus.IDLE_REASON_ERROR:
Log.w(TAG, "Got an error status from the Chromecast. "
+ "Skipping, if possible, to the next episode...");
EventBus.getDefault().post(new PlayerErrorEvent("Chromecast error code 1"));
endPlayback(false, false, true, true);
return;
default:
return;
}
break;
case MediaStatus.PLAYER_STATE_UNKNOWN:
if (playerStatus != PlayerStatus.INDETERMINATE || media != currentMedia) {
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia);
}
break;
default:
Log.w(TAG, "Remote media state undetermined!");
}
if (mediaChanged) {
callback.onMediaChanged(true);
if (oldMedia != null) {
callback.onPostPlayback(oldMedia, false, false, currentMedia != null);
}
}
}
@Override
public void playMediaObject(@NonNull final Playable playable, final boolean stream,
final boolean startWhenPrepared, final boolean prepareImmediately) {
Log.d(TAG, "playMediaObject() called");
playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately);
}
/**
* Internal implementation of playMediaObject. This method has an additional parameter that
* allows the caller to force a media player reset even if
* the given playable parameter is the same object as the currently playing media.
*
* @see #playMediaObject(Playable, boolean, boolean, boolean)
*/
private void playMediaObject(@NonNull final Playable playable, final boolean forceReset,
final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) {
if (!CastUtils.isCastable(playable, castContext.getSessionManager().getCurrentCastSession())) {
Log.d(TAG, "media provided is not compatible with cast device");
EventBus.getDefault().post(new PlayerErrorEvent("Media not compatible with cast device"));
Playable nextPlayable = playable;
do {
nextPlayable = callback.getNextInQueue(nextPlayable);
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable,
castContext.getSessionManager().getCurrentCastSession()));
if (nextPlayable != null) {
playMediaObject(nextPlayable, forceReset, stream, startWhenPrepared, prepareImmediately);
}
return;
}
if (media != null) {
if (!forceReset && media.getIdentifier().equals(playable.getIdentifier())
&& playerStatus == PlayerStatus.PLAYING) {
// episode is already playing -> ignore method call
Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing.");
return;
} else {
// set temporarily to pause in order to update list with current position
boolean isPlaying = remoteMediaClient.isPlaying();
int position = (int) remoteMediaClient.getApproximateStreamPosition();
if (isPlaying) {
callback.onPlaybackPause(media, position);
}
if (!media.getIdentifier().equals(playable.getIdentifier())) {
final Playable oldMedia = media;
callback.onPostPlayback(oldMedia, false, false, true);
}
setPlayerStatus(PlayerStatus.INDETERMINATE, null);
}
}
this.media = playable;
remoteMedia = remoteVersion(playable);
this.mediaType = media.getMediaType();
this.startWhenPrepared.set(startWhenPrepared);
setPlayerStatus(PlayerStatus.INITIALIZING, media);
callback.ensureMediaInfoLoaded(media);
callback.onMediaChanged(true);
setPlayerStatus(PlayerStatus.INITIALIZED, media);
if (prepareImmediately) {
prepare();
}
}
@Override
public void resume() {
int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind(
media.getPosition(),
media.getLastPlayedTime());
seekTo(newPosition);
remoteMediaClient.play();
}
@Override
public void pause(boolean abandonFocus, boolean reinit) {
remoteMediaClient.pause();
}
@Override
public void prepare() {
if (playerStatus == PlayerStatus.INITIALIZED) {
Log.d(TAG, "Preparing media player");
setPlayerStatus(PlayerStatus.PREPARING, media);
int position = media.getPosition();
if (position > 0) {
position = RewindAfterPauseUtils.calculatePositionWithRewind(
position,
media.getLastPlayedTime());
}
remoteMediaClient.load(new MediaLoadRequestData.Builder()
.setMediaInfo(remoteMedia)
.setAutoplay(startWhenPrepared.get())
.setCurrentTime(position).build());
}
}
@Override
public void reinit() {
Log.d(TAG, "reinit() called");
if (media != null) {
playMediaObject(media, true, false, startWhenPrepared.get(), false);
} else {
Log.d(TAG, "Call to reinit was ignored: media was null");
}
}
@Override
public void seekTo(int t) {
new Exception("Seeking to " + t).printStackTrace();
remoteMediaClient.seek(new MediaSeekOptions.Builder()
.setPosition(t).build());
}
@Override
public void seekDelta(int d) {
int position = getPosition();
if (position != Playable.INVALID_TIME) {
seekTo(position + d);
} else {
Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta");
}
}
@Override
public int getDuration() {
int retVal = (int) remoteMediaClient.getStreamDuration();
if (retVal == Playable.INVALID_TIME && media != null && media.getDuration() > 0) {
retVal = media.getDuration();
}
return retVal;
}
@Override
public int getPosition() {
int retVal = (int) remoteMediaClient.getApproximateStreamPosition();
if (retVal <= 0 && media != null && media.getPosition() >= 0) {
retVal = media.getPosition();
}
return retVal;
}
@Override
public boolean isStartWhenPrepared() {
return startWhenPrepared.get();
}
@Override
public void setStartWhenPrepared(boolean startWhenPrepared) {
this.startWhenPrepared.set(startWhenPrepared);
}
@Override
public void setPlaybackParams(float speed, boolean skipSilence) {
double playbackRate = (float) Math.max(MediaLoadOptions.PLAYBACK_RATE_MIN,
Math.min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed));
remoteMediaClient.setPlaybackRate(playbackRate);
}
@Override
public float getPlaybackSpeed() {
MediaStatus status = remoteMediaClient.getMediaStatus();
return status != null ? (float) status.getPlaybackRate() : 1.0f;
}
@Override
public void setVolume(float volumeLeft, float volumeRight) {
Log.d(TAG, "Setting the Stream volume on Remote Media Player");
remoteMediaClient.setStreamVolume(volumeLeft);
}
@Override
public MediaType getCurrentMediaType() {
return mediaType;
}
@Override
public boolean isStreaming() {
return true;
}
@Override
public void shutdown() {
remoteMediaClient.unregisterCallback(remoteMediaClientCallback);
}
@Override
public void setVideoSurface(SurfaceHolder surface) {
throw new UnsupportedOperationException("Setting Video Surface unsupported in Remote Media Player");
}
@Override
public void resetVideoSurface() {
Log.e(TAG, "Resetting Video Surface unsupported in Remote Media Player");
}
@Override
public Pair<Integer, Integer> getVideoSize() {
return null;
}
@Override
public Playable getPlayable() {
return media;
}
@Override
protected void setPlayable(Playable playable) {
if (playable != media) {
media = playable;
remoteMedia = remoteVersion(playable);
}
}
@Override
public List<String> getAudioTracks() {
return Collections.emptyList();
}
public void setAudioTrack(int track) {
}
public int getSelectedAudioTrack() {
return -1;
}
@Override
protected void endPlayback(boolean hasEnded, boolean wasSkipped, boolean shouldContinue,
boolean toStoppedState) {
Log.d(TAG, "endPlayback() called");
boolean isPlaying = playerStatus == PlayerStatus.PLAYING;
if (playerStatus != PlayerStatus.INDETERMINATE) {
setPlayerStatus(PlayerStatus.INDETERMINATE, media);
}
if (media != null && wasSkipped) {
// current position only really matters when we skip
int position = getPosition();
if (position >= 0) {
media.setPosition(position);
}
}
final Playable currentMedia = media;
Playable nextMedia = null;
if (shouldContinue) {
nextMedia = callback.getNextInQueue(currentMedia);
boolean playNextEpisode = isPlaying && nextMedia != null;
if (playNextEpisode) {
Log.d(TAG, "Playback of next episode will start immediately.");
} else if (nextMedia == null) {
Log.d(TAG, "No more episodes available to play");
} else {
Log.d(TAG, "Loading next episode, but not playing automatically.");
}
if (nextMedia != null) {
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode);
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing
media = null;
playMediaObject(nextMedia, false, true, playNextEpisode, playNextEpisode);
}
}
if (shouldContinue || toStoppedState) {
if (nextMedia == null) {
remoteMediaClient.stop();
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, false);
} else {
callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, true);
}
} else if (isPlaying) {
callback.onPlaybackPause(currentMedia,
currentMedia != null ? currentMedia.getPosition() : Playable.INVALID_TIME);
}
}
@Override
protected boolean shouldLockWifi() {
return false;
}
@Override
public boolean isCasting() {
return true;
}
}

View File

@ -0,0 +1,511 @@
package ac.mdiq.podvinci.playback.cast
import ac.mdiq.podvinci.event.PlayerErrorEvent
import ac.mdiq.podvinci.event.playback.BufferUpdateEvent
import ac.mdiq.podvinci.model.feed.FeedMedia
import ac.mdiq.podvinci.model.playback.MediaType
import ac.mdiq.podvinci.model.playback.Playable
import ac.mdiq.podvinci.model.playback.RemoteMedia
import ac.mdiq.podvinci.playback.base.PlaybackServiceMediaPlayer
import ac.mdiq.podvinci.playback.base.PlayerStatus
import ac.mdiq.podvinci.playback.base.RewindAfterPauseUtils.calculatePositionWithRewind
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import android.util.Pair
import android.view.SurfaceHolder
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import org.greenrobot.eventbus.EventBus
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile
import kotlin.math.max
import kotlin.math.min
/**
* Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices.
*/
@SuppressLint("VisibleForTests")
class CastPsmp(context: Context, callback: PSMPCallback) : PlaybackServiceMediaPlayer(context, callback) {
@Volatile
private var media: Playable?
@Volatile
private var mediaType: MediaType?
@Volatile
private var remoteMedia: MediaInfo? = null
@Volatile
private var remoteState: Int
private val castContext = CastContext.getSharedInstance(context)
private val remoteMediaClient = castContext.sessionManager.currentCastSession!!.remoteMediaClient
private val isBuffering: AtomicBoolean
private val startWhenPrepared: AtomicBoolean
private val remoteMediaClientCallback: RemoteMediaClient.Callback = object : RemoteMediaClient.Callback() {
override fun onMetadataUpdated() {
super.onMetadataUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onPreloadStatusUpdated() {
super.onPreloadStatusUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onStatusUpdated() {
super.onStatusUpdated()
onRemoteMediaPlayerStatusUpdated()
}
override fun onMediaError(mediaError: MediaError) {
EventBus.getDefault().post(PlayerErrorEvent(mediaError.reason!!))
}
}
init {
remoteMediaClient!!.registerCallback(remoteMediaClientCallback)
media = null
mediaType = null
startWhenPrepared = AtomicBoolean(false)
isBuffering = AtomicBoolean(false)
remoteState = MediaStatus.PLAYER_STATE_UNKNOWN
}
private fun setBuffering(buffering: Boolean) {
if (buffering && isBuffering.compareAndSet(false, true)) {
EventBus.getDefault().post(BufferUpdateEvent.started())
} else if (!buffering && isBuffering.compareAndSet(true, false)) {
EventBus.getDefault().post(BufferUpdateEvent.ended())
}
}
private fun localVersion(info: MediaInfo?): Playable? {
if (info == null || info.metadata == null) {
return null
}
if (CastUtils.matches(info, media)) {
return media
}
val streamUrl = info.metadata!!.getString(CastUtils.KEY_STREAM_URL)
return if (streamUrl == null) CastUtils.makeRemoteMedia(info) else callback.findMedia(streamUrl)
}
private fun remoteVersion(playable: Playable?): MediaInfo? {
if (playable == null) {
return null
}
if (CastUtils.matches(remoteMedia, playable)) {
return remoteMedia
}
if (playable is FeedMedia) {
return MediaInfoCreator.from(playable)
}
if (playable is RemoteMedia) {
return MediaInfoCreator.from(playable)
}
return null
}
private fun onRemoteMediaPlayerStatusUpdated() {
val status = remoteMediaClient!!.mediaStatus
if (status == null) {
Log.d(TAG, "Received null MediaStatus")
return
} else {
Log.d(TAG, "Received remote status/media update. New state=" + status.playerState)
}
var state = status.playerState
val oldState = remoteState
remoteMedia = status.mediaInfo
val mediaChanged = !CastUtils.matches(remoteMedia, media)
var stateChanged = state != oldState
if (!mediaChanged && !stateChanged) {
Log.d(TAG, "Both media and state haven't changed, so nothing to do")
return
}
val currentMedia = if (mediaChanged) localVersion(remoteMedia) else media
val oldMedia = media
val position = status.streamPosition.toInt()
// check for incompatible states
if ((state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_PAUSED)
&& currentMedia == null) {
Log.w(TAG, "RemoteMediaPlayer returned playing or pausing state, but with no media")
state = MediaStatus.PLAYER_STATE_UNKNOWN
stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN
}
if (stateChanged) {
remoteState = state
}
if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && state != MediaStatus.PLAYER_STATE_IDLE) {
callback.onPlaybackPause(null, Playable.INVALID_TIME)
// We don't want setPlayerStatus to handle the onPlaybackPause callback
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
}
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING)
when (state) {
MediaStatus.PLAYER_STATE_PLAYING -> {
if (!stateChanged) {
//These steps are necessary because they won't be performed by setPlayerStatus()
if (position >= 0) {
currentMedia!!.setPosition(position)
}
currentMedia!!.onPlaybackStart()
}
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position)
}
MediaStatus.PLAYER_STATE_PAUSED -> setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position)
MediaStatus.PLAYER_STATE_BUFFERING -> setPlayerStatus(if ((mediaChanged || playerStatus == PlayerStatus.PREPARING)
) PlayerStatus.PREPARING else PlayerStatus.SEEKING, currentMedia,
currentMedia?.getPosition() ?: Playable.INVALID_TIME)
MediaStatus.PLAYER_STATE_IDLE -> {
val reason = status.idleReason
when (reason) {
MediaStatus.IDLE_REASON_CANCELED -> {
// Essentially means stopped at the request of a user
callback.onPlaybackEnded(null, true)
setPlayerStatus(PlayerStatus.STOPPED, currentMedia)
if (oldMedia != null) {
if (position >= 0) {
oldMedia.setPosition(position)
}
callback.onPostPlayback(oldMedia, false, false, false)
}
// onPlaybackEnded pretty much takes care of updating the UI
return
}
MediaStatus.IDLE_REASON_INTERRUPTED -> {
// Means that a request to load a different media was sent
// Not sure if currentMedia already reflects the to be loaded one
if (mediaChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING) {
callback.onPlaybackPause(null, Playable.INVALID_TIME)
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
}
setPlayerStatus(PlayerStatus.PREPARING, currentMedia)
}
MediaStatus.IDLE_REASON_NONE -> // This probably only happens when we connected but no command has been sent yet.
setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia)
MediaStatus.IDLE_REASON_FINISHED -> {
// This is our onCompletionListener...
if (mediaChanged && currentMedia != null) {
media = currentMedia
}
endPlayback(true, false, true, true)
return
}
MediaStatus.IDLE_REASON_ERROR -> {
Log.w(TAG, "Got an error status from the Chromecast. "
+ "Skipping, if possible, to the next episode...")
EventBus.getDefault().post(PlayerErrorEvent("Chromecast error code 1"))
endPlayback(false, false, true, true)
return
}
else -> return
}
}
MediaStatus.PLAYER_STATE_UNKNOWN -> if (playerStatus != PlayerStatus.INDETERMINATE || media !== currentMedia) {
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
}
else -> Log.w(TAG, "Remote media state undetermined!")
}
if (mediaChanged) {
callback.onMediaChanged(true)
if (oldMedia != null) {
callback.onPostPlayback(oldMedia, false, false, currentMedia != null)
}
}
}
override fun playMediaObject(playable: Playable, stream: Boolean,
startWhenPrepared: Boolean, prepareImmediately: Boolean
) {
Log.d(TAG, "playMediaObject() called")
playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately)
}
/**
* Internal implementation of playMediaObject. This method has an additional parameter that
* allows the caller to force a media player reset even if
* the given playable parameter is the same object as the currently playing media.
*
* @see .playMediaObject
*/
private fun playMediaObject(playable: Playable, forceReset: Boolean,
stream: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean
) {
if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) {
Log.d(TAG, "media provided is not compatible with cast device")
EventBus.getDefault().post(PlayerErrorEvent("Media not compatible with cast device"))
var nextPlayable: Playable? = playable
do {
nextPlayable = callback.getNextInQueue(nextPlayable)
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable,
castContext.sessionManager.currentCastSession))
if (nextPlayable != null) {
playMediaObject(nextPlayable, forceReset, stream, startWhenPrepared, prepareImmediately)
}
return
}
if (media != null) {
if (!forceReset && media!!.getIdentifier() == playable.getIdentifier() && playerStatus == PlayerStatus.PLAYING) {
// episode is already playing -> ignore method call
Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing.")
return
} else {
// set temporarily to pause in order to update list with current position
val isPlaying = remoteMediaClient!!.isPlaying
val position = remoteMediaClient.approximateStreamPosition.toInt()
if (isPlaying) {
callback.onPlaybackPause(media, position)
}
if (media != null && media?.getIdentifier() != playable.getIdentifier()) {
val oldMedia: Playable = media!!
callback.onPostPlayback(oldMedia, false, false, true)
}
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}
}
this.media = playable
remoteMedia = remoteVersion(playable)
this.mediaType = media!!.getMediaType()
this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, media)
callback.ensureMediaInfoLoaded(media!!)
callback.onMediaChanged(true)
setPlayerStatus(PlayerStatus.INITIALIZED, media)
if (prepareImmediately) {
prepare()
}
}
override fun resume() {
val newPosition = calculatePositionWithRewind(
media!!.getPosition(),
media!!.getLastPlayedTime())
seekTo(newPosition)
remoteMediaClient!!.play()
}
override fun pause(abandonFocus: Boolean, reinit: Boolean) {
remoteMediaClient!!.pause()
}
override fun prepare() {
if (playerStatus == PlayerStatus.INITIALIZED) {
Log.d(TAG, "Preparing media player")
setPlayerStatus(PlayerStatus.PREPARING, media)
var position = media!!.getPosition()
if (position > 0) {
position = calculatePositionWithRewind(
position,
media!!.getLastPlayedTime())
}
remoteMediaClient!!.load(MediaLoadRequestData.Builder()
.setMediaInfo(remoteMedia)
.setAutoplay(startWhenPrepared.get())
.setCurrentTime(position.toLong()).build())
}
}
override fun reinit() {
Log.d(TAG, "reinit() called")
if (media != null) {
playMediaObject(media!!, true, false, startWhenPrepared.get(), false)
} else {
Log.d(TAG, "Call to reinit was ignored: media was null")
}
}
override fun seekTo(t: Int) {
Exception("Seeking to $t").printStackTrace()
remoteMediaClient!!.seek(MediaSeekOptions.Builder()
.setPosition(t.toLong()).build())
}
override fun seekDelta(d: Int) {
val position = getPosition()
if (position != Playable.INVALID_TIME) {
seekTo(position + d)
} else {
Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta")
}
}
override fun getDuration(): Int {
var retVal = remoteMediaClient!!.streamDuration.toInt()
if (retVal == Playable.INVALID_TIME && media != null && media!!.getDuration() > 0) {
retVal = media!!.getDuration()
}
return retVal
}
override fun getPosition(): Int {
var retVal = remoteMediaClient!!.approximateStreamPosition.toInt()
if (retVal <= 0 && media != null && media!!.getPosition() >= 0) {
retVal = media!!.getPosition()
}
return retVal
}
override fun isStartWhenPrepared(): Boolean {
return startWhenPrepared.get()
}
override fun setStartWhenPrepared(startWhenPrepared: Boolean) {
this.startWhenPrepared.set(startWhenPrepared)
}
override fun setPlaybackParams(speed: Float, skipSilence: Boolean) {
val playbackRate = max(MediaLoadOptions.PLAYBACK_RATE_MIN,
min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed.toDouble())).toFloat().toDouble()
remoteMediaClient!!.setPlaybackRate(playbackRate)
}
override fun getPlaybackSpeed(): Float {
val status = remoteMediaClient!!.mediaStatus
return status?.playbackRate?.toFloat() ?: 1.0f
}
override fun setVolume(volumeLeft: Float, volumeRight: Float) {
Log.d(TAG, "Setting the Stream volume on Remote Media Player")
remoteMediaClient!!.setStreamVolume(volumeLeft.toDouble())
}
override fun getCurrentMediaType(): MediaType? {
return mediaType
}
override fun isStreaming(): Boolean {
return true
}
override fun shutdown() {
remoteMediaClient!!.unregisterCallback(remoteMediaClientCallback)
}
override fun setVideoSurface(surface: SurfaceHolder?) {
throw UnsupportedOperationException("Setting Video Surface unsupported in Remote Media Player")
}
override fun resetVideoSurface() {
Log.e(TAG, "Resetting Video Surface unsupported in Remote Media Player")
}
override fun getVideoSize(): Pair<Int, Int>? {
return null
}
override fun getPlayable(): Playable? {
return media
}
override fun setPlayable(playable: Playable?) {
if (playable !== media) {
media = playable
remoteMedia = remoteVersion(playable)
}
}
override fun getAudioTracks(): List<String> {
return emptyList<String>()
}
override fun setAudioTrack(track: Int) {
}
override fun getSelectedAudioTrack(): Int {
return -1
}
override fun endPlayback(hasEnded: Boolean, wasSkipped: Boolean, shouldContinue: Boolean,
toStoppedState: Boolean
) {
Log.d(TAG, "endPlayback() called")
val isPlaying = playerStatus == PlayerStatus.PLAYING
if (playerStatus != PlayerStatus.INDETERMINATE) {
setPlayerStatus(PlayerStatus.INDETERMINATE, media)
}
if (media != null && wasSkipped) {
// current position only really matters when we skip
val position = getPosition()
if (position >= 0) {
media!!.setPosition(position)
}
}
val currentMedia = media
var nextMedia: Playable? = null
if (shouldContinue) {
nextMedia = callback.getNextInQueue(currentMedia)
val playNextEpisode = isPlaying && nextMedia != null
if (playNextEpisode) {
Log.d(TAG, "Playback of next episode will start immediately.")
} else if (nextMedia == null) {
Log.d(TAG, "No more episodes available to play")
} else {
Log.d(TAG, "Loading next episode, but not playing automatically.")
}
if (nextMedia != null) {
callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode)
// setting media to null signals to playMediaObject() that we're taking care of post-playback processing
media = null
playMediaObject(nextMedia, false, true, playNextEpisode, playNextEpisode)
}
}
if (shouldContinue || toStoppedState) {
if (nextMedia == null) {
remoteMediaClient!!.stop()
// Otherwise we rely on the chromecast callback to tell us the playback has stopped.
callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, false)
} else {
callback.onPostPlayback(currentMedia!!, hasEnded, wasSkipped, true)
}
} else if (isPlaying) {
callback.onPlaybackPause(currentMedia,
currentMedia?.getPosition() ?: Playable.INVALID_TIME)
}
}
override fun shouldLockWifi(): Boolean {
return false
}
override fun isCasting(): Boolean {
return true
}
companion object {
const val TAG: String = "CastPSMP"
fun getInstanceIfConnected(context: Context,
callback: PSMPCallback
): PlaybackServiceMediaPlayer? {
if (GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) {
return null
}
try {
if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) {
return CastPsmp(context, callback)
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
}
}

View File

@ -1,76 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.cast.framework.CastSession;
import com.google.android.gms.cast.framework.SessionManagerListener;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
public class CastStateListener implements SessionManagerListener<CastSession> {
private final CastContext castContext;
public CastStateListener(Context context) {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) {
castContext = null;
return;
}
CastContext castCtx;
try {
castCtx = CastContext.getSharedInstance(context);
castCtx.getSessionManager().addSessionManagerListener(this, CastSession.class);
} catch (Exception e) {
e.printStackTrace();
castCtx = null;
}
castContext = castCtx;
}
public void destroy() {
if (castContext != null) {
castContext.getSessionManager().removeSessionManagerListener(this, CastSession.class);
}
}
@Override
public void onSessionStarting(@NonNull CastSession castSession) {
}
@Override
public void onSessionStarted(@NonNull CastSession session, @NonNull String sessionId) {
onSessionStartedOrEnded();
}
@Override
public void onSessionStartFailed(@NonNull CastSession castSession, int i) {
}
@Override
public void onSessionEnding(@NonNull CastSession castSession) {
}
@Override
public void onSessionResumed(@NonNull CastSession session, boolean wasSuspended) {
}
@Override
public void onSessionResumeFailed(@NonNull CastSession castSession, int i) {
}
@Override
public void onSessionSuspended(@NonNull CastSession castSession, int i) {
}
@Override
public void onSessionEnded(@NonNull CastSession session, int error) {
onSessionStartedOrEnded();
}
@Override
public void onSessionResuming(@NonNull CastSession castSession, @NonNull String s) {
}
public void onSessionStartedOrEnded() {
}
}

View File

@ -0,0 +1,66 @@
package ac.mdiq.podvinci.playback.cast
import android.content.Context
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
open class CastStateListener(context: Context?) : SessionManagerListener<CastSession> {
private var castContext: CastContext?
init {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context!!) != ConnectionResult.SUCCESS) {
castContext = null
} else {
var castCtx: CastContext?
try {
castCtx = CastContext.getSharedInstance(context)
castCtx.sessionManager.addSessionManagerListener(this, CastSession::class.java)
} catch (e: Exception) {
e.printStackTrace()
castCtx = null
}
castContext = castCtx
}
}
fun destroy() {
if (castContext != null) {
castContext!!.sessionManager.removeSessionManagerListener(this, CastSession::class.java)
}
}
override fun onSessionStarting(castSession: CastSession) {
}
override fun onSessionStarted(session: CastSession, sessionId: String) {
onSessionStartedOrEnded()
}
override fun onSessionStartFailed(castSession: CastSession, i: Int) {
}
override fun onSessionEnding(castSession: CastSession) {
}
override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) {
}
override fun onSessionResumeFailed(castSession: CastSession, i: Int) {
}
override fun onSessionSuspended(castSession: CastSession, i: Int) {
}
override fun onSessionEnded(session: CastSession, error: Int) {
onSessionStartedOrEnded()
}
override fun onSessionResuming(castSession: CastSession, s: String) {
}
open fun onSessionStartedOrEnded() {
}
}

View File

@ -1,181 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.content.ContentResolver;
import android.util.Log;
import android.text.TextUtils;
import com.google.android.gms.cast.CastDevice;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.framework.CastSession;
import com.google.android.gms.common.images.WebImage;
import ac.mdiq.podvinci.model.feed.Feed;
import ac.mdiq.podvinci.model.feed.FeedItem;
import ac.mdiq.podvinci.model.feed.FeedMedia;
import ac.mdiq.podvinci.model.playback.Playable;
import ac.mdiq.podvinci.model.playback.RemoteMedia;
import java.util.List;
/**
* Helper functions for Cast support.
*/
public class CastUtils {
private CastUtils() {
}
private static final String TAG = "CastUtils";
public static final String KEY_MEDIA_ID = "ac.mdiq.podvinci.core.cast.MediaId";
public static final String KEY_EPISODE_IDENTIFIER = "ac.mdiq.podvinci.core.cast.EpisodeId";
public static final String KEY_EPISODE_LINK = "ac.mdiq.podvinci.core.cast.EpisodeLink";
public static final String KEY_STREAM_URL = "ac.mdiq.podvinci.core.cast.StreamUrl";
public static final String KEY_FEED_URL = "ac.mdiq.podvinci.core.cast.FeedUrl";
public static final String KEY_FEED_WEBSITE = "ac.mdiq.podvinci.core.cast.FeedWebsite";
public static final String KEY_EPISODE_NOTES = "ac.mdiq.podvinci.core.cast.EpisodeNotes";
/**
* The field <code>PodVinci.FormatVersion</code> specifies which version of MediaMetaData
* fields we're using. Future implementations should try to be backwards compatible with earlier
* versions, and earlier versions should be forward compatible until the version indicated by
* <code>MAX_VERSION_FORWARD_COMPATIBILITY</code>. If an update makes the format unreadable for
* an earlier version, then its version number should be greater than the
* <code>MAX_VERSION_FORWARD_COMPATIBILITY</code> value set on the earlier one, so that it
* doesn't try to parse the object.
*/
public static final String KEY_FORMAT_VERSION = "ac.mdiq.podvinci.core.cast.FormatVersion";
public static final int FORMAT_VERSION_VALUE = 1;
public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999;
public static boolean isCastable(Playable media, CastSession castSession) {
if (media == null || castSession == null || castSession.getCastDevice() == null) {
return false;
}
if (media instanceof FeedMedia || media instanceof RemoteMedia) {
String url = media.getStreamUrl();
if (url == null || url.isEmpty()) {
return false;
}
if (url.startsWith(ContentResolver.SCHEME_CONTENT)) {
return false; // Local feed
}
switch (media.getMediaType()) {
case AUDIO:
return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT);
case VIDEO:
return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT);
default:
return false;
}
}
return false;
}
/**
* Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}.
* @return {@link Playable} object in a format proper for casting.
*/
public static Playable makeRemoteMedia(MediaInfo media) {
MediaMetadata metadata = media.getMetadata();
int version = metadata.getInt(KEY_FORMAT_VERSION);
if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) {
Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this"
+ "version of PodVinci CastUtils, curVer=" + FORMAT_VERSION_VALUE
+ ", object version=" + version);
return null;
}
List<WebImage> imageList = metadata.getImages();
String imageUrl = null;
if (!imageList.isEmpty()) {
imageUrl = imageList.get(0).getUrl().toString();
}
String notes = metadata.getString(KEY_EPISODE_NOTES);
RemoteMedia result = new RemoteMedia(media.getContentId(),
metadata.getString(KEY_EPISODE_IDENTIFIER),
metadata.getString(KEY_FEED_URL),
metadata.getString(MediaMetadata.KEY_SUBTITLE),
metadata.getString(MediaMetadata.KEY_TITLE),
metadata.getString(KEY_EPISODE_LINK),
metadata.getString(MediaMetadata.KEY_ARTIST),
imageUrl,
metadata.getString(KEY_FEED_WEBSITE),
media.getContentType(),
metadata.getDate(MediaMetadata.KEY_RELEASE_DATE).getTime(),
notes);
if (result.getDuration() == 0 && media.getStreamDuration() > 0) {
result.setDuration((int) media.getStreamDuration());
}
return result;
}
/**
* Compares a {@link MediaInfo} instance with a {@link FeedMedia} one and evaluates whether they
* represent the same podcast episode.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link FeedMedia} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, FeedMedia media) {
if (info == null || media == null) {
return false;
}
if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
return false;
}
MediaMetadata metadata = info.getMetadata();
FeedItem fi = media.getItem();
if (fi == null || metadata == null
|| !TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.getItemIdentifier())) {
return false;
}
Feed feed = fi.getFeed();
return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.getDownload_url());
}
/**
* Compares a {@link MediaInfo} instance with a {@link RemoteMedia} one and evaluates whether they
* represent the same podcast episode.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link RemoteMedia} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, RemoteMedia media) {
if (info == null || media == null) {
return false;
}
if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
return false;
}
MediaMetadata metadata = info.getMetadata();
return metadata != null
&& TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), media.getEpisodeIdentifier())
&& TextUtils.equals(metadata.getString(KEY_FEED_URL), media.getFeedUrl());
}
/**
* Compares a {@link MediaInfo} instance with a {@link Playable} and evaluates whether they
* represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device
* and want to avoid unnecessary conversions.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link Playable} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, Playable media) {
if (info == null || media == null) {
return false;
}
if (media instanceof RemoteMedia) {
return matches(info, (RemoteMedia) media);
}
return media instanceof FeedMedia && matches(info, (FeedMedia) media);
}
}

View File

@ -0,0 +1,172 @@
package ac.mdiq.podvinci.playback.cast
import ac.mdiq.podvinci.model.feed.Feed
import ac.mdiq.podvinci.model.feed.FeedMedia
import ac.mdiq.podvinci.model.playback.MediaType
import ac.mdiq.podvinci.model.playback.Playable
import ac.mdiq.podvinci.model.playback.RemoteMedia
import android.content.ContentResolver
import android.text.TextUtils
import android.util.Log
import com.google.android.gms.cast.CastDevice
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.framework.CastSession
/**
* Helper functions for Cast support.
*/
object CastUtils {
private const val TAG = "CastUtils"
const val KEY_MEDIA_ID: String = "ac.mdiq.podvinci.core.cast.MediaId"
const val KEY_EPISODE_IDENTIFIER: String = "ac.mdiq.podvinci.core.cast.EpisodeId"
const val KEY_EPISODE_LINK: String = "ac.mdiq.podvinci.core.cast.EpisodeLink"
const val KEY_STREAM_URL: String = "ac.mdiq.podvinci.core.cast.StreamUrl"
const val KEY_FEED_URL: String = "ac.mdiq.podvinci.core.cast.FeedUrl"
const val KEY_FEED_WEBSITE: String = "ac.mdiq.podvinci.core.cast.FeedWebsite"
const val KEY_EPISODE_NOTES: String = "ac.mdiq.podvinci.core.cast.EpisodeNotes"
/**
* The field `PodVinci.FormatVersion` specifies which version of MediaMetaData
* fields we're using. Future implementations should try to be backwards compatible with earlier
* versions, and earlier versions should be forward compatible until the version indicated by
* `MAX_VERSION_FORWARD_COMPATIBILITY`. If an update makes the format unreadable for
* an earlier version, then its version number should be greater than the
* `MAX_VERSION_FORWARD_COMPATIBILITY` value set on the earlier one, so that it
* doesn't try to parse the object.
*/
const val KEY_FORMAT_VERSION: String = "ac.mdiq.podvinci.core.cast.FormatVersion"
const val FORMAT_VERSION_VALUE: Int = 1
const val MAX_VERSION_FORWARD_COMPATIBILITY: Int = 9999
fun isCastable(media: Playable?, castSession: CastSession?): Boolean {
if (media == null || castSession == null || castSession.castDevice == null) {
return false
}
if (media is FeedMedia || media is RemoteMedia) {
val url = media.getStreamUrl()
if (url.isNullOrEmpty()) {
return false
}
if (url.startsWith(ContentResolver.SCHEME_CONTENT)) {
return false // Local feed
}
return when (media.getMediaType()) {
MediaType.AUDIO -> castSession.castDevice!!
.hasCapability(CastDevice.CAPABILITY_AUDIO_OUT)
MediaType.VIDEO -> castSession.castDevice!!.hasCapability(CastDevice.CAPABILITY_VIDEO_OUT)
else -> false
}
}
return false
}
/**
* Converts [MediaInfo] objects into the appropriate implementation of [Playable].
* @return [Playable] object in a format proper for casting.
*/
fun makeRemoteMedia(media: MediaInfo): Playable? {
val metadata = media.metadata
val version = metadata!!.getInt(KEY_FORMAT_VERSION)
if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) {
Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this"
+ "version of PodVinci CastUtils, curVer=" + FORMAT_VERSION_VALUE
+ ", object version=" + version)
return null
}
val imageList = metadata.images
var imageUrl: String? = null
if (imageList.isNotEmpty()) {
imageUrl = imageList[0].url.toString()
}
val notes = metadata.getString(KEY_EPISODE_NOTES)
val result = RemoteMedia(media.contentId,
metadata.getString(KEY_EPISODE_IDENTIFIER),
metadata.getString(KEY_FEED_URL),
metadata.getString(MediaMetadata.KEY_SUBTITLE),
metadata.getString(MediaMetadata.KEY_TITLE),
metadata.getString(KEY_EPISODE_LINK),
metadata.getString(MediaMetadata.KEY_ARTIST),
imageUrl,
metadata.getString(KEY_FEED_WEBSITE),
media.contentType,
metadata.getDate(MediaMetadata.KEY_RELEASE_DATE)!!.time,
notes)
if (result.getDuration() == 0 && media.streamDuration > 0) {
result.setDuration(media.streamDuration.toInt())
}
return result
}
/**
* Compares a [MediaInfo] instance with a [FeedMedia] one and evaluates whether they
* represent the same podcast episode.
*
* @param info the [MediaInfo] object to be compared.
* @param media the [FeedMedia] object to be compared.
* @return <true>true</true> if there's a match, `false` otherwise.
*
* @see RemoteMedia.equals
*/
fun matches(info: MediaInfo?, media: FeedMedia?): Boolean {
if (info == null || media == null) {
return false
}
if (!TextUtils.equals(info.contentId, media.getStreamUrl())) {
return false
}
val metadata = info.metadata
val fi = media.getItem()
if (fi == null || metadata == null || !TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.itemIdentifier)) {
return false
}
val feed: Feed? = fi.feed
return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.download_url)
}
/**
* Compares a [MediaInfo] instance with a [RemoteMedia] one and evaluates whether they
* represent the same podcast episode.
*
* @param info the [MediaInfo] object to be compared.
* @param media the [RemoteMedia] object to be compared.
* @return <true>true</true> if there's a match, `false` otherwise.
*
* @see RemoteMedia.equals
*/
fun matches(info: MediaInfo?, media: RemoteMedia?): Boolean {
if (info == null || media == null) {
return false
}
if (!TextUtils.equals(info.contentId, media.getStreamUrl())) {
return false
}
val metadata = info.metadata
return (metadata != null && TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER),
media.getEpisodeIdentifier())
&& TextUtils.equals(metadata.getString(KEY_FEED_URL), media.feedUrl))
}
/**
* Compares a [MediaInfo] instance with a [Playable] and evaluates whether they
* represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device
* and want to avoid unnecessary conversions.
*
* @param info the [MediaInfo] object to be compared.
* @param media the [Playable] object to be compared.
* @return <true>true</true> if there's a match, `false` otherwise.
*
* @see RemoteMedia.equals
*/
fun matches(info: MediaInfo?, media: Playable?): Boolean {
if (info == null || media == null) {
return false
}
if (media is RemoteMedia) {
return matches(info, media as RemoteMedia?)
}
return media is FeedMedia && matches(info, media as FeedMedia?)
}
}

View File

@ -1,135 +0,0 @@
package ac.mdiq.podvinci.playback.cast;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.common.images.WebImage;
import ac.mdiq.podvinci.model.feed.Feed;
import ac.mdiq.podvinci.model.feed.FeedItem;
import ac.mdiq.podvinci.model.feed.FeedMedia;
import ac.mdiq.podvinci.model.playback.RemoteMedia;
import java.util.Calendar;
public class MediaInfoCreator {
public static MediaInfo from(RemoteMedia media) {
MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle());
metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle());
if (!TextUtils.isEmpty(media.getImageLocation())) {
metadata.addImage(new WebImage(Uri.parse(media.getImageLocation())));
}
Calendar calendar = Calendar.getInstance();
calendar.setTime(media.getPubDate());
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar);
if (!TextUtils.isEmpty(media.getFeedAuthor())) {
metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor());
}
if (!TextUtils.isEmpty(media.getFeedUrl())) {
metadata.putString(CastUtils.KEY_FEED_URL, media.getFeedUrl());
}
if (!TextUtils.isEmpty(media.getFeedLink())) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.getFeedLink());
}
if (!TextUtils.isEmpty(media.getEpisodeIdentifier())) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier());
} else {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getDownloadUrl());
}
if (!TextUtils.isEmpty(media.getEpisodeLink())) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, media.getEpisodeLink());
}
String notes = media.getNotes();
if (notes != null) {
metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes);
}
// Default id value
metadata.putInt(CastUtils.KEY_MEDIA_ID, 0);
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE);
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl());
MediaInfo.Builder builder = new MediaInfo.Builder(media.getDownloadUrl())
.setContentType(media.getMimeType())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata);
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration());
}
return builder.build();
}
/**
* Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device.
* Before using this method, one should make sure isCastable(Playable) returns
* {@code true}. This method should not run on the main thread.
*
* @param media The {@link FeedMedia} object to be converted.
* @return {@link MediaInfo} object in a format proper for casting.
*/
public static MediaInfo from(FeedMedia media) {
if (media == null) {
return null;
}
MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
if (media.getItem() == null) {
throw new IllegalStateException("item is null");
}
FeedItem feedItem = media.getItem();
if (feedItem != null) {
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle());
String subtitle = media.getFeedTitle();
if (subtitle != null) {
metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle);
}
final @Nullable Feed feed = feedItem.getFeed();
// Manual because cast does not support embedded images
String url = (feedItem.getImageUrl() == null && feed != null) ? feed.getImageUrl() : feedItem.getImageUrl();
if (!TextUtils.isEmpty(url)) {
metadata.addImage(new WebImage(Uri.parse(url)));
}
Calendar calendar = Calendar.getInstance();
calendar.setTime(media.getItem().getPubDate());
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar);
if (feed != null) {
if (!TextUtils.isEmpty(feed.getAuthor())) {
metadata.putString(MediaMetadata.KEY_ARTIST, feed.getAuthor());
}
if (!TextUtils.isEmpty(feed.getDownload_url())) {
metadata.putString(CastUtils.KEY_FEED_URL, feed.getDownload_url());
}
if (!TextUtils.isEmpty(feed.getLink())) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.getLink());
}
}
if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier());
} else {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl());
}
if (!TextUtils.isEmpty(feedItem.getLink())) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.getLink());
}
}
// This field only identifies the id on the device that has the original version.
// Idea is to perhaps, on a first approach, check if the version on the local DB with the
// same id matches the remote object, and if not then search for episode and feed identifiers.
// This at least should make media recognition for a single device much quicker.
metadata.putInt(CastUtils.KEY_MEDIA_ID, ((Long) media.getIdentifier()).intValue());
// A way to identify different casting media formats in case we change it in the future and
// senders with different versions share a casting device.
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE);
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl());
MediaInfo.Builder builder = new MediaInfo.Builder(media.getStreamUrl())
.setContentType(media.getMime_type())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata);
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration());
}
return builder.build();
}
}

View File

@ -0,0 +1,130 @@
package ac.mdiq.podvinci.playback.cast
import ac.mdiq.podvinci.model.feed.Feed
import ac.mdiq.podvinci.model.feed.FeedMedia
import ac.mdiq.podvinci.model.playback.RemoteMedia
import android.net.Uri
import android.text.TextUtils
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.common.images.WebImage
import java.util.*
object MediaInfoCreator {
fun from(media: RemoteMedia): MediaInfo {
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC)
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle())
metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle())
if (!TextUtils.isEmpty(media.getImageLocation())) {
metadata.addImage(WebImage(Uri.parse(media.getImageLocation())))
}
val calendar = Calendar.getInstance()
calendar.time = media.getPubDate()
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (!TextUtils.isEmpty(media.getFeedAuthor())) {
metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor())
}
if (!TextUtils.isEmpty(media.feedUrl)) {
metadata.putString(CastUtils.KEY_FEED_URL, media.feedUrl!!)
}
if (!TextUtils.isEmpty(media.feedLink)) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.feedLink!!)
}
if (!TextUtils.isEmpty(media.getEpisodeIdentifier())) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()!!)
} else {
if (media.getStreamUrl() != null) metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()!!)
}
if (!TextUtils.isEmpty(media.episodeLink)) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, media.episodeLink!!)
}
val notes: String? = media.getDescription()
if (notes != null) {
metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes)
}
// Default id value
metadata.putInt(CastUtils.KEY_MEDIA_ID, 0)
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE)
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!)
val builder = MediaInfo.Builder(media.getStreamUrl()?:"")
.setContentType(media.getMimeType())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration().toLong())
}
return builder.build()
}
/**
* Converts [FeedMedia] objects into a format suitable for sending to a Cast Device.
* Before using this method, one should make sure isCastable(Playable) returns
* `true`. This method should not run on the main thread.
*
* @param media The [FeedMedia] object to be converted.
* @return [MediaInfo] object in a format proper for casting.
*/
fun from(media: FeedMedia?): MediaInfo? {
if (media == null) {
return null
}
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC)
checkNotNull(media.getItem()) { "item is null" }
val feedItem = media.getItem()
if (feedItem != null) {
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle())
val subtitle = media.getFeedTitle()
metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle)
val feed: Feed? = feedItem.feed
// Manual because cast does not support embedded images
val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:""
if (!TextUtils.isEmpty(url)) {
metadata.addImage(WebImage(Uri.parse(url)))
}
val calendar = Calendar.getInstance()
if (media.getItem()?.getPubDate() != null) calendar.time = media.getItem()!!.getPubDate()!!
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (feed != null) {
if (!feed.author.isNullOrEmpty()) {
metadata.putString(MediaMetadata.KEY_ARTIST, feed.author!!)
}
if (!feed.download_url.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_FEED_URL, feed.download_url!!)
}
if (!feed.link.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.link!!)
}
}
if (!feedItem.itemIdentifier.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.itemIdentifier!!)
} else {
metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()?:"")
}
if (!feedItem.link.isNullOrEmpty()) {
metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.link!!)
}
}
// This field only identifies the id on the device that has the original version.
// Idea is to perhaps, on a first approach, check if the version on the local DB with the
// same id matches the remote object, and if not then search for episode and feed identifiers.
// This at least should make media recognition for a single device much quicker.
metadata.putInt(CastUtils.KEY_MEDIA_ID, (media.getIdentifier() as Long).toInt())
// A way to identify different casting media formats in case we change it in the future and
// senders with different versions share a casting device.
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE)
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!)
val builder = MediaInfo.Builder(media.getStreamUrl()!!)
.setContentType(media.mime_type)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration().toLong())
}
return builder.build()
}
}