4.4.3 commit

This commit is contained in:
Xilin Jia 2024-03-29 14:17:10 +00:00
parent d442601ba8
commit 24e7e671da
18 changed files with 1006 additions and 954 deletions

View File

@ -149,8 +149,8 @@ android {
// Version code schema (not used):
// "1.2.3-beta4" -> 1020304
// "1.2.3" -> 1020395
versionCode 3020118
versionName "4.4.2"
versionCode 3020119
versionName "4.4.3"
def commit = ""
try {

View File

@ -16,7 +16,6 @@ import ac.mdiq.podcini.preferences.UserPreferences.hiddenDrawerItems
import ac.mdiq.podcini.receiver.MediaButtonReceiver.Companion.createIntent
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.storage.model.download.DownloadStatus
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.ui.appstartintent.MainActivityStarter
import ac.mdiq.podcini.ui.common.ThemeUtils.getDrawableFromAttr
import ac.mdiq.podcini.ui.dialog.RatingDialog
@ -592,10 +591,9 @@ class MainActivity : CastEnabledActivity() {
}
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED)
}
intent.hasExtra(EXTRA_EPISODES) -> {
val episodes = (if (Build.VERSION.SDK_INT >= 33) intent.getSerializableExtra(EXTRA_EPISODES)
else intent.getSerializableExtra(EXTRA_EPISODES)) as ArrayList<FeedItem>
loadChildFragment(EpisodesListFragment.newInstance(episodes))
intent.hasExtra(EXTRA_FEED_URL) -> {
val feedurl = intent.getStringExtra(EXTRA_FEED_URL)
if (feedurl != null) loadChildFragment(OnlineFeedViewFragment.newInstance(feedurl))
}
intent.hasExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG) -> {
val tag = intent.getStringExtra(MainActivityStarter.EXTRA_FRAGMENT_TAG)
@ -736,11 +734,11 @@ class MainActivity : CastEnabledActivity() {
const val PREF_IS_FIRST_LAUNCH: String = "prefMainActivityIsFirstLaunch"
const val EXTRA_FEED_ID: String = "fragment_feed_id"
const val EXTRA_FEED_URL: String = "fragment_feed_url"
const val EXTRA_REFRESH_ON_START: String = "refresh_on_start"
const val EXTRA_STARTED_FROM_SEARCH: String = "started_from_search"
const val EXTRA_ADD_TO_BACK_STACK: String = "add_to_back_stack"
const val KEY_GENERATED_VIEW_ID: String = "generated_view_id"
const val EXTRA_EPISODES: String = "episodes_list"
@JvmStatic
fun getIntentToOpenFeed(context: Context, feedId: Long): Intent {
@ -750,12 +748,12 @@ class MainActivity : CastEnabledActivity() {
return intent
}
fun openEpisodesList(context: Context, episodes: ArrayList<FeedItem>): Intent {
@JvmStatic
fun showOnlineFeed(context: Context, feedUrl: String): Intent {
val intent = Intent(context.applicationContext, MainActivity::class.java)
intent.putExtra(EXTRA_EPISODES, episodes)
intent.putExtra(EXTRA_FEED_URL, feedUrl)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
return intent
}
}
}

View File

@ -1,117 +1,24 @@
package ac.mdiq.podcini.ui.activity
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.databinding.OnlinefeedviewActivityBinding
import ac.mdiq.podcini.feed.FeedUrlNotFoundException
import ac.mdiq.podcini.feed.parser.FeedHandler
import ac.mdiq.podcini.feed.parser.FeedHandlerResult
import ac.mdiq.podcini.feed.parser.UnsupportedFeedtypeException
import ac.mdiq.podcini.net.common.UrlChecker.prepareUrl
import ac.mdiq.podcini.net.discovery.CombinedSearcher
import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.ThemeSwitcher.getTranslucentTheme
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.service.download.DownloadRequestCreator.create
import ac.mdiq.podcini.service.download.Downloader
import ac.mdiq.podcini.service.download.HttpDownloader
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.storage.DBTasks
import ac.mdiq.podcini.storage.DBWriter
import ac.mdiq.podcini.storage.model.download.DownloadError
import ac.mdiq.podcini.storage.model.download.DownloadResult
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.ui.dialog.AuthenticationDialog
import ac.mdiq.podcini.ui.glide.FastBlurTransformation
import ac.mdiq.podcini.util.DownloadErrorLabel.from
import ac.mdiq.podcini.util.event.EpisodeDownloadEvent
import ac.mdiq.podcini.util.event.FeedListUpdateEvent
import ac.mdiq.podcini.util.syndication.FeedDiscoverer
import ac.mdiq.podcini.util.syndication.HtmlToPlainText
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.LightingColorFilter
import android.net.Uri
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.TextUtils
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.annotation.UiThread
import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.Maybe
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.observers.DisposableMaybeObserver
import io.reactivex.schedulers.Schedulers
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.io.IOException
import kotlin.concurrent.Volatile
import kotlin.math.min
import java.net.URLDecoder
/**
* Downloads a feed from a feed URL and parses it. Subclasses can display the
* feed object that was parsed. This activity MUST be started with a given URL
* or an Exception will be thrown.
*
*
* If the feed cannot be downloaded or parsed, an error dialog will be displayed
* and the activity will finish as soon as the error dialog is closed.
*/
// this now is only used for receiving shared feed url
class OnlineFeedViewActivity : AppCompatActivity() {
private var _binding: OnlinefeedviewActivityBinding? = null
private val binding get() = _binding!!
@Volatile
private var feeds: List<Feed>? = null
private var selectedDownloadUrl: String? = null
private var downloader: Downloader? = null
private var username: String? = null
private var password: String? = null
private var isPaused = false
private var didPressSubscribe = false
private var isFeedFoundBySearch = false
private var dialog: Dialog? = null
private var download: Disposable? = null
private var parser: Disposable? = null
private var updater: Disposable? = null
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTranslucentTheme(this))
@OptIn(UnstableApi::class) override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_binding = OnlinefeedviewActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.transparentBackground.setOnClickListener { finish() }
binding.closeButton.setOnClickListener { finish() }
binding.card.setOnClickListener(null)
binding.card.setCardBackgroundColor(getColorFromAttr(this, R.attr.colorSurface))
var feedUrl: String? = null
when {
intent.hasExtra(ARG_FEEDURL) -> {
@ -125,21 +32,22 @@ class OnlineFeedViewActivity : AppCompatActivity() {
}
}
if (!feedUrl.isNullOrBlank() && !feedUrl.startsWith("http")) {
val uri = Uri.parse(feedUrl)
feedUrl = URLDecoder.decode(uri.getQueryParameter("url"), "UTF-8")
}
if (feedUrl == null) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
} else {
Log.d(TAG, "Activity was started with url $feedUrl")
setLoadingLayout()
// Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL
if (feedUrl.contains("subscribeonandroid.com")) {
feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "")
}
if (savedInstanceState != null) {
username = savedInstanceState.getString("username")
password = savedInstanceState.getString("password")
}
lookupUrlAndDownload(feedUrl)
val intent = MainActivity.showOnlineFeed(this, feedUrl)
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH,
getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false))
startActivity(intent)
finish()
}
}
@ -157,530 +65,15 @@ class OnlineFeedViewActivity : AppCompatActivity() {
}
}
/**
* Displays a progress indicator.
*/
private fun setLoadingLayout() {
binding.progressBar.visibility = View.VISIBLE
binding.feedDisplayContainer.visibility = View.GONE
}
override fun onStart() {
super.onStart()
isPaused = false
EventBus.getDefault().register(this)
}
override fun onStop() {
super.onStop()
isPaused = true
EventBus.getDefault().unregister(this)
if (downloader != null && !downloader!!.isFinished) downloader!!.cancel()
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
}
public override fun onDestroy() {
super.onDestroy()
_binding = null
updater?.dispose()
download?.dispose()
parser?.dispose()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString("username", username)
outState.putString("password", password)
}
private fun resetIntent(url: String) {
val intent = Intent()
intent.putExtra(ARG_FEEDURL, url)
setIntent(intent)
}
override fun finish() {
super.finish()
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
@UnstableApi override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
val destIntent = Intent(this, MainActivity::class.java)
if (NavUtils.shouldUpRecreateTask(this, destIntent)) {
startActivity(destIntent)
} else {
NavUtils.navigateUpFromSameTask(this)
}
return true
}
return super.onOptionsItemSelected(item)
}
private fun lookupUrlAndDownload(url: String) {
download = PodcastSearcherRegistry.lookupUrl(url)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe({ url1: String -> this.startFeedDownload(url1) },
{ error: Throwable? ->
if (error is FeedUrlNotFoundException) {
tryToRetrieveFeedUrlBySearch(error)
} else {
showNoPodcastFoundError()
Log.e(TAG, Log.getStackTraceString(error))
}
})
}
private fun tryToRetrieveFeedUrlBySearch(error: FeedUrlNotFoundException) {
Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search")
val url = searchFeedUrlByTrackName(error.trackName, error.artistName)
if (url != null) {
Log.d(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true
startFeedDownload(url)
} else {
showNoPodcastFoundError()
Log.d(TAG, "Failed to retrieve feed url")
}
}
private fun searchFeedUrlByTrackName(trackName: String, artistName: String): String? {
val searcher = CombinedSearcher()
val query = "$trackName $artistName"
val results = searcher.search(query).blockingGet()
if (results.isNullOrEmpty()) return null
for (result in results) {
if (result?.feedUrl != null && result.author != null &&
result.author.equals(artistName, ignoreCase = true) &&
result.title.equals(trackName, ignoreCase = true)) {
return result.feedUrl
}
}
return null
}
private fun startFeedDownload(url: String) {
Log.d(TAG, "Starting feed download")
selectedDownloadUrl = prepareUrl(url)
val request = create(Feed(selectedDownloadUrl, null))
.withAuthentication(username, password)
.withInitiatedByUser(true)
.build()
download = Observable.fromCallable {
feeds = DBReader.getFeedList()
downloader = HttpDownloader(request)
downloader?.call()
downloader?.result
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ status: DownloadResult? -> if (request.destination != null) checkDownloadResult(status, request.destination) },
{ error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) })
}
private fun checkDownloadResult(status: DownloadResult?, destination: String) {
if (status == null) return
when {
status.isSuccessful -> {
parseFeed(destination)
}
status.reason == DownloadError.ERROR_UNAUTHORIZED -> {
if (!isFinishing && !isPaused) {
if (username != null && password != null) {
Toast.makeText(this, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show()
}
if (downloader?.downloadRequest?.source != null) {
dialog = FeedViewAuthenticationDialog(this@OnlineFeedViewActivity,
R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create()
dialog?.show()
}
}
}
else -> {
showErrorDialog(getString(from(status.reason)), status.reasonDetailed)
}
}
}
@UnstableApi @Subscribe
fun onFeedListChanged(event: FeedListUpdateEvent?) {
updater = Observable.fromCallable { DBReader.getFeedList() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ feeds: List<Feed>? ->
this@OnlineFeedViewActivity.feeds = feeds
handleUpdatedFeedStatus()
}, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }
)
}
@UnstableApi @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: EpisodeDownloadEvent?) {
handleUpdatedFeedStatus()
}
private fun parseFeed(destination: String) {
Log.d(TAG, "Parsing feed")
parser = Maybe.fromCallable { doParseFeed(destination) }
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(object : DisposableMaybeObserver<FeedHandlerResult?>() {
@UnstableApi override fun onSuccess(result: FeedHandlerResult) {
showFeedInformation(result.feed, result.alternateFeedUrls)
}
override fun onComplete() {
// Ignore null result: We showed the discovery dialog.
}
override fun onError(error: Throwable) {
showErrorDialog(error.message, "")
Log.d(TAG, "Feed parser exception: " + Log.getStackTraceString(error))
}
})
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
private fun doParseFeed(destination: String): FeedHandlerResult? {
val handler = FeedHandler()
val feed = Feed(selectedDownloadUrl, null)
feed.file_url = destination
val destinationFile = File(destination)
return try {
handler.parseFeed(feed)
} catch (e: UnsupportedFeedtypeException) {
Log.d(TAG, "Unsupported feed type detected")
if ("html".equals(e.rootElement, ignoreCase = true)) {
if (selectedDownloadUrl != null) {
val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!)
if (dialogShown) {
null // Should not display an error message
} else {
throw UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html))
}
} else null
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
val rc = destinationFile.delete()
Log.d(TAG, "Deleted feed source file. Result: $rc")
}
}
/**
* Called when feed parsed successfully.
* This method is executed on the GUI thread.
*/
@UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) {
binding.progressBar.visibility = View.GONE
binding.feedDisplayContainer.visibility = View.VISIBLE
if (isFeedFoundBySearch) {
val resId = R.string.no_feed_url_podcast_found_by_search
Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show()
}
binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000)
binding.episodeLabel.setOnClickListener { showEpisodes(feed.items)}
if (!feed.imageUrl.isNullOrBlank()) {
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.error(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(binding.coverImage)
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.image_readability_tint)
.error(R.color.image_readability_tint)
.transform(FastBlurTransformation())
.dontAnimate())
.into(binding.backgroundImage)
}
binding.titleLabel.text = feed.title
binding.authorLabel.text = feed.author
binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"")
binding.subscribeButton.setOnClickListener {
if (feedInFeedlist()) {
openFeed()
} else {
DBTasks.updateFeed(this, feed, false)
didPressSubscribe = true
handleUpdatedFeedStatus()
}
}
if (isEnableAutodownload) {
val preferences = getSharedPreferences(PREFS, MODE_PRIVATE)
binding.autoDownloadCheckBox.isChecked = preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true)
}
if (alternateFeedUrls.isEmpty()) {
binding.alternateUrlsSpinner.visibility = View.GONE
} else {
binding.alternateUrlsSpinner.visibility = View.VISIBLE
val alternateUrlsList: MutableList<String> = ArrayList()
val alternateUrlsTitleList: MutableList<String?> = ArrayList()
if (feed.download_url != null) alternateUrlsList.add(feed.download_url!!)
alternateUrlsTitleList.add(feed.title)
alternateUrlsList.addAll(alternateFeedUrls.keys)
for (url in alternateFeedUrls.keys) {
alternateUrlsTitleList.add(alternateFeedUrls[url])
}
val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(this,
R.layout.alternate_urls_item, alternateUrlsTitleList) {
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
// reusing the old view causes a visual bug on Android <= 10
return super.getDropDownView(position, null, parent)
}
}
adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item)
binding.alternateUrlsSpinner.adapter = adapter
binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
selectedDownloadUrl = alternateUrlsList[position]
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
}
handleUpdatedFeedStatus()
}
@UnstableApi private fun openFeed() {
// feed.getId() is always 0, we have to retrieve the id from the feed list from
// the database
val intent = MainActivity.getIntentToOpenFeed(this, feedId)
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH,
getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false))
finish()
startActivity(intent)
}
@UnstableApi private fun showEpisodes(episodes: List<FeedItem>) {
Log.d(TAG, "showEpisodes ${episodes.size}")
if (episodes.isNullOrEmpty()) return
val intent = MainActivity.openEpisodesList(this, ArrayList(episodes.subList(0, min(50, episodes.size-1))))
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH,
getIntent().getBooleanExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, false))
finish()
startActivity(intent)
}
@UnstableApi private fun handleUpdatedFeedStatus() {
val dli = DownloadServiceInterface.get()
if (dli == null || selectedDownloadUrl == null) return
when {
dli.isDownloadingEpisode(selectedDownloadUrl!!) -> {
binding.subscribeButton.isEnabled = false
binding.subscribeButton.setText(R.string.subscribing_label)
}
feedInFeedlist() -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.open)
if (didPressSubscribe) {
didPressSubscribe = false
val feed1 = DBReader.getFeed(feedId)?: return
val feedPreferences = feed1.preferences
if (feedPreferences != null) {
if (isEnableAutodownload) {
val autoDownload = binding.autoDownloadCheckBox.isChecked
feedPreferences.autoDownload = autoDownload
val preferences = getSharedPreferences(PREFS, MODE_PRIVATE)
val editor = preferences.edit()
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload)
editor.apply()
}
if (username != null) {
feedPreferences.username = username
feedPreferences.password = password
}
DBWriter.setFeedPreferences(feedPreferences)
}
openFeed()
}
}
else -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.subscribe_label)
if (isEnableAutodownload) {
binding.autoDownloadCheckBox.visibility = View.VISIBLE
}
}
}
}
private fun feedInFeedlist(): Boolean {
return feedId != 0L
}
private val feedId: Long
get() {
if (feeds == null) return 0
for (f in feeds!!) {
if (f.download_url == selectedDownloadUrl) {
return f.id
}
}
return 0
}
@UiThread
private fun showErrorDialog(errorMsg: String?, details: String) {
if (!isFinishing && !isPaused) {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.error_label)
if (errorMsg != null) {
val total = """
$errorMsg
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778),
errorMsg.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.setMessage(errorMessage)
} else {
builder.setMessage(R.string.download_error_error_unknown)
}
builder.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> dialog.cancel() }
if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) {
builder.setNeutralButton(R.string.edit_url_menu) { _: DialogInterface?, _: Int -> editUrl() }
}
builder.setOnCancelListener {
setResult(RESULT_ERROR)
finish()
}
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
dialog = builder.show()
}
}
private fun editUrl() {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.edit_url_menu)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
if (downloader != null) {
dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source)
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
setLoadingLayout()
lookupUrlAndDownload(dialogBinding.urlEditText.text.toString())
}
builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() }
builder.setOnCancelListener {
setResult(RESULT_ERROR)
finish()
}
builder.show()
}
/**
*
* @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found).
*/
private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean {
val fd = FeedDiscoverer()
val urlsMap: Map<String, String>
try {
urlsMap = fd.findLinks(feedFile, baseUrl)
if (urlsMap.isEmpty()) return false
} catch (e: IOException) {
e.printStackTrace()
return false
}
if (isPaused || isFinishing) return false
val titles: MutableList<String?> = ArrayList()
val urls: List<String> = ArrayList(urlsMap.keys)
for (url in urls) {
titles.add(urlsMap[url])
}
if (urls.size == 1) {
// Skip dialog and display the item directly
resetIntent(urls[0])
startFeedDownload(urls[0])
return true
}
val adapter = ArrayAdapter(this@OnlineFeedViewActivity,
R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles)
val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int ->
val selectedUrl = urls[which]
dialog.dismiss()
resetIntent(selectedUrl)
startFeedDownload(selectedUrl)
}
val ab = MaterialAlertDialogBuilder(this@OnlineFeedViewActivity)
.setTitle(R.string.feeds_label)
.setCancelable(true)
.setOnCancelListener { _: DialogInterface? -> finish() }
.setAdapter(adapter, onClickListener)
runOnUiThread {
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
dialog = ab.show()
}
return true
}
private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) :
AuthenticationDialog(context, titleRes, true, username, password) {
override fun onCancelled() {
super.onCancelled()
finish()
}
override fun onConfirmed(username: String, password: String) {
this@OnlineFeedViewActivity.username = username
this@OnlineFeedViewActivity.password = password
startFeedDownload(feedUrl)
}
}
companion object {
const val ARG_FEEDURL: String = "arg.feedurl"
const val ARG_WAS_MANUAL_URL: String = "manual_url"
private const val RESULT_ERROR = 2
private const val TAG = "OnlineFeedViewActivity"
private const val PREFS = "OnlineFeedViewActivityPreferences"
private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload"
private const val DESCRIPTION_MAX_LINES_COLLAPSED = 20
}
}

View File

@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity
import ac.mdiq.podcini.preferences.ThemeSwitcher.getTranslucentTheme
import ac.mdiq.podcini.ui.dialog.VariableSpeedDialog
// This is for widget
class PlaybackSpeedDialogActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(getTranslucentTheme(this))

View File

@ -33,15 +33,16 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
// TODO: need to enable
class SelectSubscriptionActivity : AppCompatActivity() {
private var disposable: Disposable? = null
@Volatile
private var listItems: List<Feed>? = null
private var _binding: SubscriptionSelectionActivityBinding? = null
private val binding get() = _binding!!
@Volatile
private var listItems: List<Feed> = listOf()
private var disposable: Disposable? = null
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(ThemeSwitcher.getTranslucentTheme(this))
super.onCreate(savedInstanceState)
@ -64,7 +65,7 @@ class SelectSubscriptionActivity : AppCompatActivity() {
}
binding.shortcutBtn.setOnClickListener {
if (checkedPosition[0] != null && Intent.ACTION_CREATE_SHORTCUT == intent.action) {
getBitmapFromUrl(listItems!![checkedPosition[0]!!])
getBitmapFromUrl(listItems[checkedPosition[0]!!])
}
}
}

View File

@ -8,6 +8,7 @@ import ac.mdiq.podcini.util.PlaybackStatus.isCurrentlyPlaying
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isStreamOverDownload
import ac.mdiq.podcini.storage.model.playback.RemoteMedia
abstract class ItemActionButton internal constructor(@JvmField var item: FeedItem) {
abstract fun getLabel(): Int
@ -38,9 +39,6 @@ abstract class ItemActionButton internal constructor(@JvmField var item: FeedIte
isCurrentlyPlaying(media) -> {
PauseActionButton(item)
}
item.feed == null -> {
StreamActionButton(item)
}
item.feed != null && item.feed!!.isLocalFeed -> {
PlayLocalActionButton(item)
}
@ -50,7 +48,7 @@ abstract class ItemActionButton internal constructor(@JvmField var item: FeedIte
isDownloadingMedia -> {
CancelDownloadActionButton(item)
}
isStreamOverDownload || item.feed == null -> {
isStreamOverDownload || item.feed == null || item.feedId == 0L -> {
StreamActionButton(item)
}
else -> {

View File

@ -1,6 +1,15 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AddfeedBinding
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.net.discovery.*
import ac.mdiq.podcini.net.download.FeedUpdateManager
import ac.mdiq.podcini.storage.DBTasks
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.SortOrder
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import android.content.*
import android.net.Uri
import android.os.Bundle
@ -17,16 +26,6 @@ import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
import ac.mdiq.podcini.storage.DBTasks
import ac.mdiq.podcini.net.download.FeedUpdateManager
import ac.mdiq.podcini.databinding.AddfeedBinding
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.SortOrder
import ac.mdiq.podcini.net.discovery.*
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
@ -135,10 +134,8 @@ class AddFeedFragment : Fragment() {
}
private fun addUrl(url: String) {
val intent = Intent(getActivity(), OnlineFeedViewActivity::class.java)
intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, url)
intent.putExtra(OnlineFeedViewActivity.ARG_WAS_MANUAL_URL, true)
startActivity(intent)
val fragment: Fragment = OnlineFeedViewFragment.newInstance(url)
(activity as MainActivity).loadChildFragment(fragment)
}
private fun performSearch() {

View File

@ -7,12 +7,11 @@ import ac.mdiq.podcini.databinding.SelectCountryDialogBinding
import ac.mdiq.podcini.net.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
@ -23,8 +22,10 @@ import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
import android.widget.*
import android.widget.AdapterView.OnItemClickListener
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.MaterialAutoCompleteTextView
@ -95,7 +96,7 @@ class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
// Inflate the layout for this fragment
_binding = FragmentItunesSearchBinding.inflate(inflater)
// val root = inflater.inflate(R.layout.fragment_itunes_search, container, false)
@ -119,9 +120,8 @@ class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (podcast.feedUrl == null) {
return@OnItemClickListener
}
val intent = Intent(activity, OnlineFeedViewActivity::class.java)
intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast.feedUrl)
startActivity(intent)
val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
(activity as MainActivity).loadChildFragment(fragment)
}
progressBar = binding.progressBar

View File

@ -234,7 +234,7 @@ class ExternalPlayerFragment : Fragment(), SeekBar.OnSeekBarChangeListener {
val duration: Int = converter.convert(event.duration)
val remainingTime: Int = converter.convert(max((event.duration - event.position).toDouble(), 0.0).toInt())
if (currentPosition == Playable.INVALID_TIME || duration == Playable.INVALID_TIME) {
Log.w(AudioPlayerFragment.TAG, "Could not react to position observer update because of invalid time")
Log.w(TAG, "Could not react to position observer update because of invalid time")
return
}

View File

@ -0,0 +1,646 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.databinding.OnlineFeedviewFragmentBinding
import ac.mdiq.podcini.feed.FeedUrlNotFoundException
import ac.mdiq.podcini.feed.parser.FeedHandler
import ac.mdiq.podcini.feed.parser.FeedHandlerResult
import ac.mdiq.podcini.feed.parser.UnsupportedFeedtypeException
import ac.mdiq.podcini.net.common.UrlChecker.prepareUrl
import ac.mdiq.podcini.net.discovery.CombinedSearcher
import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterface
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.service.download.DownloadRequestCreator.create
import ac.mdiq.podcini.service.download.Downloader
import ac.mdiq.podcini.service.download.HttpDownloader
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.storage.DBTasks
import ac.mdiq.podcini.storage.DBWriter
import ac.mdiq.podcini.storage.model.download.DownloadError
import ac.mdiq.podcini.storage.model.download.DownloadResult
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.common.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.ui.dialog.AuthenticationDialog
import ac.mdiq.podcini.ui.glide.FastBlurTransformation
import ac.mdiq.podcini.util.DownloadErrorLabel.from
import ac.mdiq.podcini.util.event.EpisodeDownloadEvent
import ac.mdiq.podcini.util.event.FeedListUpdateEvent
import ac.mdiq.podcini.util.syndication.FeedDiscoverer
import ac.mdiq.podcini.util.syndication.HtmlToPlainText
import android.app.Dialog
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.DialogInterface
import android.graphics.LightingColorFilter
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.annotation.UiThread
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.Maybe
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.observers.DisposableMaybeObserver
import io.reactivex.schedulers.Schedulers
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.io.IOException
import kotlin.concurrent.Volatile
/**
* Downloads a feed from a feed URL and parses it. Subclasses can display the
* feed object that was parsed. This activity MUST be started with a given URL
* or an Exception will be thrown.
*
*
* If the feed cannot be downloaded or parsed, an error dialog will be displayed
* and the activity will finish as soon as the error dialog is closed.
*/
class OnlineFeedViewFragment : Fragment() {
private var _binding: OnlineFeedviewFragmentBinding? = null
private val binding get() = _binding!!
private var displayUpArrow = false
@Volatile
private var feeds: List<Feed>? = null
private var selectedDownloadUrl: String? = null
private var downloader: Downloader? = null
private var username: String? = null
private var password: String? = null
private var isPaused = false
private var didPressSubscribe = false
private var isFeedFoundBySearch = false
private var dialog: Dialog? = null
private var download: Disposable? = null
private var parser: Disposable? = null
private var updater: Disposable? = null
@OptIn(UnstableApi::class) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = OnlineFeedviewFragmentBinding.inflate(layoutInflater)
binding.closeButton.visibility = View.INVISIBLE
binding.card.setOnClickListener(null)
binding.card.setCardBackgroundColor(getColorFromAttr(requireContext(), R.attr.colorSurface))
Log.d(TAG, "fragment onCreateView")
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
if (savedInstanceState != null) {
displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
}
(activity as MainActivity).setupToolbarToggle(binding.toolbar, displayUpArrow)
var feedUrl = requireArguments().getString(ARG_FEEDURL)
if (feedUrl == null) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
} else {
Log.d(TAG, "Activity was started with url $feedUrl")
setLoadingLayout()
// Remove subscribeonandroid.com from feed URL in order to subscribe to the actual feed URL
if (feedUrl.contains("subscribeonandroid.com")) {
feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))".toRegex(), "")
}
if (savedInstanceState != null) {
username = savedInstanceState.getString("username")
password = savedInstanceState.getString("password")
}
lookupUrlAndDownload(feedUrl)
}
return binding.root
}
private fun showNoPodcastFoundError() {
requireActivity().runOnUiThread {
MaterialAlertDialogBuilder(requireContext())
.setNeutralButton(android.R.string.ok) { _: DialogInterface?, _: Int -> }
.setTitle(R.string.error_label)
.setMessage(R.string.null_value_podcast_error)
.setOnDismissListener {}
.show()
}
}
/**
* Displays a progress indicator.
*/
private fun setLoadingLayout() {
binding.progressBar.visibility = View.VISIBLE
binding.feedDisplayContainer.visibility = View.GONE
}
override fun onStart() {
super.onStart()
isPaused = false
EventBus.getDefault().register(this)
}
override fun onStop() {
super.onStop()
isPaused = true
EventBus.getDefault().unregister(this)
if (downloader != null && !downloader!!.isFinished) downloader!!.cancel()
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
}
override fun onDestroy() {
super.onDestroy()
_binding = null
updater?.dispose()
download?.dispose()
parser?.dispose()
}
@OptIn(UnstableApi::class) override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_UP_ARROW, displayUpArrow)
super.onSaveInstanceState(outState)
outState.putString("username", username)
outState.putString("password", password)
}
private fun lookupUrlAndDownload(url: String) {
download = PodcastSearcherRegistry.lookupUrl(url)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.subscribe({ url1: String -> this.startFeedDownload(url1) },
{ error: Throwable? ->
if (error is FeedUrlNotFoundException) {
tryToRetrieveFeedUrlBySearch(error)
} else {
showNoPodcastFoundError()
Log.e(TAG, Log.getStackTraceString(error))
}
})
}
private fun tryToRetrieveFeedUrlBySearch(error: FeedUrlNotFoundException) {
Log.d(TAG, "Unable to retrieve feed url, trying to retrieve feed url from search")
val url = searchFeedUrlByTrackName(error.trackName, error.artistName)
if (url != null) {
Log.d(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true
startFeedDownload(url)
} else {
showNoPodcastFoundError()
Log.d(TAG, "Failed to retrieve feed url")
}
}
private fun searchFeedUrlByTrackName(trackName: String, artistName: String): String? {
val searcher = CombinedSearcher()
val query = "$trackName $artistName"
val results = searcher.search(query).blockingGet()
if (results.isNullOrEmpty()) return null
for (result in results) {
if (result?.feedUrl != null && result.author != null &&
result.author.equals(artistName, ignoreCase = true) &&
result.title.equals(trackName, ignoreCase = true)) {
return result.feedUrl
}
}
return null
}
private fun startFeedDownload(url: String) {
Log.d(TAG, "Starting feed download")
selectedDownloadUrl = prepareUrl(url)
val request = create(Feed(selectedDownloadUrl, null))
.withAuthentication(username, password)
.withInitiatedByUser(true)
.build()
download = Observable.fromCallable {
feeds = DBReader.getFeedList()
downloader = HttpDownloader(request)
downloader?.call()
downloader?.result
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ status: DownloadResult? -> if (request.destination != null) checkDownloadResult(status, request.destination) },
{ error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) })
}
private fun checkDownloadResult(status: DownloadResult?, destination: String) {
if (status == null) return
when {
status.isSuccessful -> {
parseFeed(destination)
}
status.reason == DownloadError.ERROR_UNAUTHORIZED -> {
if (!isRemoving && !isPaused) {
if (username != null && password != null) {
Toast.makeText(requireContext(), R.string.download_error_unauthorized, Toast.LENGTH_LONG).show()
}
if (downloader?.downloadRequest?.source != null) {
dialog = FeedViewAuthenticationDialog(requireContext(),
R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create()
dialog?.show()
}
}
}
else -> {
showErrorDialog(getString(from(status.reason)), status.reasonDetailed)
}
}
}
@UnstableApi @Subscribe
fun onFeedListChanged(event: FeedListUpdateEvent?) {
updater = Observable.fromCallable { DBReader.getFeedList() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ feeds: List<Feed>? ->
this@OnlineFeedViewFragment.feeds = feeds
handleUpdatedFeedStatus()
}, { error: Throwable? -> Log.e(TAG, Log.getStackTraceString(error)) }
)
}
@UnstableApi @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
fun onEventMainThread(event: EpisodeDownloadEvent?) {
handleUpdatedFeedStatus()
}
private fun parseFeed(destination: String) {
Log.d(TAG, "Parsing feed")
parser = Maybe.fromCallable { doParseFeed(destination) }
.subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(object : DisposableMaybeObserver<FeedHandlerResult?>() {
@UnstableApi override fun onSuccess(result: FeedHandlerResult) {
showFeedInformation(result.feed, result.alternateFeedUrls)
}
override fun onComplete() {
// Ignore null result: We showed the discovery dialog.
}
override fun onError(error: Throwable) {
showErrorDialog(error.message, "")
Log.d(TAG, "Feed parser exception: " + Log.getStackTraceString(error))
}
})
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
private fun doParseFeed(destination: String): FeedHandlerResult? {
val handler = FeedHandler()
val feed = Feed(selectedDownloadUrl, null)
feed.file_url = destination
val destinationFile = File(destination)
return try {
handler.parseFeed(feed)
} catch (e: UnsupportedFeedtypeException) {
Log.d(TAG, "Unsupported feed type detected")
if ("html".equals(e.rootElement, ignoreCase = true)) {
if (selectedDownloadUrl != null) {
val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!)
if (dialogShown) {
null // Should not display an error message
} else {
throw UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html))
}
} else null
} else {
throw e
}
} catch (e: Exception) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
val rc = destinationFile.delete()
Log.d(TAG, "Deleted feed source file. Result: $rc")
}
}
/**
* Called when feed parsed successfully.
* This method is executed on the GUI thread.
*/
@UnstableApi private fun showFeedInformation(feed: Feed, alternateFeedUrls: Map<String, String>) {
binding.progressBar.visibility = View.GONE
binding.feedDisplayContainer.visibility = View.VISIBLE
if (isFeedFoundBySearch) {
val resId = R.string.no_feed_url_podcast_found_by_search
Snackbar.make(binding.root, resId, Snackbar.LENGTH_LONG).show()
}
binding.backgroundImage.colorFilter = LightingColorFilter(-0x7d7d7e, 0x000000)
binding.episodeLabel.setOnClickListener { showEpisodes(feed.items)}
if (!feed.imageUrl.isNullOrBlank()) {
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.light_gray)
.error(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(binding.coverImage)
Glide.with(this)
.load(feed.imageUrl)
.apply(RequestOptions()
.placeholder(R.color.image_readability_tint)
.error(R.color.image_readability_tint)
.transform(FastBlurTransformation())
.dontAnimate())
.into(binding.backgroundImage)
}
binding.titleLabel.text = feed.title
binding.authorLabel.text = feed.author
binding.txtvDescription.text = HtmlToPlainText.getPlainText(feed.description?:"")
binding.subscribeButton.setOnClickListener {
if (feedInFeedlist()) {
openFeed()
} else {
DBTasks.updateFeed(requireContext(), feed, false)
didPressSubscribe = true
handleUpdatedFeedStatus()
}
}
if (isEnableAutodownload) {
val preferences = requireContext().getSharedPreferences(PREFS, MODE_PRIVATE)
binding.autoDownloadCheckBox.isChecked = preferences.getBoolean(PREF_LAST_AUTO_DOWNLOAD, true)
}
if (alternateFeedUrls.isEmpty()) {
binding.alternateUrlsSpinner.visibility = View.GONE
} else {
binding.alternateUrlsSpinner.visibility = View.VISIBLE
val alternateUrlsList: MutableList<String> = ArrayList()
val alternateUrlsTitleList: MutableList<String?> = ArrayList()
if (feed.download_url != null) alternateUrlsList.add(feed.download_url!!)
alternateUrlsTitleList.add(feed.title)
alternateUrlsList.addAll(alternateFeedUrls.keys)
for (url in alternateFeedUrls.keys) {
alternateUrlsTitleList.add(alternateFeedUrls[url])
}
val adapter: ArrayAdapter<String> = object : ArrayAdapter<String>(requireContext(),
R.layout.alternate_urls_item, alternateUrlsTitleList) {
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
// reusing the old view causes a visual bug on Android <= 10
return super.getDropDownView(position, null, parent)
}
}
adapter.setDropDownViewResource(R.layout.alternate_urls_dropdown_item)
binding.alternateUrlsSpinner.adapter = adapter
binding.alternateUrlsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
selectedDownloadUrl = alternateUrlsList[position]
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
}
handleUpdatedFeedStatus()
}
@UnstableApi private fun openFeed() {
// feed.getId() is always 0, we have to retrieve the id from the feed list from
// the database
(activity as MainActivity).loadFeedFragmentById(feedId, null)
}
@UnstableApi private fun showEpisodes(episodes: List<FeedItem>) {
Log.d(TAG, "showEpisodes ${episodes.size}")
if (episodes.isNullOrEmpty()) return
val fragment: Fragment = EpisodesListFragment.newInstance(ArrayList(episodes))
(activity as MainActivity).loadChildFragment(fragment)
}
@UnstableApi private fun handleUpdatedFeedStatus() {
val dli = DownloadServiceInterface.get()
if (dli == null || selectedDownloadUrl == null) return
when {
dli.isDownloadingEpisode(selectedDownloadUrl!!) -> {
binding.subscribeButton.isEnabled = false
binding.subscribeButton.setText(R.string.subscribing_label)
}
feedInFeedlist() -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.open)
if (didPressSubscribe) {
didPressSubscribe = false
val feed1 = DBReader.getFeed(feedId)?: return
val feedPreferences = feed1.preferences
if (feedPreferences != null) {
if (isEnableAutodownload) {
val autoDownload = binding.autoDownloadCheckBox.isChecked
feedPreferences.autoDownload = autoDownload
val preferences = requireContext().getSharedPreferences(PREFS, MODE_PRIVATE)
val editor = preferences.edit()
editor.putBoolean(PREF_LAST_AUTO_DOWNLOAD, autoDownload)
editor.apply()
}
if (username != null) {
feedPreferences.username = username
feedPreferences.password = password
}
DBWriter.setFeedPreferences(feedPreferences)
}
openFeed()
}
}
else -> {
binding.subscribeButton.isEnabled = true
binding.subscribeButton.setText(R.string.subscribe_label)
if (isEnableAutodownload) {
binding.autoDownloadCheckBox.visibility = View.VISIBLE
}
}
}
}
private fun feedInFeedlist(): Boolean {
return feedId != 0L
}
private val feedId: Long
get() {
if (feeds == null) return 0
for (f in feeds!!) {
if (f.download_url == selectedDownloadUrl) {
return f.id
}
}
return 0
}
@UiThread
private fun showErrorDialog(errorMsg: String?, details: String) {
if (!isRemoving && !isPaused) {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.error_label)
if (errorMsg != null) {
val total = """
$errorMsg
$details
""".trimIndent()
val errorMessage = SpannableString(total)
errorMessage.setSpan(ForegroundColorSpan(-0x77777778),
errorMsg.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
builder.setMessage(errorMessage)
} else {
builder.setMessage(R.string.download_error_error_unknown)
}
builder.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> dialog.cancel() }
// if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) {
// builder.setNeutralButton(R.string.edit_url_menu) { _: DialogInterface?, _: Int -> editUrl() }
// }
builder.setOnCancelListener {
// setResult(RESULT_ERROR)
// finish()
}
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
dialog = builder.show()
}
}
private fun editUrl() {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.edit_url_menu)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
if (downloader != null) {
dialogBinding.urlEditText.setText(downloader!!.downloadRequest.source)
}
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
setLoadingLayout()
lookupUrlAndDownload(dialogBinding.urlEditText.text.toString())
}
builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() }
builder.setOnCancelListener {}
builder.show()
}
/**
*
* @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found).
*/
private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean {
val fd = FeedDiscoverer()
val urlsMap: Map<String, String>
try {
urlsMap = fd.findLinks(feedFile, baseUrl)
if (urlsMap.isEmpty()) return false
} catch (e: IOException) {
e.printStackTrace()
return false
}
if (isRemoving || isPaused) return false
val titles: MutableList<String?> = ArrayList()
val urls: List<String> = ArrayList(urlsMap.keys)
for (url in urls) {
titles.add(urlsMap[url])
}
if (urls.size == 1) {
// Skip dialog and display the item directly
startFeedDownload(urls[0])
return true
}
val adapter = ArrayAdapter(requireContext(), R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles)
val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int ->
val selectedUrl = urls[which]
dialog.dismiss()
startFeedDownload(selectedUrl)
}
val ab = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.feeds_label)
.setCancelable(true)
.setOnCancelListener { _: DialogInterface? ->
// finish()
}
.setAdapter(adapter, onClickListener)
requireActivity().runOnUiThread {
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
dialog = ab.show()
}
return true
}
private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) :
AuthenticationDialog(context, titleRes, true, username, password) {
override fun onConfirmed(username: String, password: String) {
this@OnlineFeedViewFragment.username = username
this@OnlineFeedViewFragment.password = password
startFeedDownload(feedUrl)
}
}
companion object {
const val ARG_FEEDURL: String = "arg.feedurl"
const val ARG_WAS_MANUAL_URL: String = "manual_url"
private const val RESULT_ERROR = 2
private const val TAG = "OnlineFeedViewFragment"
private const val PREFS = "OnlineFeedViewFragmentPreferences"
private const val PREF_LAST_AUTO_DOWNLOAD = "lastAutoDownload"
private const val KEY_UP_ARROW = "up_arrow"
@JvmStatic
fun newInstance(feedUrl: String): OnlineFeedViewFragment {
val fragment = OnlineFeedViewFragment()
val b = Bundle()
b.putString(ARG_FEEDURL, feedUrl)
fragment.arguments = b
return fragment
}
}
}

View File

@ -1,8 +1,13 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentItunesSearchBinding
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import ac.mdiq.podcini.net.discovery.PodcastSearcher
import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@ -15,13 +20,6 @@ import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.google.android.material.appbar.MaterialToolbar
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.FragmentItunesSearchBinding
import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity
import ac.mdiq.podcini.ui.adapter.OnlineFeedsAdapter
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import ac.mdiq.podcini.net.discovery.PodcastSearcher
import ac.mdiq.podcini.net.discovery.PodcastSearcherRegistry
import io.reactivex.disposables.Disposable
class OnlineSearchFragment : Fragment() {
@ -70,11 +68,9 @@ class OnlineSearchFragment : Fragment() {
//Show information about the podcast when the list item is clicked
gridView.onItemClickListener = AdapterView.OnItemClickListener { _: AdapterView<*>?, _: View?, position: Int, _: Long ->
val podcast = searchResults!![position]
if (podcast != null) {
val intent = Intent(activity, OnlineFeedViewActivity::class.java)
intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast.feedUrl)
intent.putExtra(MainActivity.EXTRA_STARTED_FROM_SEARCH, true)
startActivity(intent)
if (podcast?.feedUrl != null) {
val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast.feedUrl)
(activity as MainActivity).loadChildFragment(fragment)
}
}
progressBar = binding.progressBar

View File

@ -3,15 +3,13 @@ package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.QuickFeedDiscoveryBinding
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity
import ac.mdiq.podcini.ui.adapter.FeedDiscoverAdapter
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent
import ac.mdiq.podcini.net.discovery.ItunesTopListLoader
import ac.mdiq.podcini.net.discovery.PodcastSearchResult
import ac.mdiq.podcini.storage.DBReader
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.FeedDiscoverAdapter
import ac.mdiq.podcini.util.event.DiscoveryDefaultUpdateEvent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.util.DisplayMetrics
@ -158,14 +156,12 @@ class QuickFeedDiscoveryFragment : Fragment(), AdapterView.OnItemClickListener {
})
}
override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
@OptIn(UnstableApi::class) override fun onItemClick(parent: AdapterView<*>?, view: View, position: Int, id: Long) {
val podcast: PodcastSearchResult? = adapter.getItem(position)
if (podcast?.feedUrl.isNullOrEmpty()) {
return
}
val intent = Intent(activity, OnlineFeedViewActivity::class.java)
intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, podcast!!.feedUrl)
startActivity(intent)
if (podcast?.feedUrl.isNullOrEmpty()) return
val fragment: Fragment = OnlineFeedViewFragment.newInstance(podcast!!.feedUrl!!)
(activity as MainActivity).loadChildFragment(fragment)
}
companion object {

View File

@ -1,9 +1,29 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.databinding.SearchFragmentBinding
import ac.mdiq.podcini.net.discovery.CombinedSearcher
import ac.mdiq.podcini.playback.event.PlaybackPositionEvent
import ac.mdiq.podcini.storage.FeedSearcher
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.adapter.EpisodeItemListAdapter
import ac.mdiq.podcini.ui.adapter.HorizontalFeedListAdapter
import ac.mdiq.podcini.ui.adapter.SelectableAdapter
import ac.mdiq.podcini.ui.fragment.actions.EpisodeMultiSelectActionHandler
import ac.mdiq.podcini.ui.menuhandler.FeedItemMenuHandler
import ac.mdiq.podcini.ui.menuhandler.FeedMenuHandler
import ac.mdiq.podcini.ui.menuhandler.MenuItemUtils
import ac.mdiq.podcini.ui.view.EmptyViewHandler
import ac.mdiq.podcini.ui.view.EpisodeItemListRecyclerView
import ac.mdiq.podcini.ui.view.LiftOnScrollListener
import ac.mdiq.podcini.ui.view.viewholder.EpisodeItemViewHolder
import ac.mdiq.podcini.util.FeedItemUtil
import ac.mdiq.podcini.util.event.*
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@ -22,28 +42,6 @@ import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import ac.mdiq.podcini.R
import ac.mdiq.podcini.ui.activity.OnlineFeedViewActivity
import ac.mdiq.podcini.ui.adapter.EpisodeItemListAdapter
import ac.mdiq.podcini.ui.adapter.HorizontalFeedListAdapter
import ac.mdiq.podcini.ui.adapter.SelectableAdapter
import ac.mdiq.podcini.ui.menuhandler.MenuItemUtils
import ac.mdiq.podcini.storage.FeedSearcher
import ac.mdiq.podcini.util.FeedItemUtil
import ac.mdiq.podcini.databinding.MultiSelectSpeedDialBinding
import ac.mdiq.podcini.databinding.SearchFragmentBinding
import ac.mdiq.podcini.util.event.*
import ac.mdiq.podcini.playback.event.PlaybackPositionEvent
import ac.mdiq.podcini.ui.fragment.actions.EpisodeMultiSelectActionHandler
import ac.mdiq.podcini.ui.menuhandler.FeedItemMenuHandler
import ac.mdiq.podcini.ui.menuhandler.FeedMenuHandler
import ac.mdiq.podcini.storage.model.feed.Feed
import ac.mdiq.podcini.storage.model.feed.FeedItem
import ac.mdiq.podcini.net.discovery.CombinedSearcher
import ac.mdiq.podcini.ui.view.EmptyViewHandler
import ac.mdiq.podcini.ui.view.EpisodeItemListRecyclerView
import ac.mdiq.podcini.ui.view.LiftOnScrollListener
import ac.mdiq.podcini.ui.view.viewholder.EpisodeItemViewHolder
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
@ -140,7 +138,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener {
requireArguments().putLong(ARG_FEED, 0)
searchWithProgressBar()
}
chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE
chip.visibility = if (requireArguments().getLong(ARG_FEED, 0) == 0L) View.GONE else View.VISIBLE
chip.text = requireArguments().getString(ARG_FEED_NAME, "")
if (requireArguments().getString(ARG_QUERY, null) != null) {
search()
@ -295,7 +293,7 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener {
if (currentPlaying != null && currentPlaying!!.isCurrentlyPlayingItem)
currentPlaying!!.notifyPlaybackPositionUpdated(event)
else {
Log.d(FeedItemlistFragment.TAG, "onEventMainThread() search list")
Log.d(TAG, "onEventMainThread() search list")
for (i in 0 until adapter.itemCount) {
val holder: EpisodeItemViewHolder? =
recyclerView.findViewHolderForAdapterPosition(i) as? EpisodeItemViewHolder
@ -368,9 +366,8 @@ class SearchFragment : Fragment(), SelectableAdapter.OnSelectModeListener {
inVal.hideSoftInputFromWindow(searchView.windowToken, 0)
val query = searchView.query.toString()
if (query.matches("http[s]?://.*".toRegex())) {
val intent = Intent(activity, OnlineFeedViewActivity::class.java)
intent.putExtra(OnlineFeedViewActivity.ARG_FEEDURL, query)
startActivity(intent)
val fragment: Fragment = OnlineFeedViewFragment.newInstance(query)
(activity as MainActivity).loadChildFragment(fragment)
return
}
(activity as MainActivity).loadChildFragment(

View File

@ -0,0 +1,240 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:elevation="0dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/online_feed"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/transparentBackground"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.cardview.widget.CardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="8dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
style="?android:attr/progressBarStyle" />
<LinearLayout
android:id="@+id/feed_display_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/feeditemlist_header_height"
android:layout_marginBottom="12dp"
android:background="@color/feed_image_bg">
<ImageView
android:id="@+id/backgroundImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/coverImage"
android:layout_width="@dimen/thumbnail_length_onlinefeedview"
android:layout_height="@dimen/thumbnail_length_onlinefeedview"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_centerVertical="true"
android:layout_marginBottom="12dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:background="@drawable/bg_rounded_corners"
android:clipToOutline="true"
android:importantForAccessibility="no"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/titleLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_marginBottom="5dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginRight="24dp"
android:layout_marginEnd="24dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:maxLines="2"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="18sp"
android:textFontWeight="800"
tools:text="Podcast title" />
<TextView
android:id="@+id/author_label"
android:layout_width="0dip"
android:layout_height="wrap_content"
android:layout_below="@id/titleLabel"
android:layout_marginBottom="8dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:lines="1"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="@dimen/text_size_small"
tools:text="Podcast author" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/author_label"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/subscribeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginStart="10dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:text="@string/subscribe_label" />
<Button
android:id="@+id/episodeLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="10dp"
android:layout_marginEnd="10dp"
android:text="@string/episodes_label"/>
</LinearLayout>
<ImageButton
android:id="@+id/closeButton"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentRight="true"
android:layout_marginTop="12dp"
android:layout_marginRight="12dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_close_white" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:orientation="vertical">
<Spinner
android:id="@+id/alternate_urls_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:dropDownWidth="match_parent"
android:padding="8dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="@dimen/text_size_micro" />
<CheckBox
android:id="@+id/autoDownloadCheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:checked="true"
android:layout_marginTop="8dp"
android:text="@string/auto_download_label"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
<LinearLayout
android:id="@+id/online_feed_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/description_label"
style="@style/TextAppearance.Material3.TitleMedium" />
<TextView
android:id="@+id/txtvDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:ellipsize="end"
android:lineHeight="20dp"
style="@style/Podcini.TextView.ListItemBody"
tools:text="@string/design_time_lorem_ipsum" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</LinearLayout>

View File

@ -1,230 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/transparentBackground"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.cardview.widget.CardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="8dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
style="?android:attr/progressBarStyle" />
<LinearLayout
android:id="@+id/feed_display_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="@dimen/feeditemlist_header_height"
android:layout_marginBottom="12dp"
android:background="@color/feed_image_bg">
<ImageView
android:id="@+id/backgroundImage"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop" />
<ImageView
android:id="@+id/coverImage"
android:layout_width="@dimen/thumbnail_length_onlinefeedview"
android:layout_height="@dimen/thumbnail_length_onlinefeedview"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_centerVertical="true"
android:layout_marginBottom="12dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:background="@drawable/bg_rounded_corners"
android:clipToOutline="true"
android:importantForAccessibility="no"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/titleLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_marginBottom="5dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="10dp"
android:layout_marginRight="24dp"
android:layout_marginEnd="24dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:maxLines="2"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="18sp"
android:textFontWeight="800"
tools:text="Podcast title" />
<TextView
android:id="@+id/author_label"
android:layout_width="0dip"
android:layout_height="wrap_content"
android:layout_below="@id/titleLabel"
android:layout_marginBottom="8dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:ellipsize="end"
android:lines="1"
android:shadowColor="@color/black"
android:shadowRadius="3"
android:textColor="@color/white"
android:textSize="@dimen/text_size_small"
tools:text="Podcast author" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/author_label"
android:layout_toRightOf="@id/coverImage"
android:layout_toEndOf="@id/coverImage"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/subscribeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginStart="10dp"
android:layout_marginRight="16dp"
android:layout_marginEnd="16dp"
android:text="@string/subscribe_label" />
<Button
android:id="@+id/episodeLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginRight="10dp"
android:layout_marginEnd="10dp"
android:text="@string/episodes_label"/>
</LinearLayout>
<ImageButton
android:id="@+id/closeButton"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_alignParentRight="true"
android:layout_marginTop="12dp"
android:layout_marginRight="12dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_close_white" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:orientation="vertical">
<Spinner
android:id="@+id/alternate_urls_spinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:dropDownWidth="match_parent"
android:padding="8dp"
android:textColor="?android:attr/textColorPrimary"
android:textSize="@dimen/text_size_micro" />
<CheckBox
android:id="@+id/autoDownloadCheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="left"
android:checked="true"
android:layout_marginTop="8dp"
android:text="@string/auto_download_label"
android:visibility="gone"
tools:visibility="visible" />
<!-- <Button-->
<!-- android:id="@+id/stopPreviewButton"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginTop="4dp"-->
<!-- android:text="@string/stop_preview"-->
<!-- android:visibility="gone"-->
<!-- tools:visibility="visible" />-->
</LinearLayout>
<!-- <ListView-->
<!-- android:id="@+id/listView"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:paddingTop="16dp"-->
<!-- android:clipToPadding="false" />-->
<LinearLayout
android:id="@+id/online_feed_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="16dp"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="@string/description_label"
style="@style/TextAppearance.Material3.TitleMedium" />
<TextView
android:id="@+id/txtvDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:ellipsize="end"
android:lineHeight="20dp"
style="@style/Podcini.TextView.ListItemBody"
tools:text="@string/design_time_lorem_ipsum" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>

View File

@ -670,6 +670,7 @@
<string name="pref_pausePlaybackForFocusLoss_title">Pause for interruptions</string>
<!-- Online feed view -->
<string name="online_feed">Online feed</string>
<string name="subscribe_label">Subscribe</string>
<string name="subscribing_label">Subscribing&#8230;</string>
<string name="preview_episode">Preview</string>

View File

@ -172,3 +172,12 @@
* unrestricted the titles to 2 lines in player details view
* fixed once again the bug of player disappear on first play
* some code refactoring
## 4.4.3
* created online feed view fragment
* online episodes list view is no longer restricted to 50 episodes
* online episodes list view now better handles icons
* online episodes list view goes back to the online feed view
* the original online feed view activity is only preserved for receiving shared feed
* externally shared feed opens in the online feed view fragment

View File

@ -0,0 +1,9 @@
Version 4.4.3 brings several changes:
* created online feed view fragment
* online episodes list view is no longer restricted to 50 episodes
* online episodes list view now better handles icons
* online episodes list view goes back to the online feed view
* the original online feed view activity is only preserved for receiving shared feed
* externally shared feed opens in the online feed view fragment