6.8.7 commit

This commit is contained in:
Xilin Jia 2024-10-04 18:07:15 +01:00
parent 558b6fdf0c
commit d5881952ab
16 changed files with 410 additions and 608 deletions

View File

@ -1,14 +1,12 @@
package ac.mdiq.podcini.net.feed
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.download.service.DownloadRequestCreator.create
import ac.mdiq.podcini.net.download.service.Downloader
import ac.mdiq.podcini.net.download.service.HttpDownloader
import ac.mdiq.podcini.net.feed.parser.FeedHandler
import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl
import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfoItem
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.Feed
@ -23,10 +21,8 @@ import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo
import ac.mdiq.vista.extractor.exceptions.ExtractionException
import ac.mdiq.vista.extractor.playlist.PlaylistInfo
import ac.mdiq.vista.extractor.stream.StreamInfoItem
import android.app.Dialog
import android.content.Context
import android.util.Log
import androidx.annotation.UiThread
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList
import kotlinx.coroutines.CoroutineScope
@ -39,31 +35,19 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
// TODO: Extracted from OnlineFeedFragment, will do some merging later
class DirectSubscribe(val context: Context) {
class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) {
private val TAG = "DirectSubscribe"
var feedSource: String = ""
private var selectedDownloadUrl: String? = null
private var feeds: List<Feed>? = null
var selectedDownloadUrl: String? = null
private var downloader: Downloader? = null
private var username: String? = null
private var password: String? = null
private var dialog: Dialog? = null
fun startFeedBuilding(url: String) {
fun startFeedBuilding(url: String, username: String?, password: String?, handleFeed: (Feed, Map<String, String>)->Unit) {
if (feedSource == "VistaGuide" || url.contains("youtube.com")) {
feedSource = "VistaGuide"
CoroutineScope(Dispatchers.IO).launch {
try {
feeds = getFeedList()
val service = try {
Vista.getService("YouTube")
} catch (e: ExtractionException) {
throw ExtractionException("YouTube service not found")
}
val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") }
selectedDownloadUrl = prepareUrl(url)
val feed_ = Feed(selectedDownloadUrl, null)
feed_.id = Feed.newId()
@ -77,8 +61,7 @@ class DirectSubscribe(val context: Context) {
feed_.title = playlistInfo.name
feed_.description = playlistInfo.description?.content ?: ""
feed_.author = playlistInfo.uploaderName
feed_.imageUrl =
if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
var infoItems = playlistInfo.relatedItems
var nextPage = playlistInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}")
@ -99,18 +82,17 @@ class DirectSubscribe(val context: Context) {
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
}
feed_.episodes = eList
// withContext(Dispatchers.Main) { showFeedInformation(feed_, mapOf()) }
subscribe(feed_)
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} else {
val channelInfo = ChannelInfo.getInfo(service, url)
Logd(TAG, "startFeedBuilding result: $channelInfo ${channelInfo.tabs.size}")
if (channelInfo.tabs.isEmpty()) {
withContext(Dispatchers.Main) { showErrorDialog("Channel is empty", "") }
withContext(Dispatchers.Main) { showError("Channel is empty", "") }
return@launch
}
try {
@ -119,8 +101,7 @@ class DirectSubscribe(val context: Context) {
feed_.title = channelInfo.name
feed_.description = channelInfo.description
feed_.author = channelInfo.parentChannelName
feed_.imageUrl =
if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
var infoItems = channelTabInfo.relatedItems
var nextPage = channelTabInfo.nextPage
@ -142,21 +123,20 @@ class DirectSubscribe(val context: Context) {
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
}
feed_.episodes = eList
// withContext(Dispatchers.Main) { showFeedInformation(feed_, mapOf()) }
subscribe(feed_)
withContext(Dispatchers.Main) { handleFeed(feed_, mapOf()) }
} catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error1 ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
withContext(Dispatchers.Main) { showError(e.message, "") }
}
}
} catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
withContext(Dispatchers.Main) { showError(e.message, "") }
}
}
return
@ -171,14 +151,13 @@ class DirectSubscribe(val context: Context) {
for (element in linkElements) {
val rssUrl = element.attr("href")
Logd(TAG, "RSS URL: $rssUrl")
startFeedBuilding(rssUrl)
return
startFeedBuilding(rssUrl, username, password) {feed, map -> handleFeed(feed, map) }
}
}
"XML" -> {}
else -> {
Log.e(TAG, "unknown url type $urlType")
showErrorDialog("unknown url type $urlType", "")
showError("unknown url type $urlType", "")
return
}
}
@ -189,7 +168,6 @@ class DirectSubscribe(val context: Context) {
.build()
CoroutineScope(Dispatchers.IO).launch {
try {
feeds = getFeedList()
downloader = HttpDownloader(request)
downloader?.call()
val status = downloader?.result
@ -198,69 +176,27 @@ class DirectSubscribe(val context: Context) {
status.isSuccessful -> {
try {
val result = doParseFeed(request.destination)
// if (result != null) withContext(Dispatchers.Main) {
// showFeedInformation(result.feed, result.alternateFeedUrls)
// }
if (result != null) subscribe(result.feed)
if (result != null) withContext(Dispatchers.Main) { handleFeed(result.feed, result.alternateFeedUrls) }
} catch (e: Throwable) {
Logd(TAG, "Feed parser exception: " + Log.getStackTraceString(e))
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
}
}
else -> withContext(Dispatchers.Main) {
when {
status.reason == DownloadError.ERROR_UNAUTHORIZED -> {
Logd(TAG, "status.reason: DownloadError.ERROR_UNAUTHORIZED")
// if (!isRemoving && !isPaused) {
// if (username != null && password != null)
// Toast.makeText(context, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show()
// if (downloader?.downloadRequest?.source != null) {
// dialog = FeedViewAuthenticationDialog(context, R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create()
// dialog?.show()
// }
// }
}
else -> showErrorDialog(context.getString(from(status.reason)), status.reasonDetailed)
withContext(Dispatchers.Main) { showError(e.message, "") }
}
}
else -> withContext(Dispatchers.Main) { showError(context.getString(from(status.reason)), status.reasonDetailed) }
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
withContext(Dispatchers.Main) { showError(e.message, "") }
}
}
}
@UiThread
private fun showErrorDialog(errorMsg: String?, details: String) {
Logd(TAG, "error: ${errorMsg} \n details: $details")
// if (!isRemoving && !isPaused) {
// val builder = MaterialAlertDialogBuilder(context)
// builder.setTitle(R.string.error_label)
// if (errorMsg != null) {
// val total = """
// $errorMsg
//
// $details
// """.trimIndent()
// val errorMessage = SpannableString(total)
// errorMessage.setSpan(ForegroundColorSpan(-0x77777778), errorMsg.length, total.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
// builder.setMessage(errorMessage)
// } else builder.setMessage(R.string.download_error_error_unknown)
//
// builder.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, _: Int -> dialog.cancel() }
//// if (intent.getBooleanExtra(ARG_WAS_MANUAL_URL, false)) {
//// builder.setNeutralButton(R.string.edit_url_menu) { _: DialogInterface?, _: Int -> editUrl() }
//// }
// builder.setOnCancelListener {
//// setResult(RESULT_ERROR)
//// finish()
// }
// if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
// dialog = builder.show()
// }
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
private fun doParseFeed(destination: String): FeedHandler.FeedHandlerResult? {
val destinationFile = File(destination)
@ -302,7 +238,7 @@ class DirectSubscribe(val context: Context) {
var type: String? = null
try { type = connection.contentType } catch (e: IOException) {
Log.e(TAG, "Error connecting to URL", e)
showErrorDialog(e.message, "")
showError(e.message, "")
} finally { connection.disconnect() }
if (type == null) return null
Logd(TAG, "connection type: $type")
@ -313,7 +249,7 @@ class DirectSubscribe(val context: Context) {
}
}
private fun subscribe(feed: Feed) {
fun subscribe(feed: Feed) {
feed.id = 0L
for (item in feed.episodes) {
item.id = 0L
@ -326,13 +262,4 @@ class DirectSubscribe(val context: Context) {
val fo = updateFeed(context, feed, false)
Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}")
}
// private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) :
// AuthenticationDialog(context, titleRes, true, username, password) {
// override fun onConfirmed(username: String, password: String) {
// this@TestSubscribe.username = username
// this@TestSubscribe.password = password
// startFeedBuilding(feedUrl)
// }
// }
}

View File

@ -172,12 +172,10 @@ class FeedHandler {
state.tagstack.push(element)
}
}
@Throws(SAXException::class)
override fun characters(ch: CharArray, start: Int, length: Int) {
if (state.tagstack.size >= 2 && state.contentBuf != null) state.contentBuf!!.appendRange(ch, start, start + length)
}
@Throws(SAXException::class)
override fun endElement(uri: String, localName: String, qualifiedName: String) {
val handler = getHandlingNamespace(uri, qualifiedName)
@ -187,12 +185,10 @@ class FeedHandler {
}
state.contentBuf = null
}
@Throws(SAXException::class)
override fun endPrefixMapping(prefix: String) {
if (state.defaultNamespaces.size > 1 && prefix == DEFAULT_PREFIX) state.defaultNamespaces.pop()
}
@Throws(SAXException::class)
override fun startPrefixMapping(prefix: String, uri: String) {
// Find the right namespace
@ -235,13 +231,11 @@ class FeedHandler {
}
}
}
private fun getHandlingNamespace(uri: String, qualifiedName: String): Namespace? {
var handler = state.namespaces[uri]
if (handler == null && !state.defaultNamespaces.empty() && !qualifiedName.contains(":")) handler = state.defaultNamespaces.peek()
return handler
}
@Throws(SAXException::class)
override fun endDocument() {
super.endDocument()
@ -267,16 +261,13 @@ class FeedHandler {
else -> "Type $type not supported"
}
}
constructor(type: Type) : super() {
this.type = type
}
constructor(type: Type, rootElement: String?) {
this.type = type
this.rootElement = rootElement
}
constructor(message: String?) {
this.message = message
type = Type.INVALID

View File

@ -454,6 +454,39 @@ object Feeds {
upsertBlk(feed) {}
}
private fun getMiscSyndicate(): Feed {
var feedId: Long = 11
var feed = getFeed(feedId, true)
if (feed != null) return feed
feed = Feed()
feed.id = feedId
feed.title = "Misc Syndicate"
feed.type = Feed.FeedType.RSS.name
feed.downloadUrl = null
feed.fileUrl = File(feedfilePath, getFeedfileName(feed)).toString()
feed.preferences = FeedPreferences(feed.id, false, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, "", "")
feed.preferences!!.keepUpdated = false
feed.preferences!!.queueId = -2L
// feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY
upsertBlk(feed) {}
return feed
}
fun addToMiscSyndicate(episode: Episode) {
val feed = getMiscSyndicate()
Logd(TAG, "addToMiscSyndicate: feed: ${feed.title}")
if (searchEpisodeByIdentifyingValue(feed.episodes, episode) != null) return
Logd(TAG, "addToMiscSyndicate adding new episode: ${episode.title}")
episode.feed = feed
episode.id = Feed.newId()
episode.feedId = feed.id
episode.media?.id = episode.id
upsertBlk(episode) {}
feed.episodes.add(episode)
upsertBlk(feed) {}
}
/**
* Compares the pubDate of two FeedItems for sorting in reverse order
*/

View File

@ -27,7 +27,7 @@ class Episode : RealmObject {
var id: Long = 0L // increments from Date().time * 100 at time of creation
/**
* The id/guid that can be found in the rss/atom feed. Might not be set.
* The id/guid that can be found in the rss/atom feed. Might not be set, especially in youtube feeds
*/
@Index
var identifier: String? = null
@ -138,6 +138,9 @@ class Episode : RealmObject {
@Ignore
val downloadState = mutableIntStateOf(if (media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
@Ignore
val isRemote = mutableStateOf(false)
@Ignore
val stopMonitoring = mutableStateOf(false)

View File

@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
private val TAG = "AppTheme"
private const val TAG = "AppTheme"
val Typography = Typography(
displayLarge = TextStyle(
@ -65,32 +65,133 @@ fun getSecondaryColor(context: Context): Color {
return Color(getColorFromAttr(context, R.attr.colorSecondary))
}
val md_theme_light_primary = Color(0xFF825500)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDDB3)
val md_theme_light_onPrimaryContainer = Color(0xFF291800)
val md_theme_light_secondary = Color(0xFF6F5B40)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFBDEBC)
val md_theme_light_onSecondaryContainer = Color(0xFF271904)
val md_theme_light_tertiary = Color(0xFF51643F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFD4EABB)
val md_theme_light_onTertiaryContainer = Color(0xFF102004)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1F1B16)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1F1B16)
val md_theme_light_surfaceVariant = Color(0xFFF0E0CF)
val md_theme_light_onSurfaceVariant = Color(0xFF4F4539)
val md_theme_light_outline = Color(0xFF817567)
val md_theme_light_inverseOnSurface = Color(0xFFF9EFE7)
val md_theme_light_inverseSurface = Color(0xFF34302A)
val md_theme_light_inversePrimary = Color(0xFFFFB951)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF825500)
val md_theme_light_outlineVariant = Color(0xFFD3C4B4)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFFFB951)
val md_theme_dark_onPrimary = Color(0xFF452B00)
val md_theme_dark_primaryContainer = Color(0xFF633F00)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDDB3)
val md_theme_dark_secondary = Color(0xFFDDC2A1)
val md_theme_dark_onSecondary = Color(0xFF3E2D16)
val md_theme_dark_secondaryContainer = Color(0xFF56442A)
val md_theme_dark_onSecondaryContainer = Color(0xFFFBDEBC)
val md_theme_dark_tertiary = Color(0xFFB8CEA1)
val md_theme_dark_onTertiary = Color(0xFF243515)
val md_theme_dark_tertiaryContainer = Color(0xFF3A4C2A)
val md_theme_dark_onTertiaryContainer = Color(0xFFD4EABB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1F1B16)
val md_theme_dark_onBackground = Color(0xFFEAE1D9)
val md_theme_dark_surface = Color(0xFF1F1B16)
val md_theme_dark_onSurface = Color(0xFFEAE1D9)
val md_theme_dark_surfaceVariant = Color(0xFF4F4539)
val md_theme_dark_onSurfaceVariant = Color(0xFFD3C4B4)
val md_theme_dark_outline = Color(0xFF9C8F80)
val md_theme_dark_inverseOnSurface = Color(0xFF1F1B16)
val md_theme_dark_inverseSurface = Color(0xFFEAE1D9)
val md_theme_dark_inversePrimary = Color(0xFF825500)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB951)
val md_theme_dark_outlineVariant = Color(0xFF4F4539)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF825500)
val LightColors = lightColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF3700B3),
tertiary = Color(0xFF03DAC6),
background = Color(0xFFFFFFFF),
surface = Color(0xFFFFFFFF),
error = Color(0xFFB00020),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onBackground = Color(0xFF000000),
onSurface = Color(0xFF000000),
onError = Color(0xFFFFFFFF)
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
val DarkColors = darkColorScheme(
primary = Color(0xFFBB86FC),
secondary = Color(0xFF3700B3),
tertiary = Color(0xFF03DAC6),
background = Color(0xFF121212),
surface = Color(0xFF121212),
error = Color(0xFFCF6679),
onPrimary = Color(0xFF000000),
onSecondary = Color(0xFF000000),
onBackground = Color(0xFFFFFFFF),
onSurface = Color(0xFFFFFFFF),
onError = Color(0xFF000000)
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable

View File

@ -8,6 +8,7 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.MediaPlayerBase.Companion.status
import ac.mdiq.podcini.storage.database.Episodes
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Feeds.addToMiscSyndicate
import ac.mdiq.podcini.storage.database.Queues
import ac.mdiq.podcini.storage.database.Queues.removeFromQueue
import ac.mdiq.podcini.storage.database.RealmDB.realm
@ -36,6 +37,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@ -100,7 +102,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, modifier: Modifier = Modifier) {
val TAG = "EpisodeSpeedDial ${selected.size}"
var isExpanded by remember { mutableStateOf(false) }
val options = listOf<@Composable () -> Unit>(
val options = mutableListOf<@Composable () -> Unit>(
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
@ -184,6 +186,20 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
Text(stringResource(id = R.string.toggle_favorite_label))
} },
)
if (selected.isNotEmpty() && selected[0].isRemote.value)
options.add({ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "reserve: ${selected.size}")
CoroutineScope(Dispatchers.IO).launch {
for (e in selected) { addToMiscSyndicate(e) }
}
}, verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.AddCircle, "")
Text(stringResource(id = R.string.reserve_episodes_label))
} })
val scrollState = rememberScrollState()
Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) {
@ -227,6 +243,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
when (changes) {
is UpdatedObject -> {
Logd(TAG, "episodeMonitor UpdatedObject $index ${changes.obj.title} ${changes.changedFields.joinToString()}")
Logd(TAG, "episodeMonitor $index ${changes.obj.id} ${episodes[index].id} ${episode.id}")
if (index < episodes.size && episodes[index].id == changes.obj.id) {
playedState = changes.obj.isPlayed()
farvoriteState = changes.obj.isFavorite
@ -314,7 +331,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
else selected.remove(episodes[index])
}
val textColor = MaterialTheme.colorScheme.onSurface
Row (Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.surface)) {
Row (Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) {
if (false) {
val typedValue = TypedValue()
LocalContext.current.theme.resolveAttribute(R.attr.dragview_background, typedValue, true)
@ -452,9 +469,11 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList<Episod
Icon(painter = painterResource(selectAllRes), tint = Color.Black, contentDescription = null, modifier = Modifier.width(35.dp).height(35.dp)
.clickable(onClick = {
if (selectedSize != episodes.size) {
for (e in episodes) {
selected.add(e)
}
selected.clear()
selected.addAll(episodes)
// for (e in episodes) {
// selected.add(e)
// }
selectAllRes = R.drawable.ic_select_none
} else {
selected.clear()

View File

@ -1,10 +1,11 @@
package ac.mdiq.podcini.ui.compose
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.feed.DirectSubscribe
import ac.mdiq.podcini.net.feed.FeedBuilder
import ac.mdiq.podcini.net.feed.discovery.PodcastSearchResult
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.fragment.OnlineFeedFragment
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.MiscFormatter.formatNumber
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
@ -48,9 +49,11 @@ fun OnlineFeedItem(activity: MainActivity, feed: PodcastSearchResult) {
Button(onClick = {
CoroutineScope(Dispatchers.IO).launch {
if (feed.feedUrl != null) {
val subscribe = DirectSubscribe(activity)
subscribe.feedSource = feed.source
subscribe.startFeedBuilding(feed.feedUrl)
val feedBuilder = FeedBuilder(activity) {
message, details -> Logd("OnineFeedItem", "Subscribe error: $message \n $details")
}
feedBuilder.feedSource = feed.source
feedBuilder.startFeedBuilding(feed.feedUrl, "", "") { feed, _ -> feedBuilder.subscribe(feed)}
}
}
onDismissRequest()

View File

@ -57,13 +57,11 @@ import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -108,7 +106,9 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var controller: ServiceStatusHandler? = null
private var prevMedia: Playable? = null
private var currentMedia: Playable? = null
private var currentMedia by mutableStateOf<Playable?>(null)
private var prevItem: Episode? = null
private var currentItem: Episode? = null
private var isShowPlay: Boolean = true
@ -126,8 +126,6 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private var shownotesCleaner: ShownotesCleaner? = null
private var prevItem: Episode? = null
private var currentItem: Episode? = null
private var displayedChapterIndex = -1
private var cleanedNotes by mutableStateOf<String?>(null)
@ -171,8 +169,9 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
binding.composeDetailView.setContent {
CustomTheme(requireContext()) {
if (!isCollapsed) DetailUI()
else Spacer(modifier = Modifier.size(0.dp))
DetailUI()
// if (!isCollapsed) DetailUI()
// else Spacer(modifier = Modifier.size(0.dp))
}
}
binding.composeView2.setContent {
@ -198,24 +197,29 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PlayerUI() {
Column(modifier = Modifier.fillMaxWidth().height(133.dp)) {
Column(modifier = Modifier.fillMaxWidth()) {
val textColor = MaterialTheme.colorScheme.onSurface
Text(titleText, maxLines = 1, color = textColor, style = MaterialTheme.typography.bodyMedium)
Slider(value = sliderValue, valueRange = 0f..duration.toFloat(), modifier = Modifier.height(15.dp),
Slider(value = sliderValue, valueRange = 0f..duration.toFloat(),
// colors = SliderDefaults.colors(
// thumbColor = MaterialTheme.colorScheme.secondary,
// activeTrackColor = MaterialTheme.colorScheme.secondary,
// inactiveTrackColor = Color.Gray,
// ),
modifier = Modifier.height(12.dp).padding(top = 2.dp, bottom = 2.dp),
onValueChange = {
Logd(TAG, "Slider onValueChange: $it")
sliderValue = it
}, onValueChangeFinished = {
Logd(TAG, "Slider onValueChangeFinished: $sliderValue")
// sliderValue = tempSliderValue
currentPosition = sliderValue.toInt()
if (playbackService?.isServiceReady() == true) seekTo(currentPosition)
})
Row {
Text(DurationConverter.getDurationStringLong(currentPosition), color = textColor, style = MaterialTheme.typography.bodyMedium)
Text(DurationConverter.getDurationStringLong(currentPosition), color = textColor, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.weight(1f))
showTimeLeft = UserPreferences.shouldShowRemainingTime()
Text(txtvLengtTexth, color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.clickable {
Text(txtvLengtTexth, color = textColor, style = MaterialTheme.typography.bodySmall, modifier = Modifier.clickable {
if (controller == null) return@clickable
showTimeLeft = !showTimeLeft
UserPreferences.setShowRemainTimeSetting(showTimeLeft)
@ -228,7 +232,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (playbackService == null) PlaybackServiceStarter(requireContext(), curMedia!!).start()
}
AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(80.dp).height(80.dp).padding(start = 5.dp)
modifier = Modifier.width(70.dp).height(70.dp).padding(start = 5.dp)
.clickable(onClick = {
Logd(TAG, "icon clicked!")
Logd(TAG, "playerUiFragment was clicked")
@ -254,7 +258,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.width(48.dp).height(48.dp).clickable(onClick = {
VariableSpeedDialog.newInstance(booleanArrayOf(true, true, true), null)?.show(childFragmentManager, null)
}))
Text(txtvPlaybackSpeed, color = textColor, style = MaterialTheme.typography.bodyMedium)
Text(txtvPlaybackSpeed, color = textColor, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.weight(0.1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -267,7 +271,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}, onLongClick = {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_REWIND)
}))
Text(NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()), color = textColor, style = MaterialTheme.typography.bodyMedium)
Text(NumberFormat.getInstance().format(UserPreferences.rewindSecs.toLong()), color = textColor, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.weight(0.1f))
Icon(painter = painterResource(playButRes), tint = textColor, contentDescription = "play",
@ -299,7 +303,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}, onLongClick = {
SkipPreferenceDialog.showSkipPreference(requireContext(), SkipPreferenceDialog.SkipDirection.SKIP_FORWARD)
}))
Text(NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()), color = textColor, style = MaterialTheme.typography.bodyMedium)
Text(NumberFormat.getInstance().format(UserPreferences.fastForwardSecs.toLong()), color = textColor, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.weight(0.1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -321,7 +325,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}, onLongClick = {
activity?.sendBroadcast(MediaButtonReceiver.createIntent(requireContext(), KeyEvent.KEYCODE_MEDIA_NEXT))
}))
if (UserPreferences.speedforwardSpeed > 0.1f) Text(NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed), color = textColor, style = MaterialTheme.typography.bodyMedium)
if (UserPreferences.speedforwardSpeed > 0.1f) Text(NumberFormat.getInstance().format(UserPreferences.speedforwardSpeed), color = textColor, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.weight(0.1f))
}
@ -377,13 +381,14 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
AndroidView(modifier = Modifier.fillMaxSize(), factory = { context ->
ShownotesWebView(context).apply {
setTimecodeSelectedListener { time: Int -> seekTo(time) }
setPageFinishedListener {
// Restoring the scroll position might not always work
postDelayed({ restoreFromPreference() }, 50)
// setPageFinishedListener {
// // Restoring the scroll position might not always work
// postDelayed({ restoreFromPreference() }, 50)
// }
}
}
}, update = {
it.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank")
}, update = { webView ->
Logd(TAG, "AndroidView update: $cleanedNotes")
webView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank")
})
if (chapterControlVisible) {
Row {
@ -446,7 +451,6 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@UnstableApi
fun updateUi(media: Playable) {
Logd(TAG, "updateUi called $media")
// if (media == null) return
titleText = media.getEpisodeTitle()
// (activity as MainActivity).setPlayerVisible(true)
onPositionUpdate(FlowEvent.PlaybackPositionEvent(media, media.getPosition(), media.getDuration()))
@ -482,13 +486,12 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
prevMedia = media
}
internal fun updateInfo() {
internal fun updateDetails() {
// if (isLoading) return
lifecycleScope.launch {
Logd(TAG, "in updateInfo")
isLoading = true
withContext(Dispatchers.IO) {
if (currentItem == null) {
currentMedia = curMedia
if (currentMedia != null && currentMedia is EpisodeMedia) {
val episodeMedia = currentMedia as EpisodeMedia
@ -496,10 +499,10 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
showHomeText = false
homeText = null
}
}
if (currentItem != null) {
currentMedia = currentItem!!.media
if (prevItem?.identifier != currentItem!!.identifier) cleanedNotes = null
if (prevItem?.identifyingValue != currentItem!!.identifyingValue) cleanedNotes = null
Logd(TAG, "updateInfo ${cleanedNotes == null} ${prevItem?.identifyingValue} ${currentItem!!.identifyingValue}")
if (cleanedNotes == null) {
Logd(TAG, "calling load description ${currentItem!!.description==null} ${currentItem!!.title}")
cleanedNotes = shownotesCleaner?.processShownotes(currentItem?.description ?: "", currentMedia?.getDuration()?:0)
@ -693,14 +696,14 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun onExpanded() {
Logd(TAG, "onExpanded()")
// the function can also be called from MainActivity when a select menu pops up and closes
if (isCollapsed) {
// if (isCollapsed) {
isCollapsed = false
if (shownotesCleaner == null) shownotesCleaner = ShownotesCleaner(requireContext())
showPlayer1 = false
if (currentMedia != null) updateUi(currentMedia!!)
setIsShowPlay(isShowPlay)
updateInfo()
}
updateDetails()
// }
}
fun onCollaped() {
@ -740,7 +743,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (!loadItemsRunning) {
loadItemsRunning = true
if (!actMain.isPlayerVisible()) actMain.setPlayerVisible(true)
if (!isCollapsed && (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier())) updateInfo()
if (!isCollapsed && (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier())) updateDetails()
if (currentMedia == null || curMedia?.getIdentifier() != currentMedia?.getIdentifier() || (includingChapters && !curMedia!!.chaptersLoaded())) {
Logd(TAG, "loadMediaInfo loading details ${curMedia?.getIdentifier()} chapter: $includingChapters")
@ -753,7 +756,8 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
currentMedia = curMedia
val item = (currentMedia as? EpisodeMedia)?.episodeOrFetch()
if (item != null) setItem(item)
updateUi()
setChapterDividers()
setupOptionsMenu()
if (currentMedia != null) updateUi(currentMedia!!)
// TODO: disable for now
// if (!includingChapters) loadMediaInfo(true)
@ -767,7 +771,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private fun setItem(item_: Episode) {
Logd(TAG, "setItem ${item_.title}")
if (currentItem?.identifier != item_.identifier) {
if (currentItem?.identifyingValue != item_.identifyingValue) {
currentItem = item_
showHomeText = false
homeText = null
@ -782,7 +786,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
override fun loadMediaInfo() {
this@AudioPlayerFragment.loadMediaInfo(false)
if (!isCollapsed) updateInfo()
if (!isCollapsed) updateDetails()
}
override fun onPlaybackEnd() {
// isShowPlay = true
@ -792,12 +796,6 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
}
private fun updateUi() {
Logd(TAG, "updateUi called")
setChapterDividers()
setupOptionsMenu()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
@ -918,60 +916,6 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (curEpisode?.id == event.episode.id) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode)
}
// override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
// if (controller == null) return
// when {
// fromUser -> {
// val prog: Float = progress / (seekBar.max.toFloat())
// val converter = TimeSpeedConverter(curSpeedFB)
// val position: Int = converter.convert((prog * curDurationFB).toInt())
// val newChapterIndex: Int = ChapterUtils.getCurrentChapterIndex(curMedia, position)
//// if (newChapterIndex > -1) {
//// if (!sbPosition.isPressed && currentChapterIndex != newChapterIndex) {
//// currentChapterIndex = newChapterIndex
//// val media = getMedia
//// position = media?.getChapters()?.get(currentChapterIndex)?.start?.toInt() ?: 0
//// seekedToChapterStart = true
//// seekTo(position)
//// updateUi(controller!!.getMedia)
//// sbPosition.highlightCurrentChapter()
//// }
//// binding.txtvSeek.text = curMedia?.getChapters()?.get(newChapterIndex)?.title ?: ("\n${DurationConverter.getDurationStringLong(position)}")
//// } else binding.txtvSeek.text = DurationConverter.getDurationStringLong(position)
// }
// curDurationFB != playbackService?.curDuration -> updateUi()
// }
// }
// override fun onStartTrackingTouch(seekBar: SeekBar) {
// // interrupt position Observer, restart later
// cardViewSeek.scaleX = .8f
// cardViewSeek.scaleY = .8f
// cardViewSeek.animate()
// ?.setInterpolator(FastOutSlowInInterpolator())
// ?.alpha(1f)?.scaleX(1f)?.scaleY(1f)
// ?.setDuration(200)
// ?.start()
// }
// override fun onStopTrackingTouch(seekBar: SeekBar) {
// if (controller != null) {
// if (seekedToChapterStart) {
// seekedToChapterStart = false
// } else {
// val prog: Float = seekBar.progress / (seekBar.max.toFloat())
// seekTo((prog * curDurationFB).toInt())
// }
// }
// cardViewSeek.scaleX = 1f
// cardViewSeek.scaleY = 1f
// cardViewSeek.animate()
// ?.setInterpolator(FastOutSlowInInterpolator())
// ?.alpha(0f)?.scaleX(.8f)?.scaleY(.8f)
// ?.setDuration(200)
// ?.start()
// }
private fun setupOptionsMenu() {
if (toolbar.menu.size() == 0) toolbar.inflateMenu(R.menu.mediaplayer)

View File

@ -861,8 +861,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
fun newInstance(item: Episode): EpisodeHomeFragment {
val fragment = EpisodeHomeFragment()
Logd(TAG, "item.itemIdentifier ${item.identifier}")
if (item.identifier != episode?.identifier) episode = item
Logd(TAG, "item.identifyingValue ${item.identifyingValue}")
if (item.identifyingValue != episode?.identifyingValue) episode = item
return fragment
}
}

View File

@ -175,7 +175,7 @@ import kotlin.math.min
fun clearHistory() : Job {
Logd(TAG, "clearHistory called")
return runOnIOScope {
val episodes = realm.query(Episode::class).query("media.playbackCompletionTime > 0").find()
val episodes = realm.query(Episode::class).query("media.playbackCompletionTime > 0 || media.lastPlayedTime > 0").find()
for (e in episodes) {
upsert(e) {
it.media?.playbackCompletionDate = null

View File

@ -1,42 +1,24 @@
package ac.mdiq.podcini.ui.fragment
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.EditTextDialogBinding
import ac.mdiq.podcini.databinding.OnlineFeedviewFragmentBinding
import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.download.service.DownloadRequestCreator.create
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.net.download.service.Downloader
import ac.mdiq.podcini.net.download.service.HttpDownloader
import ac.mdiq.podcini.net.feed.FeedBuilder
import ac.mdiq.podcini.net.feed.FeedUrlNotFoundException
import ac.mdiq.podcini.net.feed.discovery.CombinedSearcher
import ac.mdiq.podcini.net.feed.discovery.PodcastSearcherRegistry
import ac.mdiq.podcini.net.feed.parser.FeedHandler
import ac.mdiq.podcini.net.utils.HtmlToPlainText
import ac.mdiq.podcini.net.utils.UrlChecker.prepareUrl
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.Episodes.episodeFromStreamInfoItem
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.Feeds.updateFeed
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.FilesUtils.feedfilePath
import ac.mdiq.podcini.storage.utils.FilesUtils.getFeedfileName
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.dialog.AuthenticationDialog
import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.error.DownloadErrorLabel.from
import ac.mdiq.vista.extractor.InfoItem
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.channel.ChannelInfo
import ac.mdiq.vista.extractor.channel.tabs.ChannelTabInfo
import ac.mdiq.vista.extractor.exceptions.ExtractionException
import ac.mdiq.vista.extractor.playlist.PlaylistInfo
import ac.mdiq.vista.extractor.stream.StreamInfoItem
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
@ -54,7 +36,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.annotation.UiThread
import androidx.collection.ArrayMap
@ -64,16 +45,12 @@ import androidx.media3.common.util.UnstableApi
import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.types.RealmList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.Volatile
/**
@ -92,7 +69,8 @@ class OnlineFeedFragment : Fragment() {
private var displayUpArrow = false
var feedSource: String = ""
var feedUrl: String = ""
private var feedUrl: String = ""
private lateinit var feedBuilder: FeedBuilder
private val feedId: Long
get() {
@ -106,7 +84,7 @@ class OnlineFeedFragment : Fragment() {
@Volatile
private var feeds: List<Feed>? = null
private var selectedDownloadUrl: String? = null
private var downloader: Downloader? = null
// private var downloader: Downloader? = null
private var username: String? = null
private var password: String? = null
@ -129,6 +107,9 @@ class OnlineFeedFragment : Fragment() {
feedUrl = requireArguments().getString(ARG_FEEDURL) ?: ""
Logd(TAG, "feedUrl: $feedUrl")
feedBuilder = FeedBuilder(requireContext()) { message, details -> showErrorDialog(message, details) }
if (feedUrl.isEmpty()) {
Log.e(TAG, "feedUrl is null.")
showNoPodcastFoundError()
@ -146,9 +127,6 @@ class OnlineFeedFragment : Fragment() {
return binding.root
}
/**
* Displays a progress indicator.
*/
private fun setLoadingLayout() {
binding.progressBar.visibility = View.VISIBLE
binding.feedDisplayContainer.visibility = View.GONE
@ -164,7 +142,7 @@ class OnlineFeedFragment : Fragment() {
super.onStop()
isPaused = true
cancelFlowEvents()
if (downloader != null && !downloader!!.isFinished) downloader!!.cancel()
// if (downloader != null && !downloader!!.isFinished) downloader!!.cancel()
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
}
@ -184,7 +162,12 @@ class OnlineFeedFragment : Fragment() {
private fun lookupUrlAndBuild(url: String) {
lifecycleScope.launch(Dispatchers.IO) {
val urlString = PodcastSearcherRegistry.lookupUrl1(url)
try { startFeedBuilding(urlString)
try {
feeds = getFeedList()
feedBuilder.startFeedBuilding(urlString, username, password) { feed, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map)
}
} catch (e: FeedUrlNotFoundException) { tryToRetrieveFeedUrlBySearch(e)
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
@ -212,7 +195,11 @@ class OnlineFeedFragment : Fragment() {
if (url != null) {
Logd(TAG, "Successfully retrieve feed url")
isFeedFoundBySearch = true
startFeedBuilding(url)
feeds = getFeedList()
feedBuilder.startFeedBuilding(url, username, password) { feed, map ->
selectedDownloadUrl = feedBuilder.selectedDownloadUrl
showFeedInformation(feed, map)
}
} else {
showNoPodcastFoundError()
Logd(TAG, "Failed to retrieve feed url")
@ -232,188 +219,6 @@ class OnlineFeedFragment : Fragment() {
// return null
// }
private fun htmlOrXml(url: String): String? {
val connection = URL(url).openConnection() as HttpURLConnection
var type: String? = null
try { type = connection.contentType } catch (e: IOException) {
Log.e(TAG, "Error connecting to URL", e)
showErrorDialog(e.message, "")
} finally { connection.disconnect() }
if (type == null) return null
Logd(TAG, "connection type: $type")
return when {
type.contains("html", ignoreCase = true) -> "HTML"
type.contains("xml", ignoreCase = true) -> "XML"
else -> type
}
}
private fun startFeedBuilding(url: String) {
if (feedSource == "VistaGuide" || url.contains("youtube.com")) {
feedSource = "VistaGuide"
lifecycleScope.launch(Dispatchers.IO) {
try {
feeds = getFeedList()
val service = try { Vista.getService("YouTube") } catch (e: ExtractionException) { throw ExtractionException("YouTube service not found") }
selectedDownloadUrl = prepareUrl(url)
val feed_ = Feed(selectedDownloadUrl, null)
feed_.id = Feed.newId()
feed_.type = Feed.FeedType.YOUTUBE.name
feed_.hasVideoMedia = true
feed_.fileUrl = File(feedfilePath, getFeedfileName(feed_)).toString()
val eList: RealmList<Episode> = realmListOf()
if (url.startsWith("https://youtube.com/playlist?") || url.startsWith("https://music.youtube.com/playlist?")) {
val playlistInfo = PlaylistInfo.getInfo(Vista.getService(0), url) ?: return@launch
feed_.title = playlistInfo.name
feed_.description = playlistInfo.description?.content ?: ""
feed_.author = playlistInfo.uploaderName
feed_.imageUrl = if (playlistInfo.thumbnails.isNotEmpty()) playlistInfo.thumbnails.first().url else null
var infoItems = playlistInfo.relatedItems
var nextPage = playlistInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}")
while (infoItems.isNotEmpty()) {
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || eList.size > 500) break
try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "PlaylistInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
break
}
}
feed_.episodes = eList
withContext(Dispatchers.Main) { showFeedInformation(feed_, mapOf()) }
} else {
val channelInfo = ChannelInfo.getInfo(service, url)
Logd(TAG, "startFeedBuilding result: $channelInfo ${channelInfo.tabs.size}")
if (channelInfo.tabs.isEmpty()) {
withContext(Dispatchers.Main) { showErrorDialog("Channel is empty", "") }
return@launch
}
try {
val channelTabInfo = ChannelTabInfo.getInfo(service, channelInfo.tabs.first())
Logd(TAG, "startFeedBuilding result1: $channelTabInfo ${channelTabInfo.relatedItems.size}")
feed_.title = channelInfo.name
feed_.description = channelInfo.description
feed_.author = channelInfo.parentChannelName
feed_.imageUrl = if (channelInfo.avatars.isNotEmpty()) channelInfo.avatars.first().url else null
var infoItems = channelTabInfo.relatedItems
var nextPage = channelTabInfo.nextPage
Logd(TAG, "infoItems: ${infoItems.size}")
while (infoItems.isNotEmpty()) {
for (r in infoItems) {
Logd(TAG, "startFeedBuilding relatedItem: $r")
if (r.infoType != InfoItem.InfoType.STREAM) continue
val e = episodeFromStreamInfoItem(r as StreamInfoItem)
e.feed = feed_
e.feedId = feed_.id
eList.add(e)
}
if (nextPage == null || eList.size > 200) break
try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage)
nextPage = page.nextPage
infoItems = page.items
Logd(TAG, "more infoItems: ${infoItems.size}")
} catch (e: Throwable) {
Logd(TAG, "ChannelTabInfo.getMoreItems error: ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
break
}
}
feed_.episodes = eList
withContext(Dispatchers.Main) { showFeedInformation(feed_, mapOf()) }
} catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error1 ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
}
}
} catch (e: Throwable) {
Logd(TAG, "startFeedBuilding error ${e.message}")
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
}
}
return
}
// handle normal podcast source
when (val urlType = htmlOrXml(url)) {
"HTML" -> {
val doc = Jsoup.connect(url).get()
val linkElements = doc.select("link[type=application/rss+xml]")
// TODO: should show all as options
for (element in linkElements) {
val rssUrl = element.attr("href")
Logd(TAG, "RSS URL: $rssUrl")
startFeedBuilding(rssUrl)
return
}
}
"XML" -> {}
else -> {
Log.e(TAG, "unknown url type $urlType")
showErrorDialog("unknown url type $urlType", "")
return
}
}
selectedDownloadUrl = prepareUrl(url)
val request = create(Feed(selectedDownloadUrl, null))
.withAuthentication(username, password)
.withInitiatedByUser(true)
.build()
lifecycleScope.launch(Dispatchers.IO) {
try {
feeds = getFeedList()
downloader = HttpDownloader(request)
downloader?.call()
val status = downloader?.result
when {
request.destination == null || status == null -> return@launch
status.isSuccessful -> {
try {
val result = doParseFeed(request.destination)
if (result != null) withContext(Dispatchers.Main) { showFeedInformation(result.feed, result.alternateFeedUrls) }
} catch (e: Throwable) {
Logd(TAG, "Feed parser exception: " + Log.getStackTraceString(e))
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
}
}
else -> withContext(Dispatchers.Main) {
when {
status.reason == DownloadError.ERROR_UNAUTHORIZED -> {
if (!isRemoving && !isPaused) {
if (username != null && password != null)
Toast.makeText(requireContext(), R.string.download_error_unauthorized, Toast.LENGTH_LONG).show()
if (downloader?.downloadRequest?.source != null) {
dialog = FeedViewAuthenticationDialog(requireContext(), R.string.authentication_notification_title, downloader!!.downloadRequest.source!!).create()
dialog?.show()
}
}
}
else -> showErrorDialog(getString(from(status.reason)), status.reasonDetailed)
}
}
}
} catch (e: Throwable) {
Log.e(TAG, Log.getStackTraceString(e))
withContext(Dispatchers.Main) { showErrorDialog(e.message, "") }
}
}
}
private var eventSink: Job? = null
private var eventStickySink: Job? = null
private fun cancelFlowEvents() {
@ -458,47 +263,6 @@ class OnlineFeedFragment : Fragment() {
}
}
/**
* Try to parse the feed.
* @return The FeedHandlerResult if successful.
* Null if unsuccessful but we started another attempt.
* @throws Exception If unsuccessful but we do not know a resolution.
*/
@Throws(Exception::class)
private fun doParseFeed(destination: String): FeedHandler.FeedHandlerResult? {
val destinationFile = File(destination)
return try {
val feed = Feed(selectedDownloadUrl, null)
feed.fileUrl = destination
FeedHandler().parseFeed(feed)
} catch (e: FeedHandler.UnsupportedFeedtypeException) {
Logd(TAG, "Unsupported feed type detected")
if ("html".equals(e.rootElement, ignoreCase = true)) {
if (selectedDownloadUrl != null) {
// val doc = Jsoup.connect(selectedDownloadUrl).get()
// val linkElements = doc.select("link[type=application/rss+xml]")
// for (element in linkElements) {
// val rssUrl = element.attr("href")
// Log.d(TAG, "RSS URL: $rssUrl")
// val rc = destinationFile.delete()
// Log.d(TAG, "Deleted feed source file. Result: $rc")
// startFeedDownload(rssUrl)
// return null
// }
val dialogShown = showFeedDiscoveryDialog(destinationFile, selectedDownloadUrl!!)
if (dialogShown) null // Should not display an error message
else throw FeedHandler.UnsupportedFeedtypeException(getString(R.string.download_error_unsupported_type_html))
} else null
} else throw e
} catch (e: Exception) {
Log.e(TAG, Log.getStackTraceString(e))
throw e
} finally {
val rc = destinationFile.delete()
Logd(TAG, "Deleted feed source file. Result: $rc")
}
}
/**
* Called when feed parsed successfully.
* This method is executed on the GUI thread.
@ -531,19 +295,7 @@ class OnlineFeedFragment : Fragment() {
else {
lifecycleScope.launch {
binding.progressBar.visibility = View.VISIBLE
withContext(Dispatchers.IO) {
feed.id = 0L
for (item in feed.episodes) {
item.id = 0L
item.media?.id = 0L
item.feedId = null
item.feed = feed
val media = item.media
media?.episode = item
}
val fo = updateFeed(requireContext(), feed, false)
Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}")
}
withContext(Dispatchers.IO) { feedBuilder.subscribe(feed) }
withContext(Dispatchers.Main) {
binding.progressBar.visibility = View.GONE
didPressSubscribe = true
@ -597,6 +349,7 @@ class OnlineFeedFragment : Fragment() {
for (i in 0..<episodes.size) {
episodes[i].id = id_++
episodes[i].media?.id = episodes[i].id
episodes[i].isRemote.value = true
}
val fragment: Fragment = RemoteEpisodesFragment.newInstance(episodes)
(activity as MainActivity).loadChildFragment(fragment)
@ -681,65 +434,67 @@ class OnlineFeedFragment : Fragment() {
}
}
private fun editUrl() {
val builder = MaterialAlertDialogBuilder(requireContext())
builder.setTitle(R.string.edit_url_menu)
val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
if (downloader != null) dialogBinding.editText.setText(downloader!!.downloadRequest.source)
builder.setView(dialogBinding.root)
builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
setLoadingLayout()
lookupUrlAndBuild(dialogBinding.editText.text.toString())
}
builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() }
builder.setOnCancelListener {}
builder.show()
}
// private fun editUrl() {
// val builder = MaterialAlertDialogBuilder(requireContext())
// builder.setTitle(R.string.edit_url_menu)
// val dialogBinding = EditTextDialogBinding.inflate(layoutInflater)
// if (downloader != null) dialogBinding.editText.setText(downloader!!.downloadRequest.source)
//
// builder.setView(dialogBinding.root)
// builder.setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
// setLoadingLayout()
// lookupUrlAndBuild(dialogBinding.editText.text.toString())
// }
// builder.setNegativeButton(R.string.cancel_label) { dialog1: DialogInterface, _: Int -> dialog1.cancel() }
// builder.setOnCancelListener {}
// builder.show()
// }
/**
*
* @return true if a FeedDiscoveryDialog is shown, false otherwise (e.g., due to no feed found).
*/
private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean {
val fd = FeedDiscoverer()
val urlsMap: Map<String, String>
try {
urlsMap = fd.findLinks(feedFile, baseUrl)
if (urlsMap.isEmpty()) return false
} catch (e: IOException) {
e.printStackTrace()
return false
}
if (isRemoving || isPaused) return false
val titles: MutableList<String?> = ArrayList()
val urls: List<String> = ArrayList(urlsMap.keys)
for (url in urls) {
titles.add(urlsMap[url])
}
if (urls.size == 1) {
// Skip dialog and display the item directly
startFeedBuilding(urls[0])
return true
}
val adapter = ArrayAdapter(requireContext(), R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles)
val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int ->
val selectedUrl = urls[which]
dialog.dismiss()
startFeedBuilding(selectedUrl)
}
val ab = MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.feeds_label)
.setCancelable(true)
.setOnCancelListener { _: DialogInterface? ->/* finish() */ }
.setAdapter(adapter, onClickListener)
requireActivity().runOnUiThread {
if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
dialog = ab.show()
}
return true
}
// private fun showFeedDiscoveryDialog(feedFile: File, baseUrl: String): Boolean {
// val fd = FeedDiscoverer()
// val urlsMap: Map<String, String>
// try {
// urlsMap = fd.findLinks(feedFile, baseUrl)
// if (urlsMap.isEmpty()) return false
// } catch (e: IOException) {
// e.printStackTrace()
// return false
// }
//
// if (isRemoving || isPaused) return false
// val titles: MutableList<String?> = ArrayList()
// val urls: List<String> = ArrayList(urlsMap.keys)
// for (url in urls) {
// titles.add(urlsMap[url])
// }
// if (urls.size == 1) {
// // Skip dialog and display the item directly
// feeds = getFeedList()
// subscribe.startFeedBuilding(urls[0]) {feed, map -> showFeedInformation(feed, map) }
// return true
// }
// val adapter = ArrayAdapter(requireContext(), R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles)
// val onClickListener = DialogInterface.OnClickListener { dialog: DialogInterface, which: Int ->
// val selectedUrl = urls[which]
// dialog.dismiss()
// feeds = getFeedList()
// subscribe.startFeedBuilding(selectedUrl) {feed, map -> showFeedInformation(feed, map) }
// }
// val ab = MaterialAlertDialogBuilder(requireContext())
// .setTitle(R.string.feeds_label)
// .setCancelable(true)
// .setOnCancelListener { _: DialogInterface? ->/* finish() */ }
// .setAdapter(adapter, onClickListener)
// requireActivity().runOnUiThread {
// if (dialog != null && dialog!!.isShowing) dialog!!.dismiss()
// dialog = ab.show()
// }
// return true
// }
private fun showNoPodcastFoundError() {
requireActivity().runOnUiThread {
@ -752,14 +507,15 @@ class OnlineFeedFragment : Fragment() {
}
}
private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) :
AuthenticationDialog(context, titleRes, true, username, password) {
override fun onConfirmed(username: String, password: String) {
this@OnlineFeedFragment.username = username
this@OnlineFeedFragment.password = password
startFeedBuilding(feedUrl)
}
}
// private inner class FeedViewAuthenticationDialog(context: Context, titleRes: Int, private val feedUrl: String) :
// AuthenticationDialog(context, titleRes, true, username, password) {
// override fun onConfirmed(username: String, password: String) {
// this@OnlineFeedFragment.username = username
// this@OnlineFeedFragment.password = password
// feeds = getFeedList()
// subscribe.startFeedBuilding(feedUrl) {feed, map -> showFeedInformation(feed, map) }
// }
// }
/**
* Finds RSS/Atom URLs in a HTML document using the auto-discovery techniques described here:
@ -814,9 +570,6 @@ class OnlineFeedFragment : Fragment() {
}
}
/**
* Shows all episodes (possibly filtered by user).
*/
@UnstableApi
class RemoteEpisodesFragment : BaseEpisodesFragment() {
private val episodeList: MutableList<Episode> = mutableListOf()

View File

@ -78,7 +78,6 @@ class SearchResultsFragment : Fragment() {
MainView()
}
}
setupToolbar(binding.toolbar)
// gridView.setOnScrollListener(object : AbsListView.OnScrollListener {

View File

@ -844,18 +844,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
if (isSelected) selected.add(feed)
else selected.remove(feed)
}
Column(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.surface)) {
val textColor = MaterialTheme.colorScheme.onSurface
ConstraintLayout {
val (coverImage, episodeCount, error) = createRefs()
AsyncImage(model = feed.imageUrl, contentDescription = "coverImage",
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier
.constrainAs(coverImage) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
}.combinedClickable(onClick = {
Column(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}")
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
@ -870,13 +860,25 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
longPressIndex = -1
}
Logd(TAG, "long clicked: ${feed.title}")
}))
})) {
val textColor = MaterialTheme.colorScheme.onSurface
ConstraintLayout {
val (coverImage, episodeCount, error) = createRefs()
AsyncImage(model = feed.imageUrl, contentDescription = "coverImage",
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier
.constrainAs(coverImage) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
})
Text(NumberFormat.getInstance().format(feed.episodes.size.toLong()),
modifier = Modifier.constrainAs(episodeCount) {
end.linkTo(parent.end)
top.linkTo(coverImage.top)
})
Icon(painter = painterResource(R.drawable.ic_error),
// TODO: need to use state
if (feed.lastUpdateFailed) Icon(painter = painterResource(R.drawable.ic_error), tint = Color.Red,
contentDescription = "error",
modifier = Modifier.constrainAs(error) {
end.linkTo(parent.end)
@ -906,7 +908,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
else selected.remove(feed)
Logd(TAG, "toggleSelected: selected: ${selected.size}")
}
Row(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.surface)) {
Row(Modifier.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)) {
AsyncImage(model = feed.imageUrl, contentDescription = "imgvCover",
placeholder = painterResource(R.mipmap.ic_launcher),
modifier = Modifier.width(80.dp).height(80.dp)
@ -917,7 +919,7 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
})
)
val textColor = MaterialTheme.colorScheme.onSurface
Column(Modifier.fillMaxWidth().padding(start = 10.dp).combinedClickable(onClick = {
Column(Modifier.weight(1f).padding(start = 10.dp).combinedClickable(onClick = {
Logd(TAG, "clicked: ${feed.title}")
if (selectMode) toggleSelected()
else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id))
@ -948,7 +950,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Text(feedSortInfo, color = textColor, style = MaterialTheme.typography.bodyMedium)
}
}
Icon(painter = painterResource(R.drawable.ic_error), contentDescription = "error")
// TODO: need to use state
if (feed.lastUpdateFailed) Icon(painter = painterResource(R.drawable.ic_error), tint = Color.Red, contentDescription = "error")
}
}
}
@ -989,9 +992,11 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
modifier = Modifier.width(35.dp).height(35.dp)
.clickable(onClick = {
if (selectedSize != feedListFiltered.size) {
for (e in feedListFiltered) {
selected.add(e)
}
selected.clear()
selected.addAll(feedListFiltered)
// for (e in feedListFiltered) {
// selected.add(e)
// }
selectAllRes = R.drawable.ic_select_none
} else {
selected.clear()

View File

@ -239,6 +239,7 @@
<string name="play_label">Play</string>
<string name="pause_label">Pause</string>
<string name="stream_label">Stream</string>
<string name="reserve_episodes_label">Reserve episodes</string>
<string name="delete_label">Delete</string>
<string name="delete_failed">Unable to delete file. Rebooting the device could help.</string>
<string name="delete_local_failed">Unable to delete file. Try re-connecting the local folder from the podcast info screen.</string>

View File

@ -1,6 +1,18 @@
# 6.8.7
* clear history really clears it
* fixed deselect all in episodes and podcasts lists
* consolidated OnlineFeed and SearchResults classes to use the common FeedBuilder class
* cleared the error icon on subscription grid
* in Grid view of Subscriptions, click and long-click is received on the entire block of a podcast
* in episodes list of an online feed (unsubscribed), multi-selection of episodes now allows to reserve them
* once reserved, the episodes are added to a synthetic podcast named "Misc Syndicate"
* fixed PlayerDetailed view showing wrong information or even crashing
* tuned Material3 colorscheme
# 6.8.6
* Queues Bin view now has separate swipe actions indipendent from the Queues view
* Queues Bin view now has separate swipe actions independent from the Queues view
* SearchResults and Discovery fragments are in Jetpack Compose
* in online search result list, long pressing on a feed will pop up dialog to confirm direct subscription
* fixed crash when clearing history

View File

@ -0,0 +1,11 @@
Version 6.8.7
* clear history really clears it
* fixed deselect all in episodes and podcasts lists
* consolidated OnlineFeed and SearchResults classes to use the common FeedBuilder class
* cleared the error icon on subscription grid
* in Grid view of Subscriptions, click and long-click is received on the entire block of a podcast
* in episodes list of an online feed (unsubscribed), multi-selection of episodes now allows to reserve them
* once reserved, the episodes are added to a synthetic podcast named "Misc Syndicate"
* fixed PlayerDetailed view showing wrong information or even crashing
* tuned Material3 colorscheme