diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/DirectSubscribe.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt similarity index 68% rename from app/src/main/kotlin/ac/mdiq/podcini/net/feed/DirectSubscribe.kt rename to app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt index 3d3e8088..83857bdc 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/DirectSubscribe.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt @@ -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? = 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)->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) -// } -// } } \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt index cdb2b38c..fc9b6956 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandler.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt index d323717a..e609b6ca 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/database/Feeds.kt @@ -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 */ diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt index cf97b7e5..cf6b0f28 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Episode.kt @@ -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) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt index 8905748b..7495d6d2 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/AppTheme.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt index 44e35099..260d9dc6 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/Episodes.kt @@ -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, 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 { 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 - episodes[index] = changes.obj // direct assignment doesn't update member like media?? + episodes[index] = changes.obj // direct assignment doesn't update member like media?? changes.obj.copyStates(episodes[index]) // remove action could possibly conflict with the one in mediaMonitor // episodes.removeAt(index) @@ -314,7 +331,7 @@ fun EpisodeLazyColumn(activity: MainActivity, episodes: SnapshotStateList Logd("OnineFeedItem", "Subscribe error: $message \n $details") + } + feedBuilder.feedSource = feed.source + feedBuilder.startFeedBuilding(feed.feedUrl, "", "") { feed, _ -> feedBuilder.subscribe(feed)} } } onDismissRequest() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt index 2ef7c5fb..f1b82eca 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/AudioPlayerFragment.kt @@ -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(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(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,24 +486,23 @@ 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 - currentItem = episodeMedia.episodeOrFetch() - showHomeText = false - homeText = null - } + currentMedia = curMedia + if (currentMedia != null && currentMedia is EpisodeMedia) { + val episodeMedia = currentMedia as EpisodeMedia + currentItem = episodeMedia.episodeOrFetch() + 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) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt index 133c6fac..8c8b417a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/EpisodeInfoFragment.kt @@ -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 } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt index 9557810f..0a7f0f9a 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/HistoryFragment.kt @@ -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 diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt index e5e52045..aad357df 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/OnlineFeedFragment.kt @@ -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? = 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 = 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.. - 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 - 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 = ArrayList() - val urls: List = 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 +// 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 = ArrayList() +// val urls: List = 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 = mutableListOf() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt index 52b647d2..3c74ac0c 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SearchResultsFragment.kt @@ -78,7 +78,6 @@ class SearchResultsFragment : Fragment() { MainView() } } - setupToolbar(binding.toolbar) // gridView.setOnScrollListener(object : AbsListView.OnScrollListener { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt index 94ccf4c5..f138bd93 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/SubscriptionsFragment.kt @@ -844,7 +844,23 @@ 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)) { + 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)) + }, onLongClick = { + selectMode = !selectMode + isSelected = selectMode + if (selectMode) { + selected.add(feed) + longPressIndex = index + } else { + selectedSize = 0 + longPressIndex = -1 + } + Logd(TAG, "long clicked: ${feed.title}") + })) { val textColor = MaterialTheme.colorScheme.onSurface ConstraintLayout { val (coverImage, episodeCount, error) = createRefs() @@ -855,28 +871,14 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { top.linkTo(parent.top) bottom.linkTo(parent.bottom) start.linkTo(parent.start) - }.combinedClickable(onClick = { - Logd(TAG, "clicked: ${feed.title}") - if (selectMode) toggleSelected() - else (activity as MainActivity).loadChildFragment(FeedEpisodesFragment.newInstance(feed.id)) - }, onLongClick = { - selectMode = !selectMode - isSelected = selectMode - if (selectMode) { - selected.add(feed) - longPressIndex = index - } else { - selectedSize = 0 - longPressIndex = -1 - } - Logd(TAG, "long clicked: ${feed.title}") - })) + }) 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() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca51e93f..76f8ec50 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -239,6 +239,7 @@ Play Pause Stream + Reserve episodes Delete Unable to delete file. Rebooting the device could help. Unable to delete file. Try re-connecting the local folder from the podcast info screen. diff --git a/changelog.md b/changelog.md index c46eea74..7e5fbccd 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/fastlane/metadata/android/en-US/changelogs/3020264.txt b/fastlane/metadata/android/en-US/changelogs/3020264.txt new file mode 100644 index 00000000..b038571e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3020264.txt @@ -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