diff --git a/Licenses_and_permissions.md b/Licenses_and_permissions.md
index cc883a75..5cb8c377 100644
--- a/Licenses_and_permissions.md
+++ b/Licenses_and_permissions.md
@@ -30,12 +30,12 @@ Apache License 2.0
[com.github.skydoves](https://github.com/skydoves/Only/blob/master/LICENSE) Apache License 2.0
-[com.github.xabaras](https://github.com/xabaras/RecyclerViewSwipeDecorator/blob/master/LICENSE) Apache License 2.0
+[//]: # ([com.github.xabaras](https://github.com/xabaras/RecyclerViewSwipeDecorator/blob/master/LICENSE) Apache License 2.0)
-[com.annimon](https://github.com/aNNiMON/Lightweight-Stream-API/blob/master/LICENSE) Apache License 2.0
+[//]: # ([com.annimon](https://github.com/aNNiMON/Lightweight-Stream-API/blob/master/LICENSE) Apache License 2.0)
[com.github.mfietz](https://github.com/mfietz/fyydlin/blob/master/LICENSE) Apache License 2.0
-[javax.inject](https://github.com/javax-inject/javax-inject) Apache License 2.0
+[//]: # ([javax.inject](https://github.com/javax-inject/javax-inject) Apache License 2.0)
[org.conscrypt](https://github.com/google/conscrypt/blob/master/LICENSE) Apache License 2.0
diff --git a/README.md b/README.md
index 0edd4a39..77a71b74 100644
--- a/README.md
+++ b/README.md
@@ -24,18 +24,16 @@ This project was developed from a fork of [AntennaPod](
-
+
+
+
+
+
+
()
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- listView.divider = null
+ class LicensesFragment : Fragment() {
+ private val licenses = mutableStateListOf()
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ val composeView = ComposeView(requireContext()).apply { setContent { CustomTheme(requireContext()) { MainView() } } }
lifecycleScope.launch(Dispatchers.IO) {
licenses.clear()
val stream = requireContext().assets.open("licenses.xml")
@@ -83,29 +94,42 @@ class AboutFragment : PreferenceFragmentCompat() {
for (i in 0 until libraryList.length) {
val lib = libraryList.item(i).attributes
licenses.add(LicenseItem(lib.getNamedItem("name").textContent,
- String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent),
- "", lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent))
+ String.format("By %s, %s license", lib.getNamedItem("author").textContent, lib.getNamedItem("license").textContent), lib.getNamedItem("website").textContent, lib.getNamedItem("licenseText").textContent))
}
- withContext(Dispatchers.Main) { listAdapter = SimpleIconListAdapter(requireContext(), licenses) }
}.invokeOnCompletion { throwable -> if (throwable!= null) Toast.makeText(context, throwable.message, Toast.LENGTH_LONG).show() }
+ return composeView
}
- private class LicenseItem(title: String, subtitle: String, imageUrl: String, val licenseUrl: String, val licenseTextFile: String)
- : SimpleIconListAdapter.ListItem(title, subtitle, imageUrl)
-
- override fun onListItemClick(l: ListView, v: View, position: Int, id: Long) {
- super.onListItemClick(l, v, position, id)
-
- val item = licenses[position]
- val items = arrayOf("View website", "View license")
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(item.title)
- .setItems(items) { _: DialogInterface?, which: Int ->
- when (which) {
- 0 -> openInBrowser(requireContext(), item.licenseUrl)
- 1 -> showLicenseText(item.licenseTextFile)
+ @Composable
+ fun MainView() {
+ val lazyListState = rememberLazyListState()
+ val textColor = MaterialTheme.colorScheme.onSurface
+ var showDialog by remember { mutableStateOf(false) }
+ var curLicenseIndex by remember { mutableIntStateOf(-1) }
+ if (showDialog) Dialog(onDismissRequest = { showDialog = false }) {
+ Surface(shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(licenses[curLicenseIndex].title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Row {
+ Button(onClick = { openInBrowser(requireContext(), licenses[curLicenseIndex].licenseUrl) }) { Text("View website") }
+ Spacer(Modifier.weight(1f))
+ Button(onClick = { showLicenseText(licenses[curLicenseIndex].licenseTextFile) }) { Text("View license") }
+ }
}
- }.show()
+ }
+ }
+ LazyColumn(state = lazyListState, modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ itemsIndexed(licenses) { index, item ->
+ Column(Modifier.clickable(onClick = {
+ curLicenseIndex = index
+ showDialog = true
+ })) {
+ Text(item.title, color = textColor, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
+ Text(item.subtitle, color = textColor, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
}
private fun showLicenseText(licenseTextFile: String) {
@@ -122,23 +146,7 @@ class AboutFragment : PreferenceFragmentCompat() {
super.onStart()
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.licenses)
}
- }
- class SimpleIconListAdapter(private val context: Context, private val listItems: List)
- : ArrayAdapter(context, R.layout.simple_icon_list_item, listItems) {
-
- override fun getView(position: Int, view: View?, parent: ViewGroup): View {
- var view = view
- if (view == null) view = View.inflate(context, R.layout.simple_icon_list_item, null)
-
- val item: ListItem = listItems[position]
- val binding = SimpleIconListItemBinding.bind(view!!)
- binding.title.text = item.title
- binding.subtitle.text = item.subtitle
- binding.icon.load(item.imageUrl)
- return view
- }
-
- open class ListItem(val title: String, val subtitle: String, val imageUrl: String)
+ private class LicenseItem(val title: String, val subtitle: String, val licenseUrl: String, val licenseTextFile: String)
}
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt
index 7dae28d1..09fe95cf 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt
@@ -353,6 +353,7 @@ object Feeds {
// Look for new or updated Items
for (idx in newFeed.episodes.indices) {
val episode = newFeed.episodes[idx]
+ if ((episode.media?.duration?: 0) < 1000) continue
if (episode.getPubDate() <= priorMostRecentDate || episode.media?.getStreamUrl() == priorMostRecent?.media?.getStreamUrl()) continue
Logd(TAG, "Found new episode: ${episode.title}")
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt
index 14ee7f23..f0f4887b 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/EpisodeMedia.kt
@@ -91,6 +91,9 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
@Ignore
var forceVideo by mutableStateOf(false)
+ @Ignore
+ var audioUrl = ""
+
/* Used for loading item when restoring from parcel. */
// var episodeId: Long = 0
// private set
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt
index 45fa96b7..23420808 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/BugReportActivity.kt
@@ -98,12 +98,7 @@ class BugReportActivity : AppCompatActivity() {
try {
val authority = getString(R.string.provider_authority)
val fileUri = FileProvider.getUriForFile(this, authority, filename)
-
- IntentBuilder(this)
- .setType("text/*")
- .addStream(fileUri)
- .setChooserTitle(R.string.share_file_label)
- .startChooser()
+ IntentBuilder(this).setType("text/*").addStream(fileUri).setChooserTitle(R.string.share_file_label).startChooser()
} catch (e: Exception) {
e.printStackTrace()
val strResId = R.string.log_file_share_exception
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt
index 3ebecba6..21c8ac7a 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt
@@ -154,7 +154,6 @@ class VideoplayerActivity : CastEnabledActivity() {
if (::videoEpisodeFragment.isInitialized) videoEpisodeFragment.setForVideoMode()
}
-
override fun onResume() {
super.onResume()
setForVideoMode()
@@ -180,7 +179,7 @@ class VideoplayerActivity : CastEnabledActivity() {
public override fun onUserLeaveHint() {
super.onUserLeaveHint()
- if (!isInPictureInPictureMode()) compatEnterPictureInPicture()
+ if (!isInPictureInPictureMode) compatEnterPictureInPicture()
}
override fun onStart() {
@@ -230,13 +229,13 @@ class VideoplayerActivity : CastEnabledActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
- requestCastButton(menu)
+ // TODO: consider enable this
+// requestCastButton(menu)
val inflater = menuInflater
inflater.inflate(R.menu.mediaplayer, menu)
return true
}
-
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
super.onPrepareOptionsMenu(menu)
@@ -501,7 +500,7 @@ class VideoplayerActivity : CastEnabledActivity() {
private val onVideoviewTouched = View.OnTouchListener { v: View, event: MotionEvent ->
Logd(TAG, "onVideoviewTouched ${event.action}")
if (event.action != MotionEvent.ACTION_DOWN) return@OnTouchListener false
- if (requireActivity().isInPictureInPictureMode()) return@OnTouchListener true
+ if (requireActivity().isInPictureInPictureMode) return@OnTouchListener true
videoControlsHider.removeCallbacks(hideVideoControls)
Logd(TAG, "onVideoviewTouched $videoControlsVisible ${System.currentTimeMillis() - lastScreenTap}")
if (System.currentTimeMillis() - lastScreenTap < 300) {
@@ -594,7 +593,7 @@ class VideoplayerActivity : CastEnabledActivity() {
override fun onStop() {
super.onStop()
cancelFlowEvents()
- if (!requireActivity().isInPictureInPictureMode()) videoControlsHider.removeCallbacks(hideVideoControls)
+ if (!requireActivity().isInPictureInPictureMode) videoControlsHider.removeCallbacks(hideVideoControls)
// Controller released; we will not receive buffering updates
binding.progressBar.visibility = View.GONE
}
@@ -731,8 +730,7 @@ class VideoplayerActivity : CastEnabledActivity() {
invalidateOptionsMenu(activity)
}
}
- if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData,
- "text/html", "utf-8", "about:blank")
+ if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank")
itemsLoaded = true
}
} catch (e: Throwable) { Log.e(TAG, Log.getStackTraceString(e))
@@ -746,7 +744,6 @@ class VideoplayerActivity : CastEnabledActivity() {
}
}
-
private fun setupView() {
showTimeLeft = shouldShowRemainingTime()
Logd(TAG, "setupView showTimeLeft: $showTimeLeft")
@@ -802,8 +799,7 @@ class VideoplayerActivity : CastEnabledActivity() {
(curMedia as? EpisodeMedia)?.forceVideo = false
(activity as? VideoplayerActivity)?.finish()
}
- if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData,
- "text/html", "utf-8", "about:blank")
+ if (!itemsLoaded) webvDescription?.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank")
}
fun toggleVideoControlsVisibility() {
@@ -840,19 +836,16 @@ class VideoplayerActivity : CastEnabledActivity() {
})
}
-
fun onRewind() {
playbackService?.mPlayer?.seekDelta(-rewindSecs * 1000)
setupVideoControlsToggler()
}
-
fun onPlayPause() {
playPause()
setupVideoControlsToggler()
}
-
fun onFastForward() {
playbackService?.mPlayer?.seekDelta(fastForwardSecs * 1000)
setupVideoControlsToggler()
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt
index c15d8889..82d1b222 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Composables.kt
@@ -254,18 +254,3 @@ fun AutoCompleteTextField(suggestions: List) {
}
}
}
-
-@Composable
-fun InputChipExample(text: String, onDismiss: () -> Unit) {
- var enabled by remember { mutableStateOf(true) }
- if (!enabled) return
-
- InputChip(onClick = {
- onDismiss()
- enabled = !enabled
- }, label = { Text(text) }, selected = enabled,
- trailingIcon = {
- Icon(Icons.Default.Delete, contentDescription = "Localized description", Modifier.size(InputChipDefaults.AvatarSize))
- },
- )
-}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt
index 806eb9e2..afa55315 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt
@@ -518,7 +518,7 @@ class AudioPlayerFragment : Fragment() {
}
}
}, update = { webView ->
- Logd(TAG, "AndroidView update: $cleanedNotes")
+// Logd(TAG, "AndroidView update: $cleanedNotes")
webView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank")
})
if (displayedChapterIndex >= 0) {
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt
index cde2a22c..0ca32c07 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt
@@ -625,9 +625,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
episode = realm.query(Episode::class).query("id == ${item_.id}").first().find()
}
- /**
- * Displays information about an Episode (FeedItem) and actions.
- */
class EpisodeHomeFragment : Fragment() {
private var _binding: EpisodeHomeFragmentBinding? = null
private val binding get() = _binding!!
@@ -647,7 +644,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var tts: TextToSpeech? = null
private var ttsReady = false
-
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
_binding = EpisodeHomeFragmentBinding.inflate(inflater, container, false)
@@ -689,7 +685,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val htmlSource = fetchHtmlSource(url)
val article = Readability4JExtended(episode?.link!!, htmlSource).parse()
readerText = article.textContent
-// Log.d(TAG, "readability4J: ${article.textContent}")
readerhtml = article.contentWithDocumentsCharsetOrUtf8
} else {
readerhtml = episode!!.transcript
@@ -698,26 +693,18 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (!readerhtml.isNullOrEmpty()) {
val shownotesCleaner = ShownotesCleaner(requireContext())
cleanedNotes = shownotesCleaner.processShownotes(readerhtml!!, 0)
- episode = upsertBlk(episode!!) {
- it.setTranscriptIfLonger(readerhtml)
- }
-// persistEpisode(episode)
+ episode = upsertBlk(episode!!) { it.setTranscriptIfLonger(readerhtml) }
}
}
}
if (!cleanedNotes.isNullOrEmpty()) {
if (!ttsReady) initializeTTS(requireContext())
withContext(Dispatchers.Main) {
- binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes",
- "text/html", "UTF-8", null)
+ binding.readerView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes ?: "No notes", "text/html", "UTF-8", null)
binding.readerView.visibility = View.VISIBLE
binding.webView.visibility = View.GONE
}
- } else {
- withContext(Dispatchers.Main) {
- Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show()
- }
- }
+ } else withContext(Dispatchers.Main) { Toast.makeText(context, R.string.web_content_not_available, Toast.LENGTH_LONG).show() }
}
}
@@ -767,9 +754,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "onPrepareMenu called")
val textSpeech = menu.findItem(R.id.text_speech)
textSpeech?.isVisible = readMode && tts != null
- if (textSpeech?.isVisible == true) {
- if (ttsPlaying) textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp)
- }
+ if (textSpeech?.isVisible == true && ttsPlaying)
+ textSpeech.setIcon(R.drawable.ic_pause) else textSpeech.setIcon(R.drawable.ic_play_24dp)
+
menu.findItem(R.id.share_notes)?.isVisible = readMode
menu.findItem(R.id.switchJS)?.isVisible = !readMode
val btn = menu.findItem(R.id.switch_home)
@@ -812,7 +799,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
} else ttsPlaying = false
updateAppearance()
} else Toast.makeText(context, R.string.tts_not_available, Toast.LENGTH_LONG).show()
-
return true
}
R.id.share_notes -> {
@@ -829,14 +815,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
return true
}
- else -> {
- return episode != null
- }
+ else -> return episode != null
}
}
}
-
override fun onResume() {
super.onResume()
updateAppearance()
@@ -850,7 +833,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
webview.destroy()
}
- override fun onDestroyView() {
+ override fun onDestroyView() {
Logd(TAG, "onDestroyView")
cleatWebview(binding.webView)
cleatWebview(binding.readerView)
@@ -861,15 +844,12 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
super.onDestroyView()
}
-
private fun updateAppearance() {
if (episode == null) {
Logd(TAG, "updateAppearance currentItem is null")
return
}
-// onPrepareOptionsMenu(toolbar.menu)
toolbar.invalidateMenu()
-// menuProvider.onPrepareMenu(toolbar.menu)
}
companion object {
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt
index ce5d4a41..92ecbae4 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt
@@ -196,7 +196,11 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener {
@VisibleForTesting
const val PREF_NAME: String = "NavDrawerPrefs"
private var prefs: SharedPreferences? = null
- var feedCount: Int = 0
+ var feedCount: Int = -1
+ get() {
+ if (field < 0) field = getFeedCount()
+ return field
+ }
fun getSharedPrefs(context: Context) {
if (prefs == null) prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt
index b1b61adb..aab9b6e8 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineSearchFragment.kt
@@ -1,48 +1,101 @@
package ac.mdiq.podcini.ui.fragment
+import ac.mdiq.podcini.BuildConfig
import ac.mdiq.podcini.R
-import ac.mdiq.podcini.databinding.AddfeedBinding
+import ac.mdiq.podcini.databinding.ComposeFragmentBinding
import ac.mdiq.podcini.databinding.EditTextDialogBinding
+import ac.mdiq.podcini.databinding.SelectCountryDialogBinding
+import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
import ac.mdiq.podcini.net.feed.FeedUpdateManager
import ac.mdiq.podcini.net.feed.searcher.*
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.isOPMLRestared
import ac.mdiq.podcini.preferences.OpmlBackupAgent.Companion.performRestore
+import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.EpisodeSortOrder
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.activity.OpmlImportActivity
+import ac.mdiq.podcini.ui.compose.CustomTheme
+import ac.mdiq.podcini.ui.compose.NonlazyGrid
+import ac.mdiq.podcini.ui.compose.OnlineFeedItem
import ac.mdiq.podcini.ui.fragment.NavDrawerFragment.Companion.feedCount
+import ac.mdiq.podcini.util.EventFlow
+import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.content.*
import android.net.Uri
import android.os.Bundle
+import android.util.DisplayMetrics
import android.util.Log
-import android.view.KeyEvent
import android.view.LayoutInflater
+import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import android.view.inputmethod.InputMethodManager
-import android.widget.TextView
+import android.widget.ArrayAdapter
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.widget.Toolbar
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.constraintlayout.compose.ConstraintLayout
import androidx.documentfile.provider.DocumentFile
import androidx.fragment.app.Fragment
-
+import androidx.lifecycle.lifecycleScope
+import coil.compose.AsyncImage
+import coil.request.CachePolicy
+import coil.request.ImageRequest
+import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.collectLatest
+import okhttp3.CacheControl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.util.*
+import java.util.concurrent.TimeUnit
+import kotlin.Throws
class OnlineSearchFragment : Fragment() {
- private var _binding: AddfeedBinding? = null
+ val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) }
+
+ private var _binding: ComposeFragmentBinding? = null
private val binding get() = _binding!!
private var mainAct: MainActivity? = null
private var displayUpArrow = false
+ private var showError by mutableStateOf(false)
+ private var errorText by mutableStateOf("")
+ private var showPowerBy by mutableStateOf(false)
+ private var showRetry by mutableStateOf(false)
+ private var retryTextRes by mutableIntStateOf(0)
+ private var showGrid by mutableStateOf(false)
+
+ private var numColumns by mutableIntStateOf(4)
+ private val searchResult = mutableStateListOf()
+
private val chooseOpmlImportPathLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
this.chooseOpmlImportPathResult(uri) }
@@ -50,38 +103,23 @@ class OnlineSearchFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
super.onCreateView(inflater, container, savedInstanceState)
- _binding = AddfeedBinding.inflate(inflater)
+ _binding = ComposeFragmentBinding.inflate(inflater)
mainAct = activity as? MainActivity
Logd(TAG, "fragment onCreateView")
displayUpArrow = parentFragmentManager.backStackEntryCount != 0
if (savedInstanceState != null) displayUpArrow = savedInstanceState.getBoolean(KEY_UP_ARROW)
mainAct?.setupToolbarToggle(binding.toolbar, displayUpArrow)
- binding.searchButton.setOnClickListener { performSearch() }
- binding.searchVistaGuideButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) }
- binding.searchItunesButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) }
- binding.searchFyydButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) }
- binding.searchGPodderButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) }
- binding.searchPodcastIndexButton.setOnClickListener { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) }
- binding.combinedFeedSearchEditText.setOnEditorActionListener { _: TextView?, _: Int, _: KeyEvent? ->
- performSearch()
- true
- }
- binding.addViaUrlButton.setOnClickListener { showAddViaUrlDialog() }
- binding.opmlImportButton.setOnClickListener {
- try { chooseOpmlImportPathLauncher.launch("*/*")
- } catch (e: ActivityNotFoundException) {
- e.printStackTrace()
- mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
- }
- }
- binding.addLocalFolderButton.setOnClickListener {
- try { addLocalFolderLauncher.launch(null)
- } catch (e: ActivityNotFoundException) {
- e.printStackTrace()
- mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
- }
- }
+ val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics
+ val screenWidthDp: Float = displayMetrics.widthPixels / displayMetrics.density
+ if (screenWidthDp > 600) numColumns = 6
+
+ // Fill with dummy elements to have a fixed height and
+ // prevent the UI elements below from jumping on slow connections
+ for (i in 0 until NUM_SUGGESTIONS) searchResult.add(PodcastSearchResult.dummy())
+ binding.mainView.setContent { CustomTheme(requireContext()) { MainView() } }
+
+ loadToplist()
if (isOPMLRestared && feedCount == 0) {
AlertDialog.Builder(requireContext())
@@ -98,6 +136,161 @@ class OnlineSearchFragment : Fragment() {
return binding.root
}
+ @Composable
+ fun MainView() {
+ val textColor = MaterialTheme.colorScheme.onSurface
+ Column(Modifier.padding(horizontal = 10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ var queryText by remember { mutableStateOf("") }
+ fun performSearch() {
+ if (queryText.matches("http[s]?://.*".toRegex())) addUrl(queryText)
+ else mainAct?.loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, queryText))
+ }
+ TextField(value = queryText, onValueChange = { queryText = it }, label = { Text(stringResource(R.string.search_podcast_hint)) },
+ keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { performSearch() }), modifier = Modifier.weight(1f))
+ Icon(imageVector = ImageVector.vectorResource(R.drawable.ic_search), tint = textColor, contentDescription = "right_action_icon",
+ modifier = Modifier.width(40.dp).height(40.dp).padding(start = 5.dp).clickable(onClick = { performSearch() }))
+ }
+ QuickDiscoveryView()
+ Text(stringResource(R.string.advanced), color = textColor, fontWeight = FontWeight.Bold)
+ Text(stringResource(R.string.add_podcast_by_url), color = textColor, modifier = Modifier.clickable(onClick = { showAddViaUrlDialog() }))
+ Text(stringResource(R.string.add_local_folder), color = textColor, modifier = Modifier.clickable(onClick = {
+ try { addLocalFolderLauncher.launch(null)
+ } catch (e: ActivityNotFoundException) {
+ e.printStackTrace()
+ mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
+ }
+ }))
+ Text(stringResource(R.string.search_vistaguide_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(VistaGuidePodcastSearcher::class.java)) }))
+ Text(stringResource(R.string.search_itunes_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(ItunesPodcastSearcher::class.java)) }))
+ Text(stringResource(R.string.search_fyyd_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(FyydPodcastSearcher::class.java)) }))
+ Text(stringResource(R.string.gpodnet_search_hint), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(GpodnetPodcastSearcher::class.java)) }))
+ Text(stringResource(R.string.search_podcastindex_label), color = textColor, modifier = Modifier.clickable(onClick = { mainAct?.loadChildFragment(SearchResultsFragment.newInstance(PodcastIndexPodcastSearcher::class.java)) }))
+ Text(stringResource(R.string.opml_add_podcast_label), color = textColor, modifier = Modifier.clickable(onClick = {
+ try { chooseOpmlImportPathLauncher.launch("*/*")
+ } catch (e: ActivityNotFoundException) {
+ e.printStackTrace()
+ mainAct?.showSnackbarAbovePlayer(R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG)
+ }
+ }))
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ procFlowEvents()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ cancelFlowEvents()
+ }
+
+ @Composable
+ fun QuickDiscoveryView() {
+ val textColor = MaterialTheme.colorScheme.onSurface
+ val context = LocalContext.current
+ Column(modifier = Modifier.padding(vertical = 5.dp)) {
+ Row {
+ Text(stringResource(R.string.discover), color = textColor, fontWeight = FontWeight.Bold)
+ Spacer(Modifier.weight(1f))
+ Text(stringResource(R.string.discover_more), color = textColor, modifier = Modifier.clickable(onClick = {(activity as MainActivity).loadChildFragment(DiscoveryFragment())}))
+ }
+ ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
+ val (grid, error) = createRefs()
+ if (showGrid) NonlazyGrid(columns = numColumns, itemCount = searchResult.size, modifier = Modifier.fillMaxWidth().constrainAs(grid) { centerTo(parent) }) { index ->
+ AsyncImage(model = ImageRequest.Builder(context).data(searchResult[index].imageUrl)
+ .memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(),
+ contentDescription = "imgvCover", modifier = Modifier.padding(top = 8.dp)
+ .clickable(onClick = {
+ Logd(TAG, "icon clicked!")
+ val podcast: PodcastSearchResult? = searchResult[index]
+ if (!podcast?.feedUrl.isNullOrEmpty()) {
+ val fragment: Fragment = OnlineFeedFragment.newInstance(podcast.feedUrl)
+ (activity as MainActivity).loadChildFragment(fragment)
+ }
+ }))
+ }
+ if (showError) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().constrainAs(error) { centerTo(parent) }) {
+ Text(errorText, color = textColor)
+ if (showRetry) Button(onClick = {
+ prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
+ loadToplist()
+ }) { Text(stringResource(retryTextRes)) }
+ }
+ }
+ Text(stringResource(R.string.discover_powered_by_itunes), color = textColor, modifier = Modifier.align(Alignment.End))
+ }
+ }
+
+ private var eventSink: Job? = null
+ private fun cancelFlowEvents() {
+ eventSink?.cancel()
+ eventSink = null
+ }
+ private fun procFlowEvents() {
+ if (eventSink != null) return
+ eventSink = lifecycleScope.launch {
+ EventFlow.events.collectLatest { event ->
+ Logd(TAG, "Received event: ${event.TAG}")
+ when (event) {
+ is FlowEvent.DiscoveryDefaultUpdateEvent -> loadToplist()
+ else -> {}
+ }
+ }
+ }
+ }
+
+ private fun loadToplist() {
+ showError = false
+ showPowerBy = true
+ showRetry = false
+ retryTextRes = R.string.retry_label
+ val loader = ItunesTopListLoader(requireContext())
+ val countryCode: String = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)!!
+ if (prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)) {
+ showError = true
+ errorText = requireContext().getString(R.string.discover_is_hidden)
+ showPowerBy = false
+ showRetry = false
+ return
+ }
+ if (BuildConfig.FLAVOR == "free" && prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) == true) {
+ showError = true
+ errorText = ""
+ showGrid = true
+ showRetry = true
+ retryTextRes = R.string.discover_confirm
+ showPowerBy = true
+ return
+ }
+
+ lifecycleScope.launch {
+ try {
+ val searchResults_ = withContext(Dispatchers.IO) { loader.loadToplist(countryCode, NUM_SUGGESTIONS, getFeedList()) }
+ withContext(Dispatchers.Main) {
+ showError = false
+ if (searchResults_.isEmpty()) {
+ errorText = requireContext().getString(R.string.search_status_no_results)
+ showError = true
+ showGrid = false
+ } else {
+ showGrid = true
+ searchResult.clear()
+ searchResult.addAll(searchResults_)
+ }
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ showError = true
+ showGrid = false
+ showRetry = true
+ errorText = e.localizedMessage ?: ""
+ }
+ }
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(KEY_UP_ARROW, displayUpArrow)
super.onSaveInstanceState(outState)
@@ -126,19 +319,6 @@ class OnlineSearchFragment : Fragment() {
mainAct?.loadChildFragment(fragment)
}
- private fun performSearch() {
- binding.combinedFeedSearchEditText.clearFocus()
- val inVal = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- inVal.hideSoftInputFromWindow(binding.combinedFeedSearchEditText.windowToken, 0)
- val query = binding.combinedFeedSearchEditText.text.toString()
- if (query.matches("http[s]?://.*".toRegex())) {
- addUrl(query)
- return
- }
- mainAct?.loadChildFragment(SearchResultsFragment.newInstance(CombinedSearcher::class.java, query))
- binding.combinedFeedSearchEditText.post { binding.combinedFeedSearchEditText.setText("") }
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
@@ -198,8 +378,301 @@ class OnlineSearchFragment : Fragment() {
}
}
+ class ItunesTopListLoader(private val context: Context) {
+ @Throws(JSONException::class, IOException::class)
+ fun loadToplist(country: String, limit: Int, subscribed: List): List {
+ val client = getHttpClient()
+ val feedString: String
+ var loadCountry = country
+ if (COUNTRY_CODE_UNSET == country) loadCountry = Locale.getDefault().country
+ feedString = try { getTopListFeed(client, loadCountry)
+ } catch (e: IOException) { if (COUNTRY_CODE_UNSET == country) getTopListFeed(client, "US") else throw e }
+ return removeSubscribed(parseFeed(feedString), subscribed, limit)
+ }
+ @Throws(IOException::class)
+ private fun getTopListFeed(client: OkHttpClient?, country: String): String {
+ val url = "https://itunes.apple.com/%s/rss/toppodcasts/limit=$NUM_LOADED/explicit=true/json"
+ Logd(TAG, "Feed URL " + String.format(url, country))
+ val httpReq: Request.Builder = Request.Builder()
+ .cacheControl(CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build())
+ .url(String.format(url, country))
+ client!!.newCall(httpReq.build()).execute().use { response ->
+ if (response.isSuccessful) return response.body!!.string()
+ if (response.code == 400) throw IOException("iTunes does not have data for the selected country.")
+ val prefix = context.getString(R.string.error_msg_prefix)
+ throw IOException(prefix + response)
+ }
+ }
+ @Throws(JSONException::class)
+ private fun parseFeed(jsonString: String): List {
+ val result = JSONObject(jsonString)
+ val feed: JSONObject
+ val entries: JSONArray
+ try {
+ feed = result.getJSONObject("feed")
+ entries = feed.getJSONArray("entry")
+ } catch (_: JSONException) { return ArrayList() }
+ val results: MutableList = ArrayList()
+ for (i in 0 until entries.length()) {
+ val json = entries.getJSONObject(i)
+ results.add(PodcastSearchResult.fromItunesToplist(json))
+ }
+ return results
+ }
+
+ companion object {
+ private val TAG: String = ItunesTopListLoader::class.simpleName ?: "Anonymous"
+ const val PREF_KEY_COUNTRY_CODE: String = "country_code"
+ const val PREF_KEY_HIDDEN_DISCOVERY_COUNTRY: String = "hidden_discovery_country"
+ const val PREF_KEY_NEEDS_CONFIRM: String = "needs_confirm"
+ const val PREFS: String = "CountryRegionPrefs"
+ const val COUNTRY_CODE_UNSET: String = "99"
+ private const val NUM_LOADED = 25
+
+ private fun removeSubscribed(suggestedPodcasts: List, subscribedFeeds: List, limit: Int): List {
+ val subscribedPodcastsSet: MutableSet = HashSet()
+ for (subscribedFeed in subscribedFeeds) {
+ if (subscribedFeed.title != null && subscribedFeed.author != null)
+ subscribedPodcastsSet.add(subscribedFeed.title!!.trim { it <= ' ' } + " - " + subscribedFeed.author!!.trim { it <= ' ' })
+ }
+ val suggestedNotSubscribed: MutableList = ArrayList()
+ for (suggested in suggestedPodcasts) {
+ if (!subscribedPodcastsSet.contains(suggested.title.trim { it <= ' ' })) suggestedNotSubscribed.add(suggested)
+ if (suggestedNotSubscribed.size == limit) return suggestedNotSubscribed
+ }
+ return suggestedNotSubscribed
+ }
+ }
+ }
+
+ class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
+ val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) }
+
+ private var _binding: ComposeFragmentBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var toolbar: MaterialToolbar
+
+ private var topList: List? = listOf()
+
+ private var countryCode: String? = "US"
+ private var hidden = false
+ private var needsConfirm = false
+
+ private var searchResults = mutableStateListOf()
+ private var errorText by mutableStateOf("")
+ private var retryQerry by mutableStateOf("")
+ private var showProgress by mutableStateOf(true)
+ private var noResultText by mutableStateOf("")
+
+ /**
+ * Replace adapter data with provided search results from SearchTask.
+ * @param result List of Podcast objects containing search results
+ */
+ private fun updateData(result: List) {
+ searchResults.clear()
+ if (result.isNotEmpty()) {
+ searchResults.addAll(result)
+ noResultText = ""
+ } else noResultText = getString(R.string.no_results_for_query)
+ showProgress = false
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ countryCode = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)
+ hidden = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)
+ needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true)
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ // Inflate the layout for this fragment
+ _binding = ComposeFragmentBinding.inflate(inflater)
+ Logd(TAG, "fragment onCreateView")
+ binding.mainView.setContent {
+ CustomTheme(requireContext()) {
+ MainView()
+ }
+ }
+
+ toolbar = binding.toolbar
+ toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
+ toolbar.inflateMenu(R.menu.countries_menu)
+ val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
+ discoverHideItem.isChecked = hidden
+ toolbar.setOnMenuItemClickListener(this)
+
+ loadToplist(countryCode)
+ return binding.root
+ }
+
+ @Composable
+ fun MainView() {
+ val textColor = MaterialTheme.colorScheme.onSurface
+ ConstraintLayout(modifier = Modifier.fillMaxSize()) {
+ val (gridView, progressBar, empty, txtvError, butRetry, powered) = createRefs()
+ if (showProgress) CircularProgressIndicator(progress = {0.6f}, strokeWidth = 10.dp, modifier = Modifier.size(50.dp).constrainAs(progressBar) { centerTo(parent) })
+ val lazyListState = rememberLazyListState()
+ if (searchResults.isNotEmpty()) LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
+ .constrainAs(gridView) {
+ top.linkTo(parent.top)
+ bottom.linkTo(parent.bottom)
+ start.linkTo(parent.start)
+ },
+ verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(searchResults.size) { index ->
+ OnlineFeedItem(activity = activity as MainActivity, searchResults[index])
+ }
+ }
+ if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) })
+ if (errorText.isNotEmpty()) Text(errorText, color = textColor, modifier = Modifier.constrainAs(txtvError) { centerTo(parent) })
+ if (retryQerry.isNotEmpty()) Button(modifier = Modifier.padding(16.dp).constrainAs(butRetry) { top.linkTo(txtvError.bottom)},
+ onClick = {
+ if (needsConfirm) {
+ prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
+ needsConfirm = false
+ }
+ loadToplist(countryCode)
+ }, ) {
+ Text(stringResource(id = R.string.retry_label))
+ }
+// Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background(
+// Color.LightGray)
+// .constrainAs(powered) {
+// bottom.linkTo(parent.bottom)
+// end.linkTo(parent.end)
+// })
+ }
+ }
+
+ override fun onDestroy() {
+ _binding = null
+ searchResults.clear()
+ topList = null
+ super.onDestroy()
+ }
+
+ private fun loadToplist(country: String?) {
+ searchResults.clear()
+ errorText = ""
+ retryQerry = ""
+ noResultText = ""
+ showProgress = true
+ if (hidden) {
+ errorText = resources.getString(R.string.discover_is_hidden)
+ showProgress = false
+ return
+ }
+ if (BuildConfig.FLAVOR == "free" && needsConfirm) {
+ errorText = ""
+ retryQerry = resources.getString(R.string.discover_confirm)
+ noResultText = ""
+ showProgress = false
+ return
+ }
+
+ val loader = ItunesTopListLoader(requireContext())
+ lifecycleScope.launch {
+ try {
+ val podcasts = withContext(Dispatchers.IO) { loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList()) }
+ withContext(Dispatchers.Main) {
+ showProgress = false
+ topList = podcasts
+ updateData(topList!!)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, Log.getStackTraceString(e))
+ searchResults.clear()
+ errorText = e.message ?: "no error message"
+ retryQerry = " retry"
+ }
+ }
+ }
+
+ override fun onMenuItemClick(item: MenuItem): Boolean {
+ if (super.onOptionsItemSelected(item)) return true
+
+ val itemId = item.itemId
+ when (itemId) {
+ R.id.discover_hide_item -> {
+ item.isChecked = !item.isChecked
+ hidden = item.isChecked
+ prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply()
+
+ EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent())
+ loadToplist(countryCode)
+ return true
+ }
+ R.id.discover_countries_item -> {
+ val inflater = layoutInflater
+ val selectCountryDialogView = inflater.inflate(R.layout.select_country_dialog, null)
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ builder.setView(selectCountryDialogView)
+
+ val countryCodeArray: List = listOf(*Locale.getISOCountries())
+ val countryCodeNames: MutableMap = HashMap()
+ val countryNameCodes: MutableMap = HashMap()
+ for (code in countryCodeArray) {
+ val locale = Locale("", code)
+ val countryName = locale.displayCountry
+ countryCodeNames[code] = countryName
+ countryNameCodes[countryName] = code
+ }
+
+ val countryNamesSort: MutableList = ArrayList(countryCodeNames.values)
+ countryNamesSort.sort()
+
+ val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort)
+ val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView)
+ val textInput = scBinding.countryTextInput
+ val editText = textInput.editText as? MaterialAutoCompleteTextView
+ editText!!.setAdapter(dataAdapter)
+ editText.setText(countryCodeNames[countryCode])
+ editText.setOnClickListener {
+ if (editText.text.isNotEmpty()) {
+ editText.setText("")
+ editText.postDelayed({ editText.showDropDown() }, 100)
+ }
+ }
+ editText.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
+ if (hasFocus) {
+ editText.setText("")
+ editText.postDelayed({ editText.showDropDown() }, 100)
+ }
+ }
+
+ builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
+ val countryName = editText.text.toString()
+ if (countryNameCodes.containsKey(countryName)) {
+ countryCode = countryNameCodes[countryName]
+ val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
+ discoverHideItem.isChecked = false
+ hidden = false
+ }
+
+ prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply()
+ prefs.edit().putString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, countryCode).apply()
+
+ EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent())
+ loadToplist(countryCode)
+ }
+ builder.setNegativeButton(R.string.cancel_label, null)
+ builder.show()
+ return true
+ }
+ else -> return false
+ }
+ }
+
+ companion object {
+ private const val NUM_OF_TOP_PODCASTS = 25
+ }
+ }
+
companion object {
val TAG = OnlineSearchFragment::class.simpleName ?: "Anonymous"
private const val KEY_UP_ARROW = "up_arrow"
+
+ private const val NUM_SUGGESTIONS = 12
}
}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt
deleted file mode 100644
index 0b1ae905..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/QuickDiscoveryFragment.kt
+++ /dev/null
@@ -1,521 +0,0 @@
-package ac.mdiq.podcini.ui.fragment
-
-import ac.mdiq.podcini.BuildConfig
-import ac.mdiq.podcini.R
-import ac.mdiq.podcini.databinding.ComposeFragmentBinding
-import ac.mdiq.podcini.databinding.SelectCountryDialogBinding
-import ac.mdiq.podcini.net.download.service.PodciniHttpClient.getHttpClient
-import ac.mdiq.podcini.net.feed.searcher.PodcastSearchResult
-import ac.mdiq.podcini.storage.database.Feeds.getFeedList
-import ac.mdiq.podcini.storage.model.Feed
-import ac.mdiq.podcini.ui.activity.MainActivity
-import ac.mdiq.podcini.ui.compose.CustomTheme
-import ac.mdiq.podcini.ui.compose.NonlazyGrid
-import ac.mdiq.podcini.ui.compose.OnlineFeedItem
-import ac.mdiq.podcini.util.EventFlow
-import ac.mdiq.podcini.util.FlowEvent
-import ac.mdiq.podcini.util.Logd
-import android.content.Context
-import android.content.DialogInterface
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.util.DisplayMetrics
-import android.util.Log
-import android.view.LayoutInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ArrayAdapter
-import androidx.appcompat.widget.Toolbar
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.constraintlayout.compose.ConstraintLayout
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import coil.compose.AsyncImage
-import coil.request.CachePolicy
-import coil.request.ImageRequest
-import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.MaterialAutoCompleteTextView
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import okhttp3.CacheControl
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import org.json.JSONArray
-import org.json.JSONException
-import org.json.JSONObject
-import java.io.IOException
-import java.util.*
-import java.util.concurrent.TimeUnit
-
-class QuickDiscoveryFragment : Fragment() {
- val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) }
-
- private var showError by mutableStateOf(false)
- private var errorText by mutableStateOf("")
- private var showPowerBy by mutableStateOf(false)
- private var showRetry by mutableStateOf(false)
- private var retryTextRes by mutableIntStateOf(0)
- private var showGrid by mutableStateOf(false)
-
- private var numColumns by mutableIntStateOf(4)
- private val searchResult = mutableStateListOf()
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- super.onCreateView(inflater, container, savedInstanceState)
- Logd(TAG, "fragment onCreateView")
- val composeView = ComposeView(requireContext()).apply {
- setContent {
- CustomTheme(requireContext()) {
- MainView()
- }
- }
- }
-
- val displayMetrics: DisplayMetrics = requireContext().resources.displayMetrics
- val screenWidthDp: Float = displayMetrics.widthPixels / displayMetrics.density
- if (screenWidthDp > 600) numColumns = 6
-
- // Fill with dummy elements to have a fixed height and
- // prevent the UI elements below from jumping on slow connections
- val dummies: MutableList = ArrayList()
- for (i in 0 until NUM_SUGGESTIONS) {
- dummies.add(PodcastSearchResult.dummy())
- searchResult.add(PodcastSearchResult.dummy())
- }
- loadToplist()
- return composeView
- }
-
- override fun onStart() {
- super.onStart()
- procFlowEvents()
- }
-
- override fun onStop() {
- super.onStop()
- cancelFlowEvents()
- }
-
- @Composable
- fun MainView() {
- val textColor = MaterialTheme.colorScheme.onSurface
- val context = LocalContext.current
- Column {
- Row {
- Text(stringResource(R.string.discover), color = textColor)
- Spacer(Modifier.weight(1f))
- Text(stringResource(R.string.discover_more), color = textColor, modifier = Modifier.clickable(onClick = {(activity as MainActivity).loadChildFragment(DiscoveryFragment())}))
- }
- ConstraintLayout(modifier = Modifier.fillMaxWidth()) {
- val (grid, error) = createRefs()
- if (showGrid) NonlazyGrid(columns = numColumns, itemCount = searchResult.size, modifier = Modifier.fillMaxWidth().constrainAs(grid) { centerTo(parent) }) { index ->
- AsyncImage(model = ImageRequest.Builder(context).data(searchResult[index].imageUrl)
- .memoryCachePolicy(CachePolicy.ENABLED).placeholder(R.mipmap.ic_launcher).error(R.mipmap.ic_launcher).build(),
- contentDescription = "imgvCover", modifier = Modifier.padding(top = 8.dp)
- .clickable(onClick = {
- Logd(TAG, "icon clicked!")
- val podcast: PodcastSearchResult? = searchResult[index]
- if (!podcast?.feedUrl.isNullOrEmpty()) {
- val fragment: Fragment = OnlineFeedFragment.newInstance(podcast.feedUrl)
- (activity as MainActivity).loadChildFragment(fragment)
- }
- }))
- }
- if (showError) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth().constrainAs(error) { centerTo(parent) }) {
- Text(errorText, color = textColor)
- if (showRetry) Button(onClick = {
- prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
- loadToplist()
- }) { Text(stringResource(retryTextRes)) }
- }
- }
- Text(stringResource(R.string.discover_powered_by_itunes), color = textColor, modifier = Modifier.align(Alignment.End))
- }
- }
-
- private var eventSink: Job? = null
- private fun cancelFlowEvents() {
- eventSink?.cancel()
- eventSink = null
- }
- private fun procFlowEvents() {
- if (eventSink != null) return
- eventSink = lifecycleScope.launch {
- EventFlow.events.collectLatest { event ->
- Logd(TAG, "Received event: ${event.TAG}")
- when (event) {
- is FlowEvent.DiscoveryDefaultUpdateEvent -> loadToplist()
- else -> {}
- }
- }
- }
- }
-
- private fun loadToplist() {
- showError = false
- showPowerBy = true
- showRetry = false
- retryTextRes = R.string.retry_label
- val loader = ItunesTopListLoader(requireContext())
- val countryCode: String = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)!!
- if (prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)) {
- showError = true
- errorText = requireContext().getString(R.string.discover_is_hidden)
- showPowerBy = false
- showRetry = false
- return
- }
- if (BuildConfig.FLAVOR == "free" && prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true) == true) {
- showError = true
- errorText = ""
- showGrid = true
- showRetry = true
- retryTextRes = R.string.discover_confirm
- showPowerBy = true
- return
- }
-
- lifecycleScope.launch {
- try {
- val searchResults_ = withContext(Dispatchers.IO) { loader.loadToplist(countryCode, NUM_SUGGESTIONS, getFeedList()) }
- withContext(Dispatchers.Main) {
- showError = false
- if (searchResults_.isEmpty()) {
- errorText = requireContext().getString(R.string.search_status_no_results)
- showError = true
- showGrid = false
- } else {
- showGrid = true
- searchResult.clear()
- searchResult.addAll(searchResults_)
- }
- }
- } catch (e: Throwable) {
- Log.e(TAG, Log.getStackTraceString(e))
- showError = true
- showGrid = false
- showRetry = true
- errorText = e.localizedMessage ?: ""
- }
- }
- }
-
- class ItunesTopListLoader(private val context: Context) {
- @Throws(JSONException::class, IOException::class)
- fun loadToplist(country: String, limit: Int, subscribed: List): List {
- val client = getHttpClient()
- val feedString: String
- var loadCountry = country
- if (COUNTRY_CODE_UNSET == country) loadCountry = Locale.getDefault().country
- feedString = try { getTopListFeed(client, loadCountry)
- } catch (e: IOException) {
- if (COUNTRY_CODE_UNSET == country) getTopListFeed(client, "US")
- else throw e
- }
- return removeSubscribed(parseFeed(feedString), subscribed, limit)
- }
- @Throws(IOException::class)
- private fun getTopListFeed(client: OkHttpClient?, country: String): String {
- val url = "https://itunes.apple.com/%s/rss/toppodcasts/limit=$NUM_LOADED/explicit=true/json"
- Logd(TAG, "Feed URL " + String.format(url, country))
- val httpReq: Request.Builder = Request.Builder()
- .cacheControl(CacheControl.Builder().maxStale(1, TimeUnit.DAYS).build())
- .url(String.format(url, country))
- client!!.newCall(httpReq.build()).execute().use { response ->
- if (response.isSuccessful) return response.body!!.string()
- if (response.code == 400) throw IOException("iTunes does not have data for the selected country.")
-
- val prefix = context.getString(R.string.error_msg_prefix)
- throw IOException(prefix + response)
- }
- }
- @Throws(JSONException::class)
- private fun parseFeed(jsonString: String): List {
- val result = JSONObject(jsonString)
- val feed: JSONObject
- val entries: JSONArray
- try {
- feed = result.getJSONObject("feed")
- entries = feed.getJSONArray("entry")
- } catch (_: JSONException) { return ArrayList() }
- val results: MutableList = ArrayList()
- for (i in 0 until entries.length()) {
- val json = entries.getJSONObject(i)
- results.add(PodcastSearchResult.fromItunesToplist(json))
- }
- return results
- }
-
- companion object {
- private val TAG: String = ItunesTopListLoader::class.simpleName ?: "Anonymous"
- const val PREF_KEY_COUNTRY_CODE: String = "country_code"
- const val PREF_KEY_HIDDEN_DISCOVERY_COUNTRY: String = "hidden_discovery_country"
- const val PREF_KEY_NEEDS_CONFIRM: String = "needs_confirm"
- const val PREFS: String = "CountryRegionPrefs"
- const val COUNTRY_CODE_UNSET: String = "99"
- private const val NUM_LOADED = 25
-
- private fun removeSubscribed(suggestedPodcasts: List, subscribedFeeds: List, limit: Int): List {
- val subscribedPodcastsSet: MutableSet = HashSet()
- for (subscribedFeed in subscribedFeeds) {
- if (subscribedFeed.title != null && subscribedFeed.author != null)
- subscribedPodcastsSet.add(subscribedFeed.title!!.trim { it <= ' ' } + " - " + subscribedFeed.author!!.trim { it <= ' ' })
- }
- val suggestedNotSubscribed: MutableList = ArrayList()
- for (suggested in suggestedPodcasts) {
- if (!subscribedPodcastsSet.contains(suggested.title.trim { it <= ' ' })) suggestedNotSubscribed.add(suggested)
- if (suggestedNotSubscribed.size == limit) return suggestedNotSubscribed
- }
- return suggestedNotSubscribed
- }
- }
- }
-
- class DiscoveryFragment : Fragment(), Toolbar.OnMenuItemClickListener {
- val prefs: SharedPreferences by lazy { requireActivity().getSharedPreferences(ItunesTopListLoader.PREFS, Context.MODE_PRIVATE) }
-
- private var _binding: ComposeFragmentBinding? = null
- private val binding get() = _binding!!
-
- private lateinit var toolbar: MaterialToolbar
-
- private var topList: List? = listOf()
-
- private var countryCode: String? = "US"
- private var hidden = false
- private var needsConfirm = false
-
- private var searchResults = mutableStateListOf()
- private var errorText by mutableStateOf("")
- private var retryQerry by mutableStateOf("")
- private var showProgress by mutableStateOf(true)
- private var noResultText by mutableStateOf("")
-
- /**
- * Replace adapter data with provided search results from SearchTask.
- * @param result List of Podcast objects containing search results
- */
- private fun updateData(result: List) {
- searchResults.clear()
- if (result.isNotEmpty()) {
- searchResults.addAll(result)
- noResultText = ""
- } else noResultText = getString(R.string.no_results_for_query)
- showProgress = false
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- countryCode = prefs.getString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, Locale.getDefault().country)
- hidden = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, false)
- needsConfirm = prefs.getBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, true)
- }
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- // Inflate the layout for this fragment
- _binding = ComposeFragmentBinding.inflate(inflater)
- Logd(TAG, "fragment onCreateView")
- binding.mainView.setContent {
- CustomTheme(requireContext()) {
- MainView()
- }
- }
-
- toolbar = binding.toolbar
- toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
- toolbar.inflateMenu(R.menu.countries_menu)
- val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
- discoverHideItem.isChecked = hidden
- toolbar.setOnMenuItemClickListener(this)
-
- loadToplist(countryCode)
- return binding.root
- }
-
- @Composable
- fun MainView() {
- val textColor = MaterialTheme.colorScheme.onSurface
- ConstraintLayout(modifier = Modifier.fillMaxSize()) {
- val (gridView, progressBar, empty, txtvError, butRetry, powered) = createRefs()
- if (showProgress) CircularProgressIndicator(progress = {0.6f}, strokeWidth = 10.dp, modifier = Modifier.size(50.dp).constrainAs(progressBar) { centerTo(parent) })
- val lazyListState = rememberLazyListState()
- if (searchResults.isNotEmpty()) LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
- .constrainAs(gridView) {
- top.linkTo(parent.top)
- bottom.linkTo(parent.bottom)
- start.linkTo(parent.start)
- },
- verticalArrangement = Arrangement.spacedBy(8.dp)) {
- items(searchResults.size) { index ->
- OnlineFeedItem(activity = activity as MainActivity, searchResults[index])
- }
- }
- if (searchResults.isEmpty()) Text(noResultText, color = textColor, modifier = Modifier.constrainAs(empty) { centerTo(parent) })
- if (errorText.isNotEmpty()) Text(errorText, color = textColor, modifier = Modifier.constrainAs(txtvError) { centerTo(parent) })
- if (retryQerry.isNotEmpty()) Button(modifier = Modifier.padding(16.dp).constrainAs(butRetry) { top.linkTo(txtvError.bottom)},
- onClick = {
- if (needsConfirm) {
- prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_NEEDS_CONFIRM, false).apply()
- needsConfirm = false
- }
- loadToplist(countryCode)
- }, ) {
- Text(stringResource(id = R.string.retry_label))
- }
-// Text( getString(R.string.search_powered_by, searchProvider!!.name), color = Color.Black, style = MaterialTheme.typography.labelSmall, modifier = Modifier.background(
-// Color.LightGray)
-// .constrainAs(powered) {
-// bottom.linkTo(parent.bottom)
-// end.linkTo(parent.end)
-// })
- }
- }
-
- override fun onDestroy() {
- _binding = null
- searchResults.clear()
- topList = null
- super.onDestroy()
- }
-
- private fun loadToplist(country: String?) {
- searchResults.clear()
- errorText = ""
- retryQerry = ""
- noResultText = ""
- showProgress = true
- if (hidden) {
- errorText = resources.getString(R.string.discover_is_hidden)
- showProgress = false
- return
- }
- if (BuildConfig.FLAVOR == "free" && needsConfirm) {
- errorText = ""
- retryQerry = resources.getString(R.string.discover_confirm)
- noResultText = ""
- showProgress = false
- return
- }
-
- val loader = ItunesTopListLoader(requireContext())
- lifecycleScope.launch {
- try {
- val podcasts = withContext(Dispatchers.IO) { loader.loadToplist(country?:"", NUM_OF_TOP_PODCASTS, getFeedList()) }
- withContext(Dispatchers.Main) {
- showProgress = false
- topList = podcasts
- updateData(topList!!)
- }
- } catch (e: Throwable) {
- Log.e(TAG, Log.getStackTraceString(e))
- searchResults.clear()
- errorText = e.message ?: "no error message"
- retryQerry = " retry"
- }
- }
- }
-
- override fun onMenuItemClick(item: MenuItem): Boolean {
- if (super.onOptionsItemSelected(item)) return true
-
- val itemId = item.itemId
- when (itemId) {
- R.id.discover_hide_item -> {
- item.isChecked = !item.isChecked
- hidden = item.isChecked
- prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply()
-
- EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent())
- loadToplist(countryCode)
- return true
- }
- R.id.discover_countries_item -> {
- val inflater = layoutInflater
- val selectCountryDialogView = inflater.inflate(R.layout.select_country_dialog, null)
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(selectCountryDialogView)
-
- val countryCodeArray: List = listOf(*Locale.getISOCountries())
- val countryCodeNames: MutableMap = HashMap()
- val countryNameCodes: MutableMap = HashMap()
- for (code in countryCodeArray) {
- val locale = Locale("", code)
- val countryName = locale.displayCountry
- countryCodeNames[code] = countryName
- countryNameCodes[countryName] = code
- }
-
- val countryNamesSort: MutableList = ArrayList(countryCodeNames.values)
- countryNamesSort.sort()
-
- val dataAdapter = ArrayAdapter(this.requireContext(), android.R.layout.simple_list_item_1, countryNamesSort)
- val scBinding = SelectCountryDialogBinding.bind(selectCountryDialogView)
- val textInput = scBinding.countryTextInput
- val editText = textInput.editText as? MaterialAutoCompleteTextView
- editText!!.setAdapter(dataAdapter)
- editText.setText(countryCodeNames[countryCode])
- editText.setOnClickListener {
- if (editText.text.isNotEmpty()) {
- editText.setText("")
- editText.postDelayed({ editText.showDropDown() }, 100)
- }
- }
- editText.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
- if (hasFocus) {
- editText.setText("")
- editText.postDelayed({ editText.showDropDown() }, 100)
- }
- }
-
- builder.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
- val countryName = editText.text.toString()
- if (countryNameCodes.containsKey(countryName)) {
- countryCode = countryNameCodes[countryName]
- val discoverHideItem = toolbar.menu.findItem(R.id.discover_hide_item)
- discoverHideItem.isChecked = false
- hidden = false
- }
-
- prefs.edit().putBoolean(ItunesTopListLoader.PREF_KEY_HIDDEN_DISCOVERY_COUNTRY, hidden).apply()
- prefs.edit().putString(ItunesTopListLoader.PREF_KEY_COUNTRY_CODE, countryCode).apply()
-
- EventFlow.postEvent(FlowEvent.DiscoveryDefaultUpdateEvent())
- loadToplist(countryCode)
- }
- builder.setNegativeButton(R.string.cancel_label, null)
- builder.show()
- return true
- }
- else -> return false
- }
- }
-
- companion object {
- private const val NUM_OF_TOP_PODCASTS = 25
- }
- }
-
- companion object {
- private val TAG: String = QuickDiscoveryFragment::class.simpleName ?: "Anonymous"
- private const val NUM_SUGGESTIONS = 12
- }
-}
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt
index 6e837f1d..d2ba8110 100644
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt
+++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchFragment.kt
@@ -1,7 +1,7 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
-import ac.mdiq.podcini.databinding.SearchFragmentBinding
+import ac.mdiq.podcini.databinding.ComposeFragmentBinding
import ac.mdiq.podcini.net.download.DownloadStatus
import ac.mdiq.podcini.net.feed.searcher.CombinedSearcher
import ac.mdiq.podcini.storage.database.RealmDB.realm
@@ -33,10 +33,13 @@ import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.SearchView
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -54,7 +57,6 @@ import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.google.android.material.appbar.MaterialToolbar
-import com.google.android.material.chip.Chip
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
@@ -63,17 +65,18 @@ import kotlinx.coroutines.withContext
import java.text.NumberFormat
class SearchFragment : Fragment() {
- private var _binding: SearchFragmentBinding? = null
+ private var _binding: ComposeFragmentBinding? = null
private val binding get() = _binding!!
private lateinit var searchView: SearchView
- private lateinit var chip: Chip
private lateinit var automaticSearchDebouncer: Handler
private val resultFeeds = mutableStateListOf()
private val results = mutableListOf()
private val vms = mutableStateListOf()
private var infoBarText = mutableStateOf("")
+ private var searchInFeed by mutableStateOf(false)
+ private var feedName by mutableStateOf("")
private var leftActionState = mutableStateOf(NoActionSwipeAction())
private var rightActionState = mutableStateOf(NoActionSwipeAction())
@@ -89,15 +92,25 @@ class SearchFragment : Fragment() {
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- _binding = SearchFragmentBinding.inflate(inflater)
+ _binding = ComposeFragmentBinding.inflate(inflater)
Logd(TAG, "fragment onCreateView")
setupToolbar(binding.toolbar)
swipeActions = SwipeActions(this, TAG)
lifecycle.addObserver(swipeActions)
- binding.resultsListView.setContent {
+ if (requireArguments().getLong(ARG_FEED, 0) > 0L) {
+ searchInFeed = true
+ feedName = requireArguments().getString(ARG_FEED_NAME, "")
+ }
+ binding.mainView.setContent {
CustomTheme(requireContext()) {
Column {
+ if (searchInFeed) FilterChip(onClick = { }, label = { Text(feedName) }, selected = searchInFeed,
+ trailingIcon = { Icon(imageVector = Icons.Filled.Close, contentDescription = "Close icon", modifier = Modifier.size(FilterChipDefaults.IconSize).clickable(onClick = {
+ requireArguments().putLong(ARG_FEED, 0)
+ searchInFeed = false
+ })) }
+ )
CriteriaList()
FeedsRow()
InforBar(infoBarText, leftAction = leftActionState, rightAction = rightActionState, actionConfig = {swipeActions.showDialog()})
@@ -114,15 +127,7 @@ class SearchFragment : Fragment() {
}
}
}
-
refreshSwipeTelltale()
- chip = binding.feedTitleChip
- chip.setOnCloseIconClickListener {
- requireArguments().putLong(ARG_FEED, 0)
- search()
- }
- 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()
searchView.setOnQueryTextFocusChangeListener { view: View, hasFocus: Boolean ->
@@ -193,25 +198,6 @@ class SearchFragment : Fragment() {
})
}
-// override fun onContextItemSelected(item: MenuItem): Boolean {
-//// val selectedFeedItem: Feed? = adapterFeeds.longPressedItem
-//// if (selectedFeedItem != null && onMenuItemClicked(this, item.itemId, selectedFeedItem) {}) return true
-// return super.onContextItemSelected(item)
-// }
-
-// private fun onMenuItemClicked(fragment: Fragment, menuItemId: Int, selectedFeed: Feed, callback: Runnable): Boolean {
-// val context = fragment.requireContext()
-// when (menuItemId) {
-//// R.id.rename_folder_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show()
-// R.id.edit_tags -> if (selectedFeed.preferences != null) TagSettingsDialog.newInstance(listOf(selectedFeed))
-// .show(fragment.childFragmentManager, TagSettingsDialog.TAG)
-// R.id.rename_item -> CustomFeedNameDialog(fragment.activity as Activity, selectedFeed).show()
-// R.id.remove_feed -> RemoveFeedDialog.show(context, selectedFeed, null)
-// else -> return false
-// }
-// return true
-// }
-
private var eventSink: Job? = null
private var eventStickySink: Job? = null
private fun cancelFlowEvents() {
@@ -245,20 +231,26 @@ class SearchFragment : Fragment() {
private fun onEpisodeDownloadEvent(event: FlowEvent.EpisodeDownloadEvent) {
for (url in event.urls) {
val pos: Int = EpisodeUtil.indexOfItemWithDownloadUrl(results, url)
- if (pos >= 0) {
-// results[pos].downloadState.value = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
- vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
- }
+ if (pos >= 0) vms[pos].downloadState = event.map[url]?.state ?: DownloadStatus.State.UNKNOWN.ordinal
}
}
@SuppressLint("StringFormatMatches")
private fun search() {
// adapterFeeds.setEndButton(R.string.search_online) { this.searchOnline() }
- chip.visibility = if ((requireArguments().getLong(ARG_FEED, 0) == 0L)) View.GONE else View.VISIBLE
lifecycleScope.launch {
try {
- val results_ = withContext(Dispatchers.IO) { performSearch() }
+ val results_ = withContext(Dispatchers.IO) {
+ val query = searchView.query.toString()
+ if (query.isEmpty()) Pair, List>(emptyList(), emptyList())
+ else {
+ val feedID = requireArguments().getLong(ARG_FEED)
+ val items: List = searchEpisodes(feedID, query)
+ val feeds: List = searchFeeds(query)
+ Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
+ Pair, List>(items, feeds)
+ }
+ }
withContext(Dispatchers.Main) {
if (results_.first != null) {
val first_ = results_.first!!.toMutableList()
@@ -293,6 +285,7 @@ class SearchFragment : Fragment() {
Column {
Row {
Button(onClick = {showGrid = !showGrid}) { Text(stringResource(R.string.show_criteria)) }
+ Spacer(Modifier.weight(1f))
Button(onClick = { searchOnline() }) { Text(stringResource(R.string.search_online)) }
}
if (showGrid) NonlazyGrid(columns = 2, itemCount = SearchBy.entries.size) { index ->
@@ -359,16 +352,16 @@ class SearchFragment : Fragment() {
}
}
- private fun performSearch(): Pair, List> {
- val query = searchView.query.toString()
- if (query.isEmpty()) return Pair, List>(emptyList(), emptyList())
-
- val feedID = requireArguments().getLong(ARG_FEED)
- val items: List = searchEpisodes(feedID, query)
- val feeds: List = searchFeeds(query)
- Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
- return Pair, List>(items, feeds)
- }
+// private fun performSearch(): Pair, List> {
+// val query = searchView.query.toString()
+// if (query.isEmpty()) return Pair, List>(emptyList(), emptyList())
+//
+// val feedID = requireArguments().getLong(ARG_FEED)
+// val items: List = searchEpisodes(feedID, query)
+// val feeds: List = searchFeeds(query)
+// Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
+// return Pair, List>(items, feeds)
+// }
private fun searchFeeds(query: String): List {
Logd(TAG, "searchFeeds called ${SearchBy.AUTHOR.selected}")
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt
deleted file mode 100644
index f56a5c4e..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/SquareImageView.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package ac.mdiq.podcini.ui.view
-
-
-import ac.mdiq.podcini.R
-import android.content.Context
-import android.util.AttributeSet
-import androidx.appcompat.widget.AppCompatImageView
-import kotlin.math.min
-
-/**
- * From http://stackoverflow.com/a/19449488/6839
- */
-class SquareImageView : AppCompatImageView {
- private var direction = DIRECTION_WIDTH
-
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
- loadAttrs(context, attrs)
- }
-
- constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {
- loadAttrs(context, attrs)
- }
-
- private fun loadAttrs(context: Context, attrs: AttributeSet?) {
- val a = context.obtainStyledAttributes(attrs, R.styleable.SquareImageView)
- direction = a.getInt(R.styleable.SquareImageView_direction, DIRECTION_WIDTH)
- a.recycle()
- }
-
- fun setDirection(direction: Int) {
- this.direction = direction
- requestLayout()
- }
-
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
-
- when (direction) {
- DIRECTION_MINIMUM -> {
- val size = min(measuredWidth.toDouble(), measuredHeight.toDouble()).toInt()
- setMeasuredDimension(size, size)
- }
- DIRECTION_HEIGHT -> setMeasuredDimension(measuredHeight, measuredHeight)
- else -> setMeasuredDimension(measuredWidth, measuredWidth)
- }
- }
-
- companion object {
- const val DIRECTION_WIDTH: Int = 0
- const val DIRECTION_HEIGHT: Int = 1
- const val DIRECTION_MINIMUM: Int = 2
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt
deleted file mode 100644
index 4c2c7310..00000000
--- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/WrappingGridView.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package ac.mdiq.podcini.ui.view
-
-import android.content.Context
-import android.util.AttributeSet
-import android.widget.GridView
-
-/**
- * Source: https://stackoverflow.com/a/46350213/
- */
-class WrappingGridView : GridView {
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
-
- constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle)
-
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- var heightSpec = heightMeasureSpec
- // The great Android "hackatlon", the love, the magic.
- // The two leftmost bits in the height measure spec have
- // a special meaning, hence we can't use them to describe height.
- if (layoutParams.height == LayoutParams.WRAP_CONTENT) heightSpec = MeasureSpec.makeMeasureSpec(Int.MAX_VALUE shr 2, MeasureSpec.AT_MOST)
-
- super.onMeasure(widthMeasureSpec, heightSpec)
- }
-}
diff --git a/app/src/main/res/layout/addfeed.xml b/app/src/main/res/layout/addfeed.xml
deleted file mode 100644
index 82b04a4a..00000000
--- a/app/src/main/res/layout/addfeed.xml
+++ /dev/null
@@ -1,167 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/compose_fragment.xml b/app/src/main/res/layout/compose_fragment.xml
index 6305ceed..3c690a86 100644
--- a/app/src/main/res/layout/compose_fragment.xml
+++ b/app/src/main/res/layout/compose_fragment.xml
@@ -2,7 +2,6 @@
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/simple_icon_list_item.xml b/app/src/main/res/layout/simple_icon_list_item.xml
deleted file mode 100644
index fac17929..00000000
--- a/app/src/main/res/layout/simple_icon_list_item.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index bb3960a0..3e24c58b 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -14,28 +14,28 @@
-
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/styleable.xml b/app/src/main/res/values/styleable.xml
index 3542cc1b..ae99a0e8 100644
--- a/app/src/main/res/values/styleable.xml
+++ b/app/src/main/res/values/styleable.xml
@@ -1,13 +1,5 @@
-
-
-
-
-
-
-
-
diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt
index 9f76d71b..f30755d5 100644
--- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt
+++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastEnabledActivity.kt
@@ -50,11 +50,7 @@ abstract class CastEnabledActivity : AppCompatActivity() {
fun CastIconButton() {
if (canCast) {
AndroidView( modifier = Modifier.size(24.dp),
- factory = { ctx ->
- MediaRouteButton(ctx).apply {
- CastButtonFactory.setUpMediaRouteButton(ctx, this)
- }
- },
+ factory = { ctx -> MediaRouteButton(ctx).apply { CastButtonFactory.setUpMediaRouteButton(ctx, this) } },
)
}
}
diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt
index 9dbdb81e..3788b110 100644
--- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt
+++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastMediaPlayer.kt
@@ -6,6 +6,7 @@ import ac.mdiq.podcini.playback.base.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.model.EpisodeMedia
+import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia
import ac.mdiq.podcini.util.Logd
@@ -13,6 +14,7 @@ import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import android.annotation.SuppressLint
import android.app.UiModeManager
+import android.bluetooth.BluetoothClass.Service.AUDIO
import android.content.Context
import android.content.res.Configuration
import android.util.Log
@@ -129,6 +131,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
// We don't want setPlayerStatus to handle the onPlaybackPause callback
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
}
+ Log.w(TAG, "RemoteMediaPlayer state: $state")
setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING)
when (state) {
MediaStatus.PLAYER_STATE_PLAYING -> {
@@ -140,9 +143,9 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position)
}
MediaStatus.PLAYER_STATE_PAUSED -> setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position)
+ MediaStatus.PLAYER_STATE_LOADING -> { Logd(TAG, "Remote player loading") }
MediaStatus.PLAYER_STATE_BUFFERING -> setPlayerStatus(
- if ((mediaChanged || status == PlayerStatus.PREPARING)) PlayerStatus.PREPARING
- else PlayerStatus.SEEKING,
+ if ((mediaChanged || status == PlayerStatus.PREPARING)) PlayerStatus.PREPARING else PlayerStatus.SEEKING,
currentMedia, currentMedia?.getPosition() ?: Playable.INVALID_TIME)
MediaStatus.PLAYER_STATE_IDLE -> {
val reason = mediaStatus.idleReason
@@ -184,9 +187,8 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
else -> return
}
}
- MediaStatus.PLAYER_STATE_UNKNOWN -> if (status != PlayerStatus.INDETERMINATE || curMedia !== currentMedia) {
+ MediaStatus.PLAYER_STATE_UNKNOWN -> if (status != PlayerStatus.INDETERMINATE || curMedia !== currentMedia)
setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia)
- }
else -> Log.w(TAG, "Remote media state undetermined!")
}
if (mediaChanged) {
@@ -199,10 +201,10 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
* 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
*/
override fun playMediaObject(playable: Playable, streaming: Boolean, startWhenPrepared: Boolean, prepareImmediately: Boolean, forceReset: Boolean) {
+ Logd(TAG, "playMediaObject")
if (!CastUtils.isCastable(playable, castContext.sessionManager.currentCastSession)) {
Logd(TAG, "media provided is not compatible with cast device")
EventFlow.postEvent(FlowEvent.PlayerErrorEvent("Media not compatible with cast device"))
@@ -229,71 +231,63 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
seekTo(pos)
callback.onPlaybackPause(curMedia, pos)
}
-
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
callback.onPostPlayback(prevMedia, false, skipped = false, playingNext = true)
-
prevMedia = curMedia
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}
}
curMedia = playable
- mediaInfo = toMediaInfo(playable)
this.mediaType = curMedia!!.getMediaType()
this.startWhenPrepared.set(startWhenPrepared)
setPlayerStatus(PlayerStatus.INITIALIZING, curMedia)
-// val metadata = buildMetadata(curMedia!!)
-// try {
-// callback.ensureMediaInfoLoaded(curMedia!!)
-// callback.onMediaChanged(false)
-// setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
-// CoroutineScope(Dispatchers.IO).launch {
-// when {
-// streaming -> {
-// val streamurl = curMedia!!.getStreamUrl()
-// if (streamurl != null) {
-// val media = curMedia
-// if (media is EpisodeMedia) {
-// mediaItem = null
-// mediaSource = null
-// setDataSource(metadata, media)
-//// val deferred = CoroutineScope(Dispatchers.IO).async { setDataSource(metadata, media) }
-//// if (startWhenPrepared) runBlocking { deferred.await() }
-//// val preferences = media.episodeOrFetch()?.feed?.preferences
-//// setDataSource(metadata, streamurl, preferences?.username, preferences?.password)
-// } else setDataSource(metadata, streamurl, null, null)
-// }
-// }
-// else -> {
-// val localMediaurl = curMedia!!.getLocalMediaUrl()
-//// File(localMediaurl).canRead() time consuming, leave it to MediaItem to handle
-//// if (!localMediaurl.isNullOrEmpty() && File(localMediaurl).canRead()) setDataSource(metadata, localMediaurl, null, null)
-// if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
-// else throw IOException("Unable to read local file $localMediaurl")
-// }
-// }
-// withContext(Dispatchers.Main) {
-// val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
-// if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
-// if (prepareImmediately) prepare()
-// }
-// }
-// } catch (e: IOException) {
-// e.printStackTrace()
-// setPlayerStatus(PlayerStatus.ERROR, null)
-// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
-// } catch (e: IllegalStateException) {
-// e.printStackTrace()
-// setPlayerStatus(PlayerStatus.ERROR, null)
-// EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
-// } finally { }
+ val metadata = buildMetadata(curMedia!!)
+ try {
+ callback.ensureMediaInfoLoaded(curMedia!!)
+ callback.onMediaChanged(false)
+ setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
+ CoroutineScope(Dispatchers.IO).launch {
+ when {
+ streaming -> {
+ val streamurl = curMedia!!.getStreamUrl()
+ if (streamurl != null) {
+ val media = curMedia
+ if (media is EpisodeMedia) {
+ mediaItem = null
+ mediaSource = null
+ setDataSource(metadata, media)
+ } else setDataSource(metadata, streamurl, null, null)
+ }
+ }
+ else -> {
+ val localMediaurl = curMedia!!.getLocalMediaUrl()
+ if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
+ else throw IOException("Unable to read local file $localMediaurl")
+ }
+ }
+ mediaInfo = toMediaInfo(playable)
+ withContext(Dispatchers.Main) {
+ val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
+ if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
+ if (prepareImmediately) prepare()
+ }
+ }
+ } catch (e: IOException) {
+ e.printStackTrace()
+ setPlayerStatus(PlayerStatus.ERROR, null)
+ EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
+ } catch (e: IllegalStateException) {
+ e.printStackTrace()
+ setPlayerStatus(PlayerStatus.ERROR, null)
+ EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
+ } finally { }
- callback.ensureMediaInfoLoaded(curMedia!!)
- callback.onMediaChanged(true)
- setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
- if (prepareImmediately) prepare()
+// callback.ensureMediaInfoLoaded(curMedia!!)
+// callback.onMediaChanged(true)
+// setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
+// if (prepareImmediately) prepare()
}
override fun resume() {
@@ -308,7 +302,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
override fun prepare() {
if (status == PlayerStatus.INITIALIZED) {
- Logd(TAG, "Preparing media player")
+ Logd(TAG, "Preparing media player $mediaInfo")
setPlayerStatus(PlayerStatus.PREPARING, curMedia)
var position = curMedia!!.getPosition()
if (position > 0) position = calculatePositionWithRewind(position, curMedia!!.getLastPlayedTime())
@@ -417,7 +411,6 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
}
companion object {
-
fun getInstanceIfConnected(context: Context, callback: MediaPlayerCallback): MediaPlayerBase? {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) return null
try { if (CastContext.getSharedInstance(context).castState == CastState.CONNECTED) return CastMediaPlayer(context, callback) } catch (e: Exception) { e.printStackTrace() }
diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt
index ab8a5677..a3df407e 100644
--- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt
+++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastOptionsProvider.kt
@@ -2,15 +2,19 @@ package ac.mdiq.podcini.playback.cast
import android.annotation.SuppressLint
import android.content.Context
+import com.google.android.gms.cast.CastMediaControlIntent
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 com.google.android.gms.cast.framework.media.CastMediaOptions
@Suppress("unused")
@SuppressLint("VisibleForTests")
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
- return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build()
+// return CastOptions.Builder().setReceiverApplicationId("BEBC1DB1").build()
+// return CastOptions.Builder().setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).build()
+ return CastOptions.Builder().setReceiverApplicationId("C7113B39").build()
}
override fun getAdditionalSessionProviders(context: Context): List? {
diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt
index 8eaa5a50..7328df7b 100644
--- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt
+++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/CastUtils.kt
@@ -1,6 +1,7 @@
package ac.mdiq.podcini.playback.cast
import ac.mdiq.podcini.storage.model.*
+import ac.mdiq.podcini.util.Logd
import android.content.ContentResolver
import android.util.Log
import com.google.android.gms.cast.CastDevice
@@ -56,12 +57,11 @@ object CastUtils {
* @return [Playable] object in a format proper for casting.
*/
fun makeRemoteMedia(media: MediaInfo): Playable? {
+ Logd(TAG, "makeRemoteMedia called")
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 Podcini CastUtils, curVer=" + FORMAT_VERSION_VALUE
- + ", object version=" + version)
+ Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this version of Podcini CastUtils, curVer=$FORMAT_VERSION_VALUE, object version=$version")
return null
}
val imageList = metadata.images
@@ -87,11 +87,9 @@ object CastUtils {
/**
* Compares a [MediaInfo] instance with a [EpisodeMedia] one and evaluates whether they
* represent the same podcast episode.
- *
* @param info the [MediaInfo] object to be compared.
* @param media the [EpisodeMedia] object to be compared.
* @return true if there's a match, `false` otherwise.
- *
* @see RemoteMedia.equals
*/
fun matches(info: MediaInfo?, media: EpisodeMedia?): Boolean {
@@ -109,11 +107,9 @@ object CastUtils {
/**
* 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 {
@@ -129,11 +125,9 @@ object CastUtils {
* 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 {
diff --git a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt
index c83dec32..fe14c3da 100644
--- a/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt
+++ b/app/src/play/kotlin/ac/mdiq/podcini/playback/cast/MediaInfoCreator.kt
@@ -1,8 +1,10 @@
package ac.mdiq.podcini.playback.cast
-import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.EpisodeMedia
+import ac.mdiq.podcini.storage.model.Feed
+import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.RemoteMedia
+import ac.mdiq.podcini.util.Logd
import android.net.Uri
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
@@ -36,7 +38,9 @@ object MediaInfoCreator {
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!)
val builder = MediaInfo.Builder(media.getStreamUrl()?:"")
- .setContentType(media.getMimeType())
+ // TODO: test
+// .setContentType(media.getMimeType())
+ .setContentType("audio/*")
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
@@ -66,7 +70,7 @@ object MediaInfoCreator {
val url: String = if (feedItem.imageUrl == null && feed != null) feed.imageUrl?:"" else feedItem.imageUrl?:""
if (url.isNotEmpty()) metadata.addImage(WebImage(Uri.parse(url)))
val calendar = Calendar.getInstance()
- if (media.episode?.getPubDate() != null) calendar.time = media.episode!!.getPubDate()!!
+ calendar.time = feedItem.getPubDate()
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar)
if (feed != null) {
if (!feed.author.isNullOrEmpty()) metadata.putString(MediaMetadata.KEY_ARTIST, feed.author!!)
@@ -87,8 +91,13 @@ object MediaInfoCreator {
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.mimeType)
+ Logd("MediaInfoCreator", "media.mimeType: ${media.mimeType} ${media.audioUrl}")
+ // TODO: these are hardcoded for audio only
+// val builder = MediaInfo.Builder(media.getStreamUrl()!!)
+// .setContentType(media.mimeType)
+ var url: String = if (media.getMediaType() == MediaType.AUDIO) media.getStreamUrl() ?: "" else media.audioUrl
+ val builder = MediaInfo.Builder(url)
+ .setContentType("audio/*")
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)
if (media.getDuration() > 0) builder.setStreamDuration(media.getDuration().toLong())
diff --git a/changelog.md b/changelog.md
index 7a04a6f2..f20eb2de 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,9 +1,20 @@
+# 6.14.1
+
+* changed the term "virtual queue" to "natural queue" in the literature to refer to the list of episodes in a given feed
+* ignore updating episodes with duration of less than 1 second
+* start using Podcini's own cast provider in the play app
+ * audio-only youtube media can now be cast to speaker (experimental for now)
+ * cast of any video is disabled for now
+* OnlineSearch fragment is in Compose
+* minor Compose amendments in fragments of Search, About
+* updates in documents of required licenses
+
# 6.14.0
* fixed crash when adding podcast (introduced since 6.13.11)
* naming changes in PlayState: InQueue -> Queue, InProgress -> Progress
* PlayState Queue is user settable, once set, the episode is put to associated queue of the feed
-* in getting next to play in a virtual queue, PlayStates Again and Forever are included
+* in getting next to play in a natural queue, PlayStates Again and Forever are included
* fixed the not-updating queue and tag spinners in Subscriptions
* various dates display are in flex format
* in Statistics, data for today are shown in the HH:mm format
@@ -40,7 +51,7 @@
# 6.13.9
* made Spinner in Queues view update accordingly
-* if playing from the virtual queue in FeedEpisodes, the next episode comes from the filtered list
+* if playing from the natural queue in FeedEpisodes, the next episode comes from the filtered list
* when playing another media, post playback is performed on the current media so as to set the right state
* fixed timeSpent not being correctly recorded
* further enhanced efficiency of statistics calculations
@@ -516,7 +527,7 @@
* added ability to receive music or playlist shared from YT Music
* single music received from YT Music is added to "YTMusic Syndicate"
* in newly subscribed Youtube channel or playlist, prefStreamOverDownload is set to true
-* ensured virtual queue (continuous streaming) for previously subscribed "Youtube" type feed when the associated queue is set to None
+* ensured natural queue (continuous streaming) for previously subscribed "Youtube" type feed when the associated queue is set to None
* the max number of media is dropped to 60 on subscribing a Youtube type feed (higher number may cause delay or even failure due to network issues, will investigate other ways to handle)
* fixed a bug in Reconcile that can cause downloaded files to be mistakenly deleted
* action button on each episode now reacts to LongClick to provide more options
diff --git a/fastlane/metadata/android/en-US/changelogs/3020299.txt b/fastlane/metadata/android/en-US/changelogs/3020299.txt
index 4e6302dd..8ef8a467 100644
--- a/fastlane/metadata/android/en-US/changelogs/3020299.txt
+++ b/fastlane/metadata/android/en-US/changelogs/3020299.txt
@@ -3,7 +3,7 @@
* fixed crash when adding podcast (introduced since 6.13.11)
* naming changes in PlayState: InQueue -> Queue, InProgress -> Progress
* PlayState Queue is user settable, once set, the episode is put to associated queue of the feed
-* in getting next to play in a virtual queue, PlayStates Again and Forever are included
+* in getting next to play in a natural queue, PlayStates Again and Forever are included
* fixed the not-updating queue and tag spinners in Subscriptions
* various dates display are in flex format
* in Statistics, data for today are shown in the HH:mm format
diff --git a/fastlane/metadata/android/en-US/changelogs/3020300.txt b/fastlane/metadata/android/en-US/changelogs/3020300.txt
new file mode 100644
index 00000000..63f6bf29
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3020300.txt
@@ -0,0 +1,10 @@
+ Version 6.14.1
+
+* changed the term "virtual queue" to "natural queue" in the literature to refer to the list of episodes in a given feed
+* ignore updating episodes with duration of less than 1 second
+* start using Podcini's own cast provider in the play app
+ * audio-only youtube media can now be cast to speaker (experimental for now)
+ * cast of any video is disabled for now
+* OnlineSearch fragment is in Compose
+* minor Compose amendments in fragments of Search, About
+* updates in documents of required licenses
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ce303c9d..35c12081 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,7 +20,7 @@ fyydlin = "v0.5.0"
googleMaterialTypeface = "4.0.0.3-kotlin"
googleMaterialTypefaceOutlined = "4.0.0.2-kotlin"
gradle = "8.6.1"
-gridlayout = "1.0.0"
+#gridlayout = "1.0.0"
groovyXml = "3.0.19"
iconicsCore = "5.5.0-b01"
iconicsViews = "5.5.0-b01"
@@ -51,7 +51,7 @@ rxjavaVersion = "3.1.8"
searchpreference = "v2.5.0"
uiToolingPreview = "1.7.5"
uiTooling = "1.7.5"
-viewpager2 = "1.1.0"
+#viewpager2 = "1.1.0"
vistaguide = "lv0.24.2.6"
wearable = "2.9.0"
webkit = "1.12.1"
@@ -68,7 +68,7 @@ androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorl
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
-androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" }
+#androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
@@ -80,7 +80,7 @@ androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" }
androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiToolingPreview" }
-androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
+#androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }