mirror of
https://github.com/XilinJia/Podcini.git
synced 2025-01-27 20:29:20 +01:00
6.14.1 commit
This commit is contained in:
parent
c646505e93
commit
a213284e40
@ -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
|
||||
|
31
README.md
31
README.md
@ -24,18 +24,16 @@ This project was developed from a fork of [AntennaPod](<https://github.com/Anten
|
||||
|
||||
Compared to AntennaPod this project:
|
||||
|
||||
1. Migrated all media routines to `androidx.media3`, with `AudioOffloadMode` enabled, nicer to device battery,
|
||||
2. Is purely `Kotlin` based and mono-modular, and multiple views are in Jetpack Compose,
|
||||
3. Modern object-base Realm DB replaced SQLite, Coil replaced Glide, coroutines replaced RxJava and threads, and SharedFlow replaced EventBus.
|
||||
4. Boasts new UI's including streamlined drawer, subscriptions view and player controller, and many more.
|
||||
5. Supports multiple, virtual and circular play queues associable with any podcast.
|
||||
6. Auto-download is governed by policy and limit settings of individual feed (podcast).
|
||||
7. Features synthetic podcasts and allows episodes to be shelved to any synthetic podcast.
|
||||
8. Supports channels, playlists, single media from YouTube and YT Music, as well as normal podcasts and plain RSS,
|
||||
9. Allows setting personal notes, 5-level rating, and 12-level play state on every episode.
|
||||
10. Supports sophisticated filtering and sorting on episodes and podcasts.
|
||||
11. Offers Readability and Text-to-Speech for RSS contents,
|
||||
12. Features `instant sync` across devices without a server.
|
||||
1. Is mono-modular, purely in `Kotlin`, mostly in Jetpack Compose, based on `medai3` with `AudioOffloadMode` enabled (nicer to device battery).
|
||||
2. Supports contents from YouTube and YT Music, as well as normal podcasts and plain RSS.
|
||||
3. Features multiple, natural and circular play queues associable with any podcast.
|
||||
4. Presents synthetic podcasts and allows episodes to be shelved to any synthetic podcast.
|
||||
5. Allows setting personal notes, 5-level rating, and 12-level play state on every episode.
|
||||
6. Boasts sophisticated filtering and sorting on episodes and podcasts.
|
||||
7. Promotes auto-download governed by policy and limit settings of individual feed (podcast).
|
||||
8. Spotlights `instant sync` across devices without a server.
|
||||
9. Offers Readability and Text-to-Speech for RSS contents,
|
||||
10. Replaced SQLite with modern object-base Realm DB, Glide with Coil, RxJava and threads with coroutines , and EventBus with SharedFlow,
|
||||
|
||||
The project aims to profit from modern frameworks, improve efficiency and provide more useful and user-friendly features.
|
||||
|
||||
@ -117,11 +115,10 @@ While podcast subscriptions' OPML files (from AntennaPod or any other sources) c
|
||||
* Every queue is circular: if the final item in queue finished, the first item in queue (if exists) will get played
|
||||
* Every queue has a bin containing past episodes removed from the queue, useful for further review and handling
|
||||
* Feed associated queue can be set to None, in which case:
|
||||
* episodes in the feed are not automatically added to any queue,
|
||||
* the episodes in the feed forms a virtual queue
|
||||
* the next episode is determined in such a way:
|
||||
* if the currently playing episode had been (manually) added to the active queue, then it's the next in queue
|
||||
* else if "prefer streaming" is set, it's the next unplayed (or Again and Forever) episode in the virtual queue based on the current filter and sort order
|
||||
* the episodes in the feed are not automatically added to any queue, instead forms a natural queue on their own
|
||||
* the next episode to play is determined in such a way:
|
||||
* if the currently playing episode had been (manually) added to the active queue, then it's the next in the queue
|
||||
* else if "prefer streaming" is set, it's the next unplayed (or Again and Forever) episode in the natural queue based on the current filter and sort order
|
||||
* else it's the next downloaded unplayed (or Again and Forever) episode
|
||||
* Otherwise, episode played from a list other than the queue is a one-off play, unless the episode is on the active queue, in which case, the next episode in the queue will be played
|
||||
|
||||
|
@ -26,8 +26,8 @@ android {
|
||||
vectorDrawables.useSupportLibrary false
|
||||
vectorDrawables.generatedDensities = []
|
||||
|
||||
versionCode 3020299
|
||||
versionName "6.14.0"
|
||||
versionCode 3020300
|
||||
versionName "6.14.1"
|
||||
|
||||
applicationId "ac.mdiq.podcini.R"
|
||||
def commit = ""
|
||||
@ -198,7 +198,7 @@ dependencies {
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.coordinatorlayout
|
||||
implementation libs.androidx.fragment.fragment.ktx
|
||||
implementation libs.androidx.gridlayout
|
||||
// implementation libs.androidx.gridlayout
|
||||
implementation libs.androidx.media3.exoplayer
|
||||
implementation libs.androidx.media3.ui
|
||||
implementation libs.androidx.media3.media3.datasource.okhttp
|
||||
@ -207,7 +207,7 @@ dependencies {
|
||||
implementation libs.androidx.palette.ktx
|
||||
implementation libs.androidx.preference.ktx
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.androidx.viewpager2
|
||||
// implementation libs.androidx.viewpager2
|
||||
implementation libs.androidx.work.runtime
|
||||
implementation libs.androidx.core.splashscreen
|
||||
implementation libs.androidx.documentfile
|
||||
|
@ -66,12 +66,12 @@
|
||||
website="https://jsoup.org/"
|
||||
license="MIT"
|
||||
licenseText="LICENSE_JSOUP.txt" />
|
||||
<library
|
||||
name="Lightweight-Stream-API"
|
||||
author="Victor Melnik"
|
||||
website="https://github.com/aNNiMON/Lightweight-Stream-API"
|
||||
license="Apache 2.0"
|
||||
licenseText="LICENSE_APACHE-2.0.txt" />
|
||||
<!-- <library-->
|
||||
<!-- name="Lightweight-Stream-API"-->
|
||||
<!-- author="Victor Melnik"-->
|
||||
<!-- website="https://github.com/aNNiMON/Lightweight-Stream-API"-->
|
||||
<!-- license="Apache 2.0"-->
|
||||
<!-- licenseText="LICENSE_APACHE-2.0.txt" />-->
|
||||
<library
|
||||
name="Material Components for Android"
|
||||
author="Google"
|
||||
|
@ -183,7 +183,7 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
|
||||
}
|
||||
// stop playback of this episode
|
||||
if (status == PlayerStatus.PAUSED || status == PlayerStatus.PLAYING || status == PlayerStatus.PREPARED) exoPlayer?.stop()
|
||||
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
|
||||
if (prevMedia != null && curMedia?.getIdentifier() != prevMedia?.getIdentifier())
|
||||
callback.onPostPlayback(prevMedia, ended = false, skipped = true, true)
|
||||
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
|
||||
}
|
||||
|
@ -30,8 +30,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
@ -144,6 +147,8 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
|
||||
}
|
||||
val audioStream = audioStreamsList[audioIndex]
|
||||
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
|
||||
|
||||
media.audioUrl = audioStream.content
|
||||
val aSource = DefaultMediaSourceFactory(context).createMediaSource(
|
||||
MediaItem.Builder().setMediaMetadata(metadata).setTag(metadata).setUri(Uri.parse(audioStream.content)).build())
|
||||
if (media.forceVideo || media.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) {
|
||||
|
@ -7,9 +7,6 @@ import ac.mdiq.podcini.storage.model.MediaType
|
||||
interface MediaPlayerCallback {
|
||||
fun statusChanged(newInfo: MediaPlayerInfo?)
|
||||
|
||||
// TODO: not used
|
||||
fun shouldStop() {}
|
||||
|
||||
fun onMediaChanged(reloadUI: Boolean)
|
||||
|
||||
fun onPostPlayback(playable: Playable?, ended: Boolean, skipped: Boolean, playingNext: Boolean)
|
||||
|
@ -2,31 +2,44 @@ package ac.mdiq.podcini.preferences.fragments
|
||||
|
||||
import ac.mdiq.podcini.BuildConfig
|
||||
import ac.mdiq.podcini.R
|
||||
import ac.mdiq.podcini.databinding.SimpleIconListItemBinding
|
||||
import ac.mdiq.podcini.ui.activity.PreferenceActivity
|
||||
import ac.mdiq.podcini.ui.compose.CustomTheme
|
||||
import ac.mdiq.podcini.util.IntentUtils.openInBrowser
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.ListFragment
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import coil.load
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
@ -68,13 +81,11 @@ class AboutFragment : PreferenceFragmentCompat() {
|
||||
(activity as PreferenceActivity).supportActionBar!!.setTitle(R.string.about_pref)
|
||||
}
|
||||
|
||||
class LicensesFragment : ListFragment() {
|
||||
private val licenses = ArrayList<LicenseItem>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
listView.divider = null
|
||||
class LicensesFragment : Fragment() {
|
||||
private val licenses = mutableStateListOf<LicenseItem>()
|
||||
|
||||
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<CharSequence>("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<T : SimpleIconListAdapter.ListItem>(private val context: Context, private val listItems: List<T>)
|
||||
: ArrayAdapter<T>(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)
|
||||
}
|
||||
}
|
||||
|
@ -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}")
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -254,18 +254,3 @@ fun AutoCompleteTextField(suggestions: List<String>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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<PodcastSearchResult>()
|
||||
|
||||
private val chooseOpmlImportPathLauncher = registerForActivityResult<String, Uri>(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<Feed>): List<PodcastSearchResult> {
|
||||
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<PodcastSearchResult> {
|
||||
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<PodcastSearchResult> = 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<PodcastSearchResult>, subscribedFeeds: List<Feed>, limit: Int): List<PodcastSearchResult> {
|
||||
val subscribedPodcastsSet: MutableSet<String> = 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<PodcastSearchResult> = 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<PodcastSearchResult>? = listOf()
|
||||
|
||||
private var countryCode: String? = "US"
|
||||
private var hidden = false
|
||||
private var needsConfirm = false
|
||||
|
||||
private var searchResults = mutableStateListOf<PodcastSearchResult>()
|
||||
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<PodcastSearchResult>) {
|
||||
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<String> = listOf(*Locale.getISOCountries())
|
||||
val countryCodeNames: MutableMap<String?, String> = HashMap()
|
||||
val countryNameCodes: MutableMap<String, String> = HashMap()
|
||||
for (code in countryCodeArray) {
|
||||
val locale = Locale("", code)
|
||||
val countryName = locale.displayCountry
|
||||
countryCodeNames[code] = countryName
|
||||
countryNameCodes[countryName] = code
|
||||
}
|
||||
|
||||
val countryNamesSort: MutableList<String> = 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
|
||||
}
|
||||
}
|
||||
|
@ -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<PodcastSearchResult>()
|
||||
|
||||
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<PodcastSearchResult> = ArrayList<PodcastSearchResult>()
|
||||
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<Feed>): List<PodcastSearchResult> {
|
||||
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<PodcastSearchResult> {
|
||||
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<PodcastSearchResult> = 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<PodcastSearchResult>, subscribedFeeds: List<Feed>, limit: Int): List<PodcastSearchResult> {
|
||||
val subscribedPodcastsSet: MutableSet<String> = 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<PodcastSearchResult> = 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<PodcastSearchResult>? = listOf()
|
||||
|
||||
private var countryCode: String? = "US"
|
||||
private var hidden = false
|
||||
private var needsConfirm = false
|
||||
|
||||
private var searchResults = mutableStateListOf<PodcastSearchResult>()
|
||||
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<PodcastSearchResult>) {
|
||||
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<String> = listOf(*Locale.getISOCountries())
|
||||
val countryCodeNames: MutableMap<String?, String> = HashMap()
|
||||
val countryNameCodes: MutableMap<String, String> = HashMap()
|
||||
for (code in countryCodeArray) {
|
||||
val locale = Locale("", code)
|
||||
val countryName = locale.displayCountry
|
||||
countryCodeNames[code] = countryName
|
||||
countryNameCodes[countryName] = code
|
||||
}
|
||||
|
||||
val countryNamesSort: MutableList<String> = 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
|
||||
}
|
||||
}
|
@ -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<Feed>()
|
||||
private val results = mutableListOf<Episode>()
|
||||
private val vms = mutableStateListOf<EpisodeVM>()
|
||||
private var infoBarText = mutableStateOf("")
|
||||
private var searchInFeed by mutableStateOf(false)
|
||||
private var feedName by mutableStateOf("")
|
||||
|
||||
private var leftActionState = mutableStateOf<SwipeAction>(NoActionSwipeAction())
|
||||
private var rightActionState = mutableStateOf<SwipeAction>(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<Episode>, List<Feed>>(emptyList(), emptyList())
|
||||
else {
|
||||
val feedID = requireArguments().getLong(ARG_FEED)
|
||||
val items: List<Episode> = searchEpisodes(feedID, query)
|
||||
val feeds: List<Feed> = searchFeeds(query)
|
||||
Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
|
||||
Pair<List<Episode>, List<Feed>>(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<Episode>, List<Feed>> {
|
||||
val query = searchView.query.toString()
|
||||
if (query.isEmpty()) return Pair<List<Episode>, List<Feed>>(emptyList(), emptyList())
|
||||
|
||||
val feedID = requireArguments().getLong(ARG_FEED)
|
||||
val items: List<Episode> = searchEpisodes(feedID, query)
|
||||
val feeds: List<Feed> = searchFeeds(query)
|
||||
Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
|
||||
return Pair<List<Episode>, List<Feed>>(items, feeds)
|
||||
}
|
||||
// private fun performSearch(): Pair<List<Episode>, List<Feed>> {
|
||||
// val query = searchView.query.toString()
|
||||
// if (query.isEmpty()) return Pair<List<Episode>, List<Feed>>(emptyList(), emptyList())
|
||||
//
|
||||
// val feedID = requireArguments().getLong(ARG_FEED)
|
||||
// val items: List<Episode> = searchEpisodes(feedID, query)
|
||||
// val feeds: List<Feed> = searchFeeds(query)
|
||||
// Logd(TAG, "performSearch items: ${items.size} feeds: ${feeds.size}")
|
||||
// return Pair<List<Episode>, List<Feed>>(items, feeds)
|
||||
// }
|
||||
|
||||
private fun searchFeeds(query: String): List<Feed> {
|
||||
Logd(TAG, "searchFeeds called ${SearchBy.AUTHOR.selected}")
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -1,167 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?android:attr/actionBarSize"
|
||||
android:theme="?attr/actionBarTheme"
|
||||
app:title="@string/add_feed_label"
|
||||
app:navigationContentDescription="@string/toolbar_back_button_content_description"
|
||||
app:navigationIcon="?homeAsUpIndicator" />
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginRight="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/combinedFeedSearchEditText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:inputType="text"
|
||||
android:imeOptions="actionSearch"
|
||||
android:importantForAutofill="no"
|
||||
android:layout_marginStart="0dp"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:hint="@string/search_podcast_hint"
|
||||
android:background="@null" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/searchButton"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:contentDescription="@string/search_podcast_hint"
|
||||
android:scaleType="center"
|
||||
app:srcCompat="@drawable/ic_search" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/quickFeedDiscovery"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:name="ac.mdiq.podcini.ui.fragment.QuickDiscoveryFragment" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/advanced"
|
||||
android:textSize="18sp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:accessibilityHeading="true"
|
||||
android:textColor="?android:attr/textColorPrimary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/addViaUrlButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_podcast_by_url"
|
||||
app:drawableStartCompat="@drawable/ic_feed"
|
||||
app:drawableLeftCompat="@drawable/ic_feed"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/addLocalFolderButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/add_local_folder"
|
||||
app:drawableStartCompat="@drawable/ic_folder"
|
||||
app:drawableLeftCompat="@drawable/ic_folder"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchVistaGuideButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_vistaguide_label"
|
||||
app:drawableStartCompat="@drawable/ic_search"
|
||||
app:drawableLeftCompat="@drawable/ic_search"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchItunesButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_itunes_label"
|
||||
app:drawableStartCompat="@drawable/ic_search"
|
||||
app:drawableLeftCompat="@drawable/ic_search"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchFyydButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_fyyd_label"
|
||||
app:drawableStartCompat="@drawable/ic_search"
|
||||
app:drawableLeftCompat="@drawable/ic_search"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchGPodderButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/gpodnet_search_hint"
|
||||
app:drawableStartCompat="@drawable/ic_search"
|
||||
app:drawableLeftCompat="@drawable/ic_search"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchPodcastIndexButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_podcastindex_label"
|
||||
app:drawableStartCompat="@drawable/ic_search"
|
||||
app:drawableLeftCompat="@drawable/ic_search"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/opmlImportButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/opml_add_podcast_label"
|
||||
app:drawableStartCompat="@drawable/ic_download"
|
||||
app:drawableLeftCompat="@drawable/ic_download"
|
||||
style="@style/AddPodcastTextView" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
@ -2,7 +2,6 @@
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
|
@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="true"
|
||||
android:id="@+id/search_fragment">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?android:attr/actionBarSize"
|
||||
android:theme="?attr/actionBarTheme"
|
||||
app:title="@string/search_label"
|
||||
app:navigationContentDescription="@string/toolbar_back_button_content_description"
|
||||
app:navigationIcon="?homeAsUpIndicator" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/feed_title_chip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:visibility="gone"
|
||||
app:closeIconVisible="true" />
|
||||
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/resultsListView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
</LinearLayout>
|
@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/simple_icon_list_item"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:id="@+id/icon"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<TextView
|
||||
tools:text="Title"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="16sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/title"/>
|
||||
|
||||
<TextView
|
||||
tools:text="Subtitle"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="14sp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/subtitle"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
@ -14,28 +14,28 @@
|
||||
<attr name="icon_green" format="color"/>
|
||||
<attr name="icon_purple" format="color"/>
|
||||
<attr name="icon_gray" format="color"/>
|
||||
<declare-styleable name="TriangleLabelView">
|
||||
<attr name="backgroundColor" format="color"/>
|
||||
<attr name="primaryTextColor" format="color"/>
|
||||
<attr name="primaryText" format="string"/>
|
||||
<attr name="primaryTextSize" format="dimension"/>
|
||||
<!-- <declare-styleable name="TriangleLabelView">-->
|
||||
<!-- <attr name="backgroundColor" format="color"/>-->
|
||||
<!-- <attr name="primaryTextColor" format="color"/>-->
|
||||
<!-- <attr name="primaryText" format="string"/>-->
|
||||
<!-- <attr name="primaryTextSize" format="dimension"/>-->
|
||||
|
||||
<attr name="labelTopPadding" format="dimension"/>
|
||||
<attr name="labelCenterPadding" format="dimension"/>
|
||||
<attr name="labelBottomPadding" format="dimension"/>
|
||||
<!-- <attr name="labelTopPadding" format="dimension"/>-->
|
||||
<!-- <attr name="labelCenterPadding" format="dimension"/>-->
|
||||
<!-- <attr name="labelBottomPadding" format="dimension"/>-->
|
||||
|
||||
<attr name="corner">
|
||||
<enum name="leftTop" value="1" />
|
||||
<enum name="rightTop" value="2" />
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
<declare-styleable name="NestedScrollableHost">
|
||||
<attr name="scrollDirection" format="enum">
|
||||
<enum name="both" value="0"/>
|
||||
<enum name="vertical" value="1"/>
|
||||
<enum name="horizontal" value="2"/>
|
||||
</attr>
|
||||
<attr name="preferHorizontal" format="integer"/>
|
||||
<attr name="preferVertical" format="integer"/>
|
||||
</declare-styleable>
|
||||
<!-- <attr name="corner">-->
|
||||
<!-- <enum name="leftTop" value="1" />-->
|
||||
<!-- <enum name="rightTop" value="2" />-->
|
||||
<!-- </attr>-->
|
||||
<!-- </declare-styleable>-->
|
||||
<!-- <declare-styleable name="NestedScrollableHost">-->
|
||||
<!-- <attr name="scrollDirection" format="enum">-->
|
||||
<!-- <enum name="both" value="0"/>-->
|
||||
<!-- <enum name="vertical" value="1"/>-->
|
||||
<!-- <enum name="horizontal" value="2"/>-->
|
||||
<!-- </attr>-->
|
||||
<!-- <attr name="preferHorizontal" format="integer"/>-->
|
||||
<!-- <attr name="preferVertical" format="integer"/>-->
|
||||
<!-- </declare-styleable>-->
|
||||
</resources>
|
||||
|
@ -1,13 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<declare-styleable name="SquareImageView">
|
||||
<attr name="direction" format="enum">
|
||||
<enum name="width" value="0"/>
|
||||
<enum name="height" value="1"/>
|
||||
<enum name="minimum" value="2"/>
|
||||
</attr>
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="CircularProgressBar">
|
||||
<attr name="foregroundColor" format="color" />
|
||||
</declare-styleable>
|
||||
|
@ -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) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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() }
|
||||
|
@ -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<SessionProvider>? {
|
||||
|
@ -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>true</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>true</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>true</true> if there's a match, `false` otherwise.
|
||||
*
|
||||
* @see RemoteMedia.equals
|
||||
*/
|
||||
fun matches(info: MediaInfo?, media: Playable?): Boolean {
|
||||
|
@ -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())
|
||||
|
17
changelog.md
17
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
|
||||
|
@ -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
|
||||
|
10
fastlane/metadata/android/en-US/changelogs/3020300.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/3020300.txt
Normal file
@ -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
|
@ -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" }
|
||||
|
Loading…
x
Reference in New Issue
Block a user