diff --git a/app/src/androidTest/java/ac/test/podvinci/ui/UITestUtils.kt b/app/src/androidTest/java/ac/test/podvinci/ui/UITestUtils.kt index 934abc86..3382b982 100644 --- a/app/src/androidTest/java/ac/test/podvinci/ui/UITestUtils.kt +++ b/app/src/androidTest/java/ac/test/podvinci/ui/UITestUtils.kt @@ -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 diff --git a/app/src/main/java/ac/mdiq/podvinci/dialog/EditUrlSettingsDialog.kt b/app/src/main/java/ac/mdiq/podvinci/dialog/EditUrlSettingsDialog.kt index bdc3d56a..5441bb12 100644 --- a/app/src/main/java/ac/mdiq/podvinci/dialog/EditUrlSettingsDialog.kt +++ b/app/src/main/java/ac/mdiq/podvinci/dialog/EditUrlSettingsDialog.kt @@ -11,11 +11,13 @@ 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 -abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed) { + @UnstableApi + abstract class EditUrlSettingsDialog(activity: Activity, private val feed: Feed) { private val activityRef = WeakReference(activity) fun show() { @@ -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!!) diff --git a/app/src/main/java/ac/mdiq/podvinci/fragment/FeedInfoFragment.kt b/app/src/main/java/ac/mdiq/podvinci/fragment/FeedInfoFragment.kt index 6c8f5f58..eaf5c4fc 100644 --- a/app/src/main/java/ac/mdiq/podvinci/fragment/FeedInfoFragment.kt +++ b/app/src/main/java/ac/mdiq/podvinci/fragment/FeedInfoFragment.kt @@ -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(MaybeOnSubscribe { emitter: MaybeEmitter -> + disposable = Maybe.create { emitter: MaybeEmitter -> val feed: Feed? = DBReader.getFeed(feedId) if (feed != null) { emitter.onSuccess(feed) } else { emitter.onComplete() } - } as MaybeOnSubscribe?) + } .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 = feed!!.paymentLinks!! + val fundingList: ArrayList = feed!!.paymentLinks // Filter for duplicates, but keep items in the order that they have in the feed. val i: MutableIterator = 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) diff --git a/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.java b/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.java deleted file mode 100644 index 5df974be..00000000 --- a/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.java +++ /dev/null @@ -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 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 request = manager.requestReviewFlow(); - - request.addOnCompleteListener(task -> { - if (task.isSuccessful()) { - ReviewInfo reviewInfo = task.getResult(); - Task 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; - } -} diff --git a/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.kt b/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.kt new file mode 100644 index 00000000..d9724d29 --- /dev/null +++ b/app/src/play/java/ac/mdiq/podvinci/dialog/RatingDialog.kt @@ -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? = 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 = manager.requestReviewFlow() + + request.addOnCompleteListener { task: Task -> + if (task.isSuccessful) { + val reviewInfo: ReviewInfo = task.result + val flow: Task = manager.launchReviewFlow(context as Activity, reviewInfo) + flow.addOnCompleteListener { task1: Task? -> + 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 + } +} diff --git a/core/src/main/java/ac/mdiq/podvinci/core/feed/LocalFeedUpdater.kt b/core/src/main/java/ac/mdiq/podvinci/core/feed/LocalFeedUpdater.kt index 0f7beead..20b32bdb 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/feed/LocalFeedUpdater.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/feed/LocalFeedUpdater.kt @@ -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 diff --git a/core/src/main/java/ac/mdiq/podvinci/core/service/BasicAuthorizationInterceptor.kt b/core/src/main/java/ac/mdiq/podvinci/core/service/BasicAuthorizationInterceptor.kt index 51132764..a8c7b94e 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/service/BasicAuthorizationInterceptor.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/service/BasicAuthorizationInterceptor.kt @@ -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 - if (TextUtils.isEmpty(userInfo) - && (!TextUtils.isEmpty(downloadRequest.username) - || !TextUtils.isEmpty(downloadRequest.password))) { - userInfo = downloadRequest.username + ":" + downloadRequest.password + 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 } diff --git a/core/src/main/java/ac/mdiq/podvinci/core/service/FeedUpdateWorker.kt b/core/src/main/java/ac/mdiq/podvinci/core/service/FeedUpdateWorker.kt index 5c397a3a..2ab9a796 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/service/FeedUpdateWorker.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/service/FeedUpdateWorker.kt @@ -40,7 +40,7 @@ class FeedUpdateWorker(context: Context, params: WorkerParameters) : Worker(cont ClientConfigurator.initialize(applicationContext) newEpisodesNotification.loadCountersBeforeRefresh() - val toUpdate: MutableList + val toUpdate: MutableList 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, force: Boolean) { + @UnstableApi private fun refreshFeeds(toUpdate: MutableList, 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) } } diff --git a/core/src/main/java/ac/mdiq/podvinci/core/service/download/DownloadRequestCreator.kt b/core/src/main/java/ac/mdiq/podvinci/core/service/download/DownloadRequestCreator.kt index 9c7cc1e6..e902537c 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/service/download/DownloadRequestCreator.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/service/download/DownloadRequestCreator.kt @@ -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 diff --git a/core/src/main/java/ac/mdiq/podvinci/core/service/download/HttpDownloader.kt b/core/src/main/java/ac/mdiq/podvinci/core/service/download/HttpDownloader.kt index 625328b7..587637b3 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/service/download/HttpDownloader.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/service/download/HttpDownloader.kt @@ -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) { diff --git a/core/src/main/java/ac/mdiq/podvinci/core/service/playback/PlaybackService.kt b/core/src/main/java/ac/mdiq/podvinci/core/service/playback/PlaybackService.kt index 44fdf2ee..4b7fa55a 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/service/playback/PlaybackService.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/service/playback/PlaybackService.kt @@ -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()) } diff --git a/core/src/main/java/ac/mdiq/podvinci/core/storage/DBWriter.kt b/core/src/main/java/ac/mdiq/podvinci/core/storage/DBWriter.kt index 66b16dc7..09812798 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/storage/DBWriter.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/storage/DBWriter.kt @@ -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,18 +333,16 @@ 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) { - queue.add(index, item) - adapter?.setQueue(queue) - item.addTag(FeedItem.TAG_QUEUE) - EventBus.getDefault().post(added(item, index)) - EventBus.getDefault().post(updated(item)) - if (item.isNew) { - markItemPlayed(FeedItem.UNPLAYED, item.id) - } + if (!itemListContains(queue, itemId)) { + item = getFeedItem(itemId) + if (item != null) { + queue.add(index, item) + adapter?.setQueue(queue) + item.addTag(FeedItem.TAG_QUEUE) + EventBus.getDefault().post(added(item, index)) + EventBus.getDefault().post(updated(item)) + if (item.isNew) { + markItemPlayed(FeedItem.UNPLAYED, item.id) } } } @@ -523,39 +521,35 @@ import java.util.concurrent.TimeUnit adapter?.open() val queue = getQueue(adapter).toMutableList() - if (queue != null) { - var queueModified = false - val events: MutableList = ArrayList() - val updatedItems: MutableList = ArrayList() - for (itemId in itemIds) { - val position = indexInItemList(queue, itemId) - if (position >= 0) { - val item = getFeedItem(itemId) - if (item == null) { - Log.e(TAG, "removeQueueItem - item in queue but somehow cannot be loaded." + - " Item ignored. It should never happen. id:" + itemId) - continue - } - queue.removeAt(position) - item.removeTag(FeedItem.TAG_QUEUE) - events.add(removed(item)) - updatedItems.add(item) - queueModified = true - } else { - Log.v(TAG, "removeQueueItem - item not in queue:$itemId") + var queueModified = false + val events: MutableList = ArrayList() + val updatedItems: MutableList = ArrayList() + for (itemId in itemIds) { + val position = indexInItemList(queue, itemId) + if (position >= 0) { + val item = getFeedItem(itemId) + if (item == null) { + Log.e(TAG, "removeQueueItem - item in queue but somehow cannot be loaded." + + " Item ignored. It should never happen. id:" + itemId) + continue } - } - if (queueModified) { - adapter?.setQueue(queue) - for (event in events) { - EventBus.getDefault().post(event) - } - EventBus.getDefault().post(updated(updatedItems)) + queue.removeAt(position) + item.removeTag(FeedItem.TAG_QUEUE) + events.add(removed(item)) + updatedItems.add(item) + queueModified = true } else { - Log.w(TAG, "Queue was not modified by call to removeQueueItem") + Log.v(TAG, "removeQueueItem - item not in queue:$itemId") } + } + if (queueModified) { + adapter?.setQueue(queue) + for (event in events) { + EventBus.getDefault().post(event) + } + EventBus.getDefault().post(updated(updatedItems)) } else { - Log.e(TAG, "removeQueueItem: Could not load queue") + Log.w(TAG, "Queue was not modified by call to removeQueueItem") } adapter?.close() if (performAutoDownload) { @@ -954,14 +948,10 @@ 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") + permutor.reorder(queue) + adapter.setQueue(queue) + if (broadcastUpdate) { + EventBus.getDefault().post(QueueEvent.sorted(queue)) } adapter.close() } diff --git a/core/src/main/java/ac/mdiq/podvinci/core/util/URIUtil.kt b/core/src/main/java/ac/mdiq/podvinci/core/util/URIUtil.kt index 52f90905..1a1cef51 100644 --- a/core/src/main/java/ac/mdiq/podvinci/core/util/URIUtil.kt +++ b/core/src/main/java/ac/mdiq/podvinci/core/util/URIUtil.kt @@ -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) } } diff --git a/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.java b/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.java deleted file mode 100644 index 416782d0..00000000 --- a/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.java +++ /dev/null @@ -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); - } -} diff --git a/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.kt b/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.kt new file mode 100644 index 00000000..d1f1e3cb --- /dev/null +++ b/core/src/play/java/ac/mdiq/podvinci/core/service/playback/WearMediaSession.kt @@ -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) + } +} diff --git a/core/src/test/java/android/text/TextUtils.java b/core/src/test/java/android/text/TextUtils.java deleted file mode 100644 index eda31c3b..00000000 --- a/core/src/test/java/android/text/TextUtils.java +++ /dev/null @@ -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. - *

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 - */ - 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 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. - */ - public static boolean isEmpty(CharSequence str) { - return str == null || str.length() == 0; - } -} diff --git a/core/src/test/java/android/text/TextUtils.kt b/core/src/test/java/android/text/TextUtils.kt new file mode 100644 index 00000000..1c66a063 --- /dev/null +++ b/core/src/test/java/android/text/TextUtils.kt @@ -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() + } +} diff --git a/core/src/test/java/android/util/Log.java b/core/src/test/java/android/util/Log.java deleted file mode 100644 index a65bc80f..00000000 --- a/core/src/test/java/android/util/Log.java +++ /dev/null @@ -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()); - } - -} diff --git a/core/src/test/java/android/util/Log.kt b/core/src/test/java/android/util/Log.kt new file mode 100644 index 00000000..2f529a80 --- /dev/null +++ b/core/src/test/java/android/util/Log.kt @@ -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()) + } +} diff --git a/model/src/main/java/ac/mdiq/podvinci/model/feed/Feed.kt b/model/src/main/java/ac/mdiq/podvinci/model/feed/Feed.kt index 52744f6c..6fa0f100 100644 --- a/model/src/main/java/ac/mdiq/podvinci/model/feed/Feed.kt +++ b/model/src/main/java/ac/mdiq/podvinci/model/feed/Feed.kt @@ -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 diff --git a/net/download/service-interface/src/main/java/ac/mdiq/podvinci/net/download/serviceinterface/DownloadRequest.kt b/net/download/service-interface/src/main/java/ac/mdiq/podvinci/net/download/serviceinterface/DownloadRequest.kt index 0598ae96..3dd11da7 100644 --- a/net/download/service-interface/src/main/java/ac/mdiq/podvinci/net/download/serviceinterface/DownloadRequest.kt +++ b/net/download/service-interface/src/main/java/ac/mdiq/podvinci/net/download/serviceinterface/DownloadRequest.kt @@ -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() diff --git a/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.java b/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.java deleted file mode 100644 index ef2eabba..00000000 --- a/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.java +++ /dev/null @@ -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(); - } - } -} diff --git a/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.kt b/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.kt new file mode 100644 index 00000000..db86ab0c --- /dev/null +++ b/net/ssl/src/play/java/ac/mdiq/podvinci/net/ssl/SslProviderInstaller.kt @@ -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() + } + } +} diff --git a/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.java b/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.java deleted file mode 100644 index 0c2eebd0..00000000 --- a/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.java +++ /dev/null @@ -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); - } -} diff --git a/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.kt b/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.kt new file mode 100644 index 00000000..862e3052 --- /dev/null +++ b/playback/base/src/test/java/ac/mdiq/podvinci/playback/base/RewindAfterPauseUtilTest.kt @@ -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()) + } +} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.java deleted file mode 100644 index 5081cfa3..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.java +++ /dev/null @@ -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); - } -} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.kt new file mode 100644 index 00000000..9c7c19a1 --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastEnabledActivity.kt @@ -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) + } +} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.java deleted file mode 100644 index 2029726d..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.java +++ /dev/null @@ -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 getAdditionalSessionProviders(@NonNull Context context) { - return null; - } -} \ No newline at end of file diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.kt new file mode 100644 index 00000000..ae67024d --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastOptionsProvider.kt @@ -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? { + return null + } +} \ No newline at end of file diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.java deleted file mode 100644 index ae01558e..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.java +++ /dev/null @@ -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 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 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; - } -} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.kt new file mode 100644 index 00000000..811973c6 --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastPsmp.kt @@ -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? { + 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 { + return emptyList() + } + + 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 + } + } +} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.java deleted file mode 100644 index 2e791889..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.java +++ /dev/null @@ -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 { - 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() { - } -} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.kt new file mode 100644 index 00000000..2f3b358b --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastStateListener.kt @@ -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 { + 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() { + } +} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.java deleted file mode 100644 index 76361585..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.java +++ /dev/null @@ -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 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. - */ - 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 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 if there's a match, false 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 if there's a match, false 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 if there's a match, false 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); - } -} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.kt new file mode 100644 index 00000000..ca1d1ec8 --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/CastUtils.kt @@ -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 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 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 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?) + } +} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.java b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.java deleted file mode 100644 index ad8476f6..00000000 --- a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.java +++ /dev/null @@ -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(); - } -} diff --git a/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.kt b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.kt new file mode 100644 index 00000000..10a48f78 --- /dev/null +++ b/playback/cast/src/play/java/ac/mdiq/podvinci/playback/cast/MediaInfoCreator.kt @@ -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() + } +}