diff --git a/app/build.gradle b/app/build.gradle index c31b615e..aa0d0c8c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,8 +31,8 @@ android { testApplicationId "ac.mdiq.podcini.tests" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - versionCode 3020270 - versionName "6.11.0" + versionCode 3020271 + versionName "6.11.1" applicationId "ac.mdiq.podcini.R" def commit = "" diff --git a/app/src/androidTest/kotlin/ac/test/podcini/util/syndication/feedgenerator/Rss2Generator.kt b/app/src/androidTest/kotlin/ac/test/podcini/util/syndication/feedgenerator/Rss2Generator.kt index fabfe39b..41460595 100644 --- a/app/src/androidTest/kotlin/ac/test/podcini/util/syndication/feedgenerator/Rss2Generator.kt +++ b/app/src/androidTest/kotlin/ac/test/podcini/util/syndication/feedgenerator/Rss2Generator.kt @@ -1,9 +1,9 @@ package de.test.podcini.util.syndication.feedgenerator +import ac.mdiq.podcini.net.feed.parser.FeedHandler import android.util.Xml import ac.mdiq.podcini.util.MiscFormatter.formatRfc822Date import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.net.feed.parser.namespace.PodcastIndex import de.test.podcini.util.syndication.feedgenerator.GeneratorUtil.addPaymentLink import java.io.IOException import java.io.OutputStream @@ -101,11 +101,11 @@ class Rss2Generator : FeedGenerator { } if (fundingList.isNotEmpty()) { for (funding in fundingList) { - xml.startTag(PodcastIndex.NSTAG, "funding") - xml.attribute(PodcastIndex.NSTAG, "url", funding.url) + xml.startTag(FeedHandler.PodcastIndex.NSTAG, "funding") + xml.attribute(FeedHandler.PodcastIndex.NSTAG, "url", funding.url) xml.text(funding.content) addPaymentLink(xml, funding.url, true) - xml.endTag(PodcastIndex.NSTAG, "funding") + xml.endTag(FeedHandler.PodcastIndex.NSTAG, "funding") } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt index 981fe8a1..be3a6274 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/VistaDownloaderImpl.kt @@ -72,7 +72,6 @@ class VistaDownloaderImpl private constructor(val builder: OkHttpClient.Builder) /** * Get the size of the content that the url is pointing by firing a HEAD request. - * * @param url an url pointing to the content * @return the size of the content, in bytes */ @@ -95,9 +94,7 @@ class VistaDownloaderImpl private constructor(val builder: OkHttpClient.Builder) var requestBody: RequestBody? = null if (dataToSend != null) requestBody = RequestBody.create("application/json; charset=utf-8".toMediaTypeOrNull(), dataToSend) - val requestBuilder: Builder = Builder() - .method(httpMethod, requestBody).url(url) - .addHeader("User-Agent", USER_AGENT) + val requestBuilder: Builder = Builder().method(httpMethod, requestBody).url(url).addHeader("User-Agent", USER_AGENT) val cookies = getCookies(url) if (cookies.isNotEmpty()) requestBuilder.addHeader("Cookie", cookies) @@ -142,7 +139,6 @@ class VistaDownloaderImpl private constructor(val builder: OkHttpClient.Builder) /** * It's recommended to call exactly once in the entire lifetime of the application. - * * @param builder if null, default builder will be used * @return a new instance of [DownloaderImpl] */ diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DefaultDownloaderFactory.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DefaultDownloaderFactory.kt index e63ce079..d1d9f699 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DefaultDownloaderFactory.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DefaultDownloaderFactory.kt @@ -3,6 +3,10 @@ package ac.mdiq.podcini.net.download.service import android.util.Log import android.webkit.URLUtil +interface DownloaderFactory { + fun create(request: DownloadRequest): Downloader? +} + class DefaultDownloaderFactory : DownloaderFactory { override fun create(request: DownloadRequest): Downloader? { if (!URLUtil.isHttpUrl(request.source) && !URLUtil.isHttpsUrl(request.source)) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloaderFactory.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloaderFactory.kt deleted file mode 100644 index 7ae802b1..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/download/service/DownloaderFactory.kt +++ /dev/null @@ -1,5 +0,0 @@ -package ac.mdiq.podcini.net.download.service - -interface DownloaderFactory { - fun create(request: DownloadRequest): Downloader? -} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt index 969564c1..bf599c57 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/FeedBuilder.kt @@ -281,4 +281,5 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit) // } Logd(TAG, "fo.id: ${fo?.id} feed.id: ${feed.id}") } + } \ 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 7bde1530..df44f0b9 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 @@ -1,8 +1,15 @@ package ac.mdiq.podcini.net.feed.parser -import ac.mdiq.podcini.net.feed.parser.namespace.* -import ac.mdiq.podcini.storage.model.Feed +import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseOrNullIfFuture +import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseTimeString +import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis +import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.getMimeType +import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isImageFile +import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isMediaFile +import ac.mdiq.podcini.storage.model.* import ac.mdiq.podcini.util.Logd +import android.util.Log +import androidx.core.text.HtmlCompat import org.apache.commons.io.input.XmlStreamReader import org.jsoup.Jsoup import org.xml.sax.Attributes @@ -16,6 +23,8 @@ import java.io.File import java.io.FileNotFoundException import java.io.IOException import java.io.Reader +import java.util.* +import java.util.concurrent.TimeUnit import javax.xml.parsers.ParserConfigurationException import javax.xml.parsers.SAXParserFactory @@ -153,6 +162,70 @@ class FeedHandler { } } + /** + * Contains all relevant information to describe the current state of a SyndHandler. + * Feed that the Handler is currently processing. + */ + class HandlerState(@JvmField var feed: Feed) { + /** + * Contains links to related feeds, e.g. feeds with enclosures in other formats. The key of the map is the + * URL of the feed, the value is the title + */ + @JvmField + val alternateUrls: MutableMap = HashMap() + @JvmField + var redirectUrl: String? = null + @JvmField + val items: ArrayList = ArrayList() + @JvmField + var currentItem: Episode? = null + @JvmField + var currentFunding: FeedFunding? = null + @JvmField + val tagstack: Stack = Stack() + /** + * Namespaces that have been defined so far. + */ + @JvmField + val namespaces: MutableMap = HashMap() + @JvmField + val defaultNamespaces: Stack = Stack() + /** + * Buffer for saving characters. + */ + @JvmField + var contentBuf: StringBuilder? = null + /** + * Temporarily saved objects. + */ + @JvmField + val tempObjects: MutableMap = HashMap() + /** + * Returns the SyndElement that comes after the top element of the tagstack. + */ + val secondTag: SyndElement + get() { + val top = tagstack.pop() + val second = tagstack.peek() + tagstack.push(top) + return second + } + + val thirdTag: SyndElement + get() { + val top = tagstack.pop() + val second = tagstack.pop() + val third = tagstack.peek() + tagstack.push(second) + tagstack.push(top) + return third + } + + fun addAlternateFeedUrl(title: String, url: String) { + alternateUrls[url] = title + } + } + /** Superclass for all SAX Handlers which process Syndication formats */ class SyndHandler(feed: Feed, type: Type) : DefaultHandler() { @JvmField @@ -282,6 +355,638 @@ class FeedHandler { @JvmField val alternateFeedUrls: Map, val redirectUrl: String) + abstract class Namespace { + /** Called by a Feedhandler when in startElement and it detects a namespace element + * @return The SyndElement to push onto the stack */ + abstract fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement + + /** Called by a Feedhandler when in endElement and it detects a namespace element */ + abstract fun handleElementEnd(localName: String, state: HandlerState) + + /** Trims all whitespace from beginning and ending of a String. {[String.trim]} only trims spaces. */ + fun trimAllWhitespace(string: String): String { + return string.replace("(^\\s*)|(\\s*$)".toRegex(), "") + } + } + + /** Defines a XML Element that is pushed on the tagstack */ + open class SyndElement(@JvmField val name: String, val namespace: Namespace) + + /** Represents Atom Element which contains text (content, title, summary). */ + class AtomText( + name: String, + namespace: Namespace, + private val type: String?) : SyndElement(name, namespace) { + + private var content: String? = null + + val processedContent: String? + /** Processes the content according to the type and returns it. */ + get() = when (type) { + null -> content + TYPE_HTML -> HtmlCompat.fromHtml(content!!, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() + TYPE_XHTML -> content + // Handle as text by default + else -> content + } + + fun setContent(content: String?) { + this.content = content + } + + companion object { + const val TYPE_HTML: String = "html" + private const val TYPE_XHTML = "xhtml" + } + } + + class Atom : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { +// Log.d(TAG, "handleElementStart $localName") + when { + ENTRY == localName -> { + state.currentItem = Episode() + state.items.add(state.currentItem!!) +// state.currentItem!!.feed = state.feed + } + localName.matches(isText.toRegex()) -> { + val type: String? = attributes.getValue(TEXT_TYPE) + return AtomText(localName, this, type) + } + LINK == localName -> { + val href: String? = attributes.getValue(LINK_HREF) + val rel: String? = attributes.getValue(LINK_REL) + val parent = state.tagstack.peek() + when { + parent.name.matches(isFeedItem.toRegex()) -> { + when (rel) { + null, LINK_REL_ALTERNATE -> if (state.currentItem != null) state.currentItem!!.link = href + LINK_REL_ENCLOSURE -> { + val strSize: String? = attributes.getValue(LINK_LENGTH) + var size: Long = 0 + try { if (strSize != null) size = strSize.toLong() } catch (e: NumberFormatException) { Logd(TAG, "Length attribute could not be parsed.") } + val mimeType: String? = getMimeType(attributes.getValue(LINK_TYPE), href) + val currItem = state.currentItem + if (isMediaFile(mimeType) && currItem != null && currItem.media == null) + currItem.media = EpisodeMedia(currItem, href, size, mimeType) + } + LINK_REL_PAYMENT -> if (state.currentItem != null) state.currentItem!!.paymentLink = href + } + } + parent.name.matches(isFeed.toRegex()) -> { + when (rel) { + null, LINK_REL_ALTERNATE -> { + val type: String? = attributes.getValue(LINK_TYPE) +// Use as link if +// a) no type-attribute is given and feed-object has no link yet +// b) type of link is LINK_TYPE_HTML or LINK_TYPE_XHTML + when { + type == null && state.feed.link == null || LINK_TYPE_HTML == type || LINK_TYPE_XHTML == type -> state.feed.link = href + LINK_TYPE_ATOM == type || LINK_TYPE_RSS == type -> { + // treat as podlove alternate feed + var title: String? = attributes.getValue(LINK_TITLE) + if (title.isNullOrEmpty()) title = href?:"" + if (!href.isNullOrEmpty()) state.addAlternateFeedUrl(title, href) + } + } + } + LINK_REL_ARCHIVES -> { + val type: String? = attributes.getValue(LINK_TYPE) + when (type) { + LINK_TYPE_ATOM, LINK_TYPE_RSS -> { + var title: String? = attributes.getValue(LINK_TITLE) + if (title.isNullOrEmpty()) title = href?:"" + if (!href.isNullOrEmpty()) state.addAlternateFeedUrl(title, href) + } + //A Link such as to a directory such as iTunes + LINK_TYPE_HTML, LINK_TYPE_XHTML -> {} + } + } + LINK_REL_PAYMENT -> state.feed.addPayment(FeedFunding(href, "")) + LINK_REL_NEXT -> { + state.feed.isPaged = true + state.feed.nextPageLink = href + } + } + } + } + } + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { +// Log.d(TAG, "handleElementEnd $localName") + if (ENTRY == localName) { + if (state.currentItem != null && state.tempObjects.containsKey(Itunes.DURATION)) { + val currentItem = state.currentItem + if (currentItem!!.media != null) { + val duration = state.tempObjects[Itunes.DURATION] as Int? + if (duration != null) currentItem.media!!.setDuration(duration) + } + state.tempObjects.remove(Itunes.DURATION) + } + state.currentItem = null + } + + if (state.tagstack.size >= 2) { + var textElement: AtomText? = null + val contentRaw = if (state.contentBuf != null) state.contentBuf.toString() else "" + val content = trimAllWhitespace(contentRaw) + val topElement = state.tagstack.peek() + val top = topElement.name + val secondElement = state.secondTag + val second = secondElement.name + + if (top.matches(isText.toRegex())) { + textElement = topElement as AtomText + textElement.setContent(content) + } + when { + ID == top -> { + when { + FEED == second -> state.feed.identifier = contentRaw + ENTRY == second && state.currentItem != null -> state.currentItem!!.identifier = contentRaw + } + } + TITLE == top && textElement != null -> { + when { + FEED == second -> state.feed.title = textElement.processedContent + ENTRY == second && state.currentItem != null -> state.currentItem!!.title = textElement.processedContent + } + } + SUBTITLE == top && FEED == second && textElement != null -> state.feed.description = textElement.processedContent + CONTENT == top && ENTRY == second && textElement != null && state.currentItem != null -> + state.currentItem!!.setDescriptionIfLonger(textElement.processedContent) + SUMMARY == top && ENTRY == second && textElement != null && state.currentItem != null -> + state.currentItem!!.setDescriptionIfLonger(textElement.processedContent) + UPDATED == top && ENTRY == second && state.currentItem != null && state.currentItem!!.pubDate == 0L -> + state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 + PUBLISHED == top && ENTRY == second && state.currentItem != null -> + state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 + IMAGE_LOGO == top && state.feed.imageUrl == null -> state.feed.imageUrl = content + IMAGE_ICON == top -> state.feed.imageUrl = content + AUTHOR_NAME == top && AUTHOR == second && state.currentItem == null -> { + val currentName = state.feed.author + if (currentName == null) state.feed.author = content + else state.feed.author = "$currentName, $content" + } + } + } + } + + companion object { + private val TAG: String = Atom::class.simpleName ?: "Anonymous" + const val NSTAG: String = "atom" + const val NSURI: String = "http://www.w3.org/2005/Atom" + + private const val FEED = "feed" + private const val ID = "id" + private const val TITLE = "title" + private const val ENTRY = "entry" + private const val LINK = "link" + private const val UPDATED = "updated" + private const val AUTHOR = "author" + private const val AUTHOR_NAME = "name" + private const val CONTENT = "content" + private const val SUMMARY = "summary" + private const val IMAGE_LOGO = "logo" + private const val IMAGE_ICON = "icon" + private const val SUBTITLE = "subtitle" + private const val PUBLISHED = "published" + + private const val TEXT_TYPE = "type" + + // Link + private const val LINK_HREF = "href" + private const val LINK_REL = "rel" + private const val LINK_TYPE = "type" + private const val LINK_TITLE = "title" + private const val LINK_LENGTH = "length" + + // rel-values + private const val LINK_REL_ALTERNATE = "alternate" + private const val LINK_REL_ARCHIVES = "archives" + private const val LINK_REL_ENCLOSURE = "enclosure" + private const val LINK_REL_PAYMENT = "payment" + private const val LINK_REL_NEXT = "next" + + // type-values + private const val LINK_TYPE_ATOM = "application/atom+xml" + private const val LINK_TYPE_HTML = "text/html" + private const val LINK_TYPE_XHTML = "application/xml+xhtml" + + private const val LINK_TYPE_RSS = "application/rss+xml" + + /** + * Regexp to test whether an Element is a Text Element. + */ + private const val isText = ("$TITLE|$CONTENT|$SUBTITLE|$SUMMARY") + + private const val isFeed = FEED + "|" + Rss20.CHANNEL + private const val isFeedItem = ENTRY + "|" + Rss20.ITEM + } + } + + class Content : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { + if (ENCODED == localName && state.contentBuf != null) + state.currentItem?.setDescriptionIfLonger(state.contentBuf.toString()) + } + + companion object { + const val NSTAG: String = "content" + const val NSURI: String = "http://purl.org/rss/1.0/modules/content/" + + private const val ENCODED = "encoded" + } + } + + class Itunes : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { + if (IMAGE == localName) { + val url: String? = attributes.getValue(IMAGE_HREF) + if (state.currentItem != null) state.currentItem!!.imageUrl = url + // this is the feed image + // prefer to all other images + else if (!url.isNullOrEmpty()) state.feed.imageUrl = url + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { + if (state.contentBuf == null) return + val content = state.contentBuf.toString() + if (content.isEmpty()) return + + when { + AUTHOR == localName && state.tagstack.size <= 3 -> { + val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + state.feed.author = contentFromHtml + } + DURATION == localName -> { + try { + val durationMs = inMillis(content) + state.tempObjects[DURATION] = durationMs.toInt() + } catch (e: NumberFormatException) { Log.e(NSTAG, String.format("Duration '%s' could not be parsed", content)) } + } + SUBTITLE == localName -> { + when { + state.currentItem != null && state.currentItem?.description.isNullOrEmpty() -> state.currentItem!!.setDescriptionIfLonger(content) + state.feed.description.isNullOrEmpty() -> state.feed.description = content + } + } + SUMMARY == localName -> { + when { + state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content) + Rss20.CHANNEL == state.secondTag.name -> state.feed.description = content + } + } + NEW_FEED_URL == localName && content.trim { it <= ' ' }.startsWith("http") -> state.redirectUrl = content.trim { it <= ' ' } + } + } + + companion object { + const val NSTAG: String = "itunes" + const val NSURI: String = "http://www.itunes.com/dtds/podcast-1.0.dtd" + + private const val IMAGE = "image" + private const val IMAGE_HREF = "href" + + private const val AUTHOR = "author" + const val DURATION: String = "duration" + private const val SUBTITLE = "subtitle" + private const val SUMMARY = "summary" + private const val NEW_FEED_URL = "new-feed-url" + } + } + + /** Processes tags from the http://search.yahoo.com/mrss/ namespace. */ + class Media : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { +// Log.d(TAG, "handleElementStart $localName") + when (localName) { + CONTENT -> { + val url: String? = attributes.getValue(DOWNLOAD_URL) + val defaultStr: String? = attributes.getValue(DEFAULT) + val medium: String? = attributes.getValue(MEDIUM) + var validTypeMedia = false + var validTypeImage = false + val isDefault = "true" == defaultStr + var mimeType = getMimeType(attributes.getValue(MIME_TYPE), url) + + when { + MEDIUM_AUDIO == medium -> { + validTypeMedia = true + mimeType = "audio/*" + } + MEDIUM_VIDEO == medium -> { + validTypeMedia = true + mimeType = "video/*" + } + MEDIUM_IMAGE == medium && (mimeType == null || (!mimeType.startsWith("audio/") && !mimeType.startsWith("video/"))) -> { + // Apparently, some publishers explicitly specify the audio file as an image + validTypeImage = true + mimeType = "image/*" + } + else -> { + when { + isMediaFile(mimeType) -> validTypeMedia = true + isImageFile(mimeType) -> validTypeImage = true + } + } + } + + when { + state.currentItem != null && (state.currentItem!!.media == null || isDefault) && url != null && validTypeMedia -> { + var size: Long = 0 + val sizeStr: String? = attributes.getValue(SIZE) + if (!sizeStr.isNullOrEmpty()) { + try { size = sizeStr.toLong() } catch (e: NumberFormatException) { Log.e(TAG, "Size \"$sizeStr\" could not be parsed.") } + } + var durationMs = 0 + val durationStr: String? = attributes.getValue(DURATION) + if (!durationStr.isNullOrEmpty()) { + try { + val duration = durationStr.toLong() + durationMs = TimeUnit.MILLISECONDS.convert(duration, TimeUnit.SECONDS).toInt() + } catch (e: NumberFormatException) { Log.e(TAG, "Duration \"$durationStr\" could not be parsed") } + } + Logd(TAG, "handleElementStart creating media: ${state.currentItem?.title} $url $size $mimeType") + val media = EpisodeMedia(state.currentItem, url, size, mimeType) + if (durationMs > 0) media.setDuration( durationMs) + state.currentItem!!.media = media + } + state.currentItem != null && url != null && validTypeImage -> state.currentItem!!.imageUrl = url + } + } + IMAGE -> { + val url: String? = attributes.getValue(IMAGE_URL) + if (url != null) { + when { + state.currentItem != null -> state.currentItem!!.imageUrl = url + else -> if (state.feed.imageUrl == null) state.feed.imageUrl = url + } + } + } + DESCRIPTION -> { + val type: String? = attributes.getValue(DESCRIPTION_TYPE) + return AtomText(localName, this, type) + } + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { +// Log.d(TAG, "handleElementEnd $localName") + if (DESCRIPTION == localName) { + val content = state.contentBuf.toString() + state.currentItem?.setDescriptionIfLonger(content) + } + } + + companion object { + private val TAG: String = Media::class.simpleName ?: "Anonymous" + + const val NSTAG: String = "media" + const val NSURI: String = "http://search.yahoo.com/mrss/" + + private const val CONTENT = "content" + private const val DOWNLOAD_URL = "url" + private const val SIZE = "fileSize" + private const val MIME_TYPE = "type" + private const val DURATION = "duration" + private const val DEFAULT = "isDefault" + private const val MEDIUM = "medium" + + private const val MEDIUM_IMAGE = "image" + private const val MEDIUM_AUDIO = "audio" + private const val MEDIUM_VIDEO = "video" + + private const val IMAGE = "thumbnail" + private const val IMAGE_URL = "url" + + private const val DESCRIPTION = "description" + private const val DESCRIPTION_TYPE = "type" + } + } + + class PodcastIndex : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { + when (localName) { + FUNDING -> { + val href: String? = attributes.getValue(URL) + val funding = FeedFunding(href, "") + state.currentFunding = funding + state.feed.addPayment(state.currentFunding!!) + } + CHAPTERS -> { + val href: String? = attributes.getValue(URL) + if (state.currentItem != null && !href.isNullOrEmpty()) state.currentItem!!.podcastIndexChapterUrl = href + } + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { + if (state.contentBuf == null) return + val content = state.contentBuf.toString() + if (FUNDING == localName && state.currentFunding != null && content.isNotEmpty()) state.currentFunding!!.setContent(content) + } + + companion object { + const val NSTAG: String = "podcast" + const val NSURI: String = "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" + const val NSURI2: String = "https://podcastindex.org/namespace/1.0" + private const val URL = "url" + private const val FUNDING = "funding" + private const val CHAPTERS = "chapters" + } + } + + /** + * SAX-Parser for reading RSS-Feeds. + */ + class Rss20 : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { +// Log.d(TAG, "handleElementStart $localName") + when { + ITEM == localName && CHANNEL == state.tagstack.lastElement()?.name -> { + state.currentItem = Episode() + state.items.add(state.currentItem!!) +// state.currentItem!!.feed = state.feed + } + ENCLOSURE == localName && ITEM == state.tagstack.peek()?.name -> { + val url: String? = attributes.getValue(ENC_URL) + val mimeType: String? = getMimeType(attributes.getValue(ENC_TYPE), url) + val validUrl = !url.isNullOrBlank() + if (state.currentItem?.media == null && isMediaFile(mimeType) && validUrl) { + var size: Long = 0 + try { + size = attributes.getValue(ENC_LEN)?.toLong() ?: 0 + // less than 16kb is suspicious, check manually + if (size < 16384) size = 0 + } catch (e: NumberFormatException) { Logd(TAG, "Length attribute could not be parsed.") } + val media = EpisodeMedia(state.currentItem, url, size, mimeType) + if(state.currentItem != null) state.currentItem!!.media = media + } + } + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { +// Log.d(TAG, "handleElementEnd $localName") + when { + ITEM == localName -> { + if (state.currentItem != null) { + val currentItem = state.currentItem!! + // the title tag is optional in RSS 2.0. The description is used + // as a title if the item has no title-tag. + if (currentItem.title == null) currentItem.title = currentItem.description + + if (state.tempObjects.containsKey(Itunes.DURATION)) { + if (currentItem.media != null) { + val duration = state.tempObjects[Itunes.DURATION] as? Int + if (duration != null) currentItem.media!!.setDuration(duration) + } + state.tempObjects.remove(Itunes.DURATION) + } + } + state.currentItem = null + } + state.tagstack.size >= 2 && state.contentBuf != null -> { + val contentRaw = state.contentBuf.toString() + val content = trimAllWhitespace(contentRaw) + val topElement = state.tagstack.peek() + val top = topElement.name + val secondElement = state.secondTag + val second = secondElement.name + var third: String? = null + if (state.tagstack.size >= 3) third = state.thirdTag.name + + when { + // some feed creators include an empty or non-standard guid-element in their feed, + // which should be ignored + GUID == top && ITEM == second -> if (contentRaw.isNotEmpty() && state.currentItem != null) state.currentItem!!.identifier = contentRaw + TITLE == top -> { + val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + when { + ITEM == second && state.currentItem != null -> state.currentItem!!.title = contentFromHtml + CHANNEL == second -> state.feed.title = contentFromHtml + } + } + LINK == top -> { + when { + CHANNEL == second -> state.feed.link = content + ITEM == second && state.currentItem != null -> state.currentItem!!.link = content + } + } + PUBDATE == top && ITEM == second && state.currentItem != null -> state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 + // prefer itunes:image + URL == top && IMAGE == second && CHANNEL == third -> if (state.feed.imageUrl == null) state.feed.imageUrl = content + DESCR == localName -> { + when { + CHANNEL == second -> state.feed.description = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() + ITEM == second && state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content) // fromHtml here breaks \n when not html + } + } + LANGUAGE == localName -> state.feed.language = content.lowercase() + } + } + } + } + + companion object { + private val TAG: String = Rss20::class.simpleName ?: "Anonymous" + + const val CHANNEL: String = "channel" + const val ITEM: String = "item" + private const val GUID = "guid" + private const val TITLE = "title" + private const val LINK = "link" + private const val DESCR = "description" + private const val PUBDATE = "pubDate" + private const val ENCLOSURE = "enclosure" + private const val IMAGE = "image" + private const val URL = "url" + private const val LANGUAGE = "language" + + private const val ENC_URL = "url" + private const val ENC_LEN = "length" + private const val ENC_TYPE = "type" + } + } + + class SimpleChapters : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { + val currentItem = state.currentItem + if (currentItem != null) { + when { + localName == CHAPTERS -> currentItem.chapters.clear() + localName == CHAPTER && !attributes.getValue(START).isNullOrEmpty() -> { + // if the chapter's START is empty, we don't need to do anything + try { + val start= parseTimeString(attributes.getValue(START)) + val title: String? = attributes.getValue(TITLE) + val link: String? = attributes.getValue(HREF) + val imageUrl: String? = attributes.getValue(IMAGE) + val chapter = Chapter(start, title, link, imageUrl) + currentItem.chapters?.add(chapter) + } catch (e: NumberFormatException) { Log.e(TAG, "Unable to read chapter", e) } + } + } + } + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) {} + + companion object { + private val TAG: String = SimpleChapters::class.simpleName ?: "Anonymous" + + const val NSTAG: String = "psc|sc" + const val NSURI: String = "http://podlove.org/simple-chapters" + + private const val CHAPTERS = "chapters" + private const val CHAPTER = "chapter" + private const val START = "start" + private const val TITLE = "title" + private const val HREF = "href" + private const val IMAGE = "image" + } + } + + class DublinCore : Namespace() { + override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { + return SyndElement(localName, this) + } + + override fun handleElementEnd(localName: String, state: HandlerState) { + if (state.currentItem != null && state.contentBuf != null && state.tagstack.size >= 2) { + val currentItem = state.currentItem + val top = state.tagstack.peek().name + val second = state.secondTag.name + if (DATE == top && ITEM == second) { + val content = state.contentBuf.toString() + currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 + } + } + } + + companion object { + const val NSTAG: String = "dc" + const val NSURI: String = "http://purl.org/dc/elements/1.1/" + + private const val ITEM = "item" + private const val DATE = "date" + } + } + companion object { private val TAG: String = FeedHandler::class.simpleName ?: "Anonymous" private const val ATOM_ROOT = "feed" diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandlerResult.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandlerResult.kt deleted file mode 100644 index 3b7bc68e..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/FeedHandlerResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser - -import ac.mdiq.podcini.storage.model.Feed - -/** - * Container for results returned by the Feed parser - */ -class FeedHandlerResult( - @JvmField val feed: Feed, - @JvmField val alternateFeedUrls: Map, - val redirectUrl: String) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/HandlerState.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/HandlerState.kt deleted file mode 100644 index df574c08..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/HandlerState.kt +++ /dev/null @@ -1,75 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser - -import ac.mdiq.podcini.storage.model.Feed -import ac.mdiq.podcini.storage.model.FeedFunding -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.namespace.Namespace -import java.util.* - -/** - * Contains all relevant information to describe the current state of a - * SyndHandler. - */ -/** - * Feed that the Handler is currently processing. - */ -class HandlerState(@JvmField var feed: Feed) { - /** - * Contains links to related feeds, e.g. feeds with enclosures in other formats. The key of the map is the - * URL of the feed, the value is the title - */ - @JvmField - val alternateUrls: MutableMap = HashMap() - @JvmField - var redirectUrl: String? = null - @JvmField - val items: ArrayList = ArrayList() - @JvmField - var currentItem: Episode? = null - @JvmField - var currentFunding: FeedFunding? = null - @JvmField - val tagstack: Stack = Stack() - /** - * Namespaces that have been defined so far. - */ - @JvmField - val namespaces: MutableMap = HashMap() - @JvmField - val defaultNamespaces: Stack = Stack() - /** - * Buffer for saving characters. - */ - @JvmField - var contentBuf: StringBuilder? = null - /** - * Temporarily saved objects. - */ - @JvmField - val tempObjects: MutableMap = HashMap() - /** - * Returns the SyndElement that comes after the top element of the tagstack. - */ - val secondTag: SyndElement - get() { - val top = tagstack.pop() - val second = tagstack.peek() - tagstack.push(top) - return second - } - - val thirdTag: SyndElement - get() { - val top = tagstack.pop() - val second = tagstack.pop() - val third = tagstack.peek() - tagstack.push(second) - tagstack.push(top) - return third - } - - fun addAlternateFeedUrl(title: String, url: String) { - alternateUrls[url] = title - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/AtomText.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/AtomText.kt deleted file mode 100644 index 0a1aaa98..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/AtomText.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.element - -import androidx.core.text.HtmlCompat -import ac.mdiq.podcini.net.feed.parser.namespace.Namespace - -/** Represents Atom Element which contains text (content, title, summary). */ -class AtomText( - name: String, - namespace: Namespace, - private val type: String?) : SyndElement(name, namespace) { - - private var content: String? = null - - val processedContent: String? - /** Processes the content according to the type and returns it. */ - get() = when (type) { - null -> content - TYPE_HTML -> HtmlCompat.fromHtml(content!!, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() - TYPE_XHTML -> content - // Handle as text by default - else -> content - } - - fun setContent(content: String?) { - this.content = content - } - - companion object { - const val TYPE_HTML: String = "html" - private const val TYPE_XHTML = "xhtml" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/SyndElement.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/SyndElement.kt deleted file mode 100644 index f5af4b75..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/element/SyndElement.kt +++ /dev/null @@ -1,6 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.element - -import ac.mdiq.podcini.net.feed.parser.namespace.Namespace - -/** Defines a XML Element that is pushed on the tagstack */ -open class SyndElement(@JvmField val name: String, val namespace: Namespace) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Atom.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Atom.kt deleted file mode 100644 index b551e3d3..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Atom.kt +++ /dev/null @@ -1,201 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.AtomText -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseOrNullIfFuture -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.getMimeType -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isMediaFile -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.FeedFunding -import ac.mdiq.podcini.util.Logd -import org.xml.sax.Attributes - -class Atom : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { -// Log.d(TAG, "handleElementStart $localName") - when { - ENTRY == localName -> { - state.currentItem = Episode() - state.items.add(state.currentItem!!) -// state.currentItem!!.feed = state.feed - } - localName.matches(isText.toRegex()) -> { - val type: String? = attributes.getValue(TEXT_TYPE) - return AtomText(localName, this, type) - } - LINK == localName -> { - val href: String? = attributes.getValue(LINK_HREF) - val rel: String? = attributes.getValue(LINK_REL) - val parent = state.tagstack.peek() - when { - parent.name.matches(isFeedItem.toRegex()) -> { - when (rel) { - null, LINK_REL_ALTERNATE -> if (state.currentItem != null) state.currentItem!!.link = href - LINK_REL_ENCLOSURE -> { - val strSize: String? = attributes.getValue(LINK_LENGTH) - var size: Long = 0 - try { if (strSize != null) size = strSize.toLong() } catch (e: NumberFormatException) { Logd(TAG, "Length attribute could not be parsed.") } - val mimeType: String? = getMimeType(attributes.getValue(LINK_TYPE), href) - val currItem = state.currentItem - if (isMediaFile(mimeType) && currItem != null && currItem.media == null) - currItem.media = EpisodeMedia(currItem, href, size, mimeType) - } - LINK_REL_PAYMENT -> if (state.currentItem != null) state.currentItem!!.paymentLink = href - } - } - parent.name.matches(isFeed.toRegex()) -> { - when (rel) { - null, LINK_REL_ALTERNATE -> { - val type: String? = attributes.getValue(LINK_TYPE) -// Use as link if -// a) no type-attribute is given and feed-object has no link yet -// b) type of link is LINK_TYPE_HTML or LINK_TYPE_XHTML - when { - type == null && state.feed.link == null || LINK_TYPE_HTML == type || LINK_TYPE_XHTML == type -> state.feed.link = href - LINK_TYPE_ATOM == type || LINK_TYPE_RSS == type -> { - // treat as podlove alternate feed - var title: String? = attributes.getValue(LINK_TITLE) - if (title.isNullOrEmpty()) title = href?:"" - if (!href.isNullOrEmpty()) state.addAlternateFeedUrl(title, href) - } - } - } - LINK_REL_ARCHIVES -> { - val type: String? = attributes.getValue(LINK_TYPE) - when (type) { - LINK_TYPE_ATOM, LINK_TYPE_RSS -> { - var title: String? = attributes.getValue(LINK_TITLE) - if (title.isNullOrEmpty()) title = href?:"" - if (!href.isNullOrEmpty()) state.addAlternateFeedUrl(title, href) - } - //A Link such as to a directory such as iTunes - LINK_TYPE_HTML, LINK_TYPE_XHTML -> {} - } - } - LINK_REL_PAYMENT -> state.feed.addPayment(FeedFunding(href, "")) - LINK_REL_NEXT -> { - state.feed.isPaged = true - state.feed.nextPageLink = href - } - } - } - } - } - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { -// Log.d(TAG, "handleElementEnd $localName") - if (ENTRY == localName) { - if (state.currentItem != null && state.tempObjects.containsKey(Itunes.DURATION)) { - val currentItem = state.currentItem - if (currentItem!!.media != null) { - val duration = state.tempObjects[Itunes.DURATION] as Int? - if (duration != null) currentItem.media!!.setDuration(duration) - } - state.tempObjects.remove(Itunes.DURATION) - } - state.currentItem = null - } - - if (state.tagstack.size >= 2) { - var textElement: AtomText? = null - val contentRaw = if (state.contentBuf != null) state.contentBuf.toString() else "" - val content = trimAllWhitespace(contentRaw) - val topElement = state.tagstack.peek() - val top = topElement.name - val secondElement = state.secondTag - val second = secondElement.name - - if (top.matches(isText.toRegex())) { - textElement = topElement as AtomText - textElement.setContent(content) - } - when { - ID == top -> { - when { - FEED == second -> state.feed.identifier = contentRaw - ENTRY == second && state.currentItem != null -> state.currentItem!!.identifier = contentRaw - } - } - TITLE == top && textElement != null -> { - when { - FEED == second -> state.feed.title = textElement.processedContent - ENTRY == second && state.currentItem != null -> state.currentItem!!.title = textElement.processedContent - } - } - SUBTITLE == top && FEED == second && textElement != null -> state.feed.description = textElement.processedContent - CONTENT == top && ENTRY == second && textElement != null && state.currentItem != null -> - state.currentItem!!.setDescriptionIfLonger(textElement.processedContent) - SUMMARY == top && ENTRY == second && textElement != null && state.currentItem != null -> - state.currentItem!!.setDescriptionIfLonger(textElement.processedContent) - UPDATED == top && ENTRY == second && state.currentItem != null && state.currentItem!!.pubDate == 0L -> - state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 - PUBLISHED == top && ENTRY == second && state.currentItem != null -> - state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 - IMAGE_LOGO == top && state.feed.imageUrl == null -> state.feed.imageUrl = content - IMAGE_ICON == top -> state.feed.imageUrl = content - AUTHOR_NAME == top && AUTHOR == second && state.currentItem == null -> { - val currentName = state.feed.author - if (currentName == null) state.feed.author = content - else state.feed.author = "$currentName, $content" - } - } - } - } - - companion object { - private val TAG: String = Atom::class.simpleName ?: "Anonymous" - const val NSTAG: String = "atom" - const val NSURI: String = "http://www.w3.org/2005/Atom" - - private const val FEED = "feed" - private const val ID = "id" - private const val TITLE = "title" - private const val ENTRY = "entry" - private const val LINK = "link" - private const val UPDATED = "updated" - private const val AUTHOR = "author" - private const val AUTHOR_NAME = "name" - private const val CONTENT = "content" - private const val SUMMARY = "summary" - private const val IMAGE_LOGO = "logo" - private const val IMAGE_ICON = "icon" - private const val SUBTITLE = "subtitle" - private const val PUBLISHED = "published" - - private const val TEXT_TYPE = "type" - - // Link - private const val LINK_HREF = "href" - private const val LINK_REL = "rel" - private const val LINK_TYPE = "type" - private const val LINK_TITLE = "title" - private const val LINK_LENGTH = "length" - - // rel-values - private const val LINK_REL_ALTERNATE = "alternate" - private const val LINK_REL_ARCHIVES = "archives" - private const val LINK_REL_ENCLOSURE = "enclosure" - private const val LINK_REL_PAYMENT = "payment" - private const val LINK_REL_NEXT = "next" - - // type-values - private const val LINK_TYPE_ATOM = "application/atom+xml" - private const val LINK_TYPE_HTML = "text/html" - private const val LINK_TYPE_XHTML = "application/xml+xhtml" - - private const val LINK_TYPE_RSS = "application/rss+xml" - - /** - * Regexp to test whether an Element is a Text Element. - */ - private const val isText = ("$TITLE|$CONTENT|$SUBTITLE|$SUMMARY") - - private const val isFeed = FEED + "|" + Rss20.CHANNEL - private const val isFeedItem = ENTRY + "|" + Rss20.ITEM - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Content.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Content.kt deleted file mode 100644 index 6b2b6aff..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Content.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import org.xml.sax.Attributes - -class Content : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { - if (ENCODED == localName && state.contentBuf != null) - state.currentItem?.setDescriptionIfLonger(state.contentBuf.toString()) - } - - companion object { - const val NSTAG: String = "content" - const val NSURI: String = "http://purl.org/rss/1.0/modules/content/" - - private const val ENCODED = "encoded" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/DublinCore.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/DublinCore.kt deleted file mode 100644 index d1e46834..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/DublinCore.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseOrNullIfFuture -import org.xml.sax.Attributes - -class DublinCore : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { - if (state.currentItem != null && state.contentBuf != null && state.tagstack.size >= 2) { - val currentItem = state.currentItem - val top = state.tagstack.peek().name - val second = state.secondTag.name - if (DATE == top && ITEM == second) { - val content = state.contentBuf.toString() - currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 - } - } - } - - companion object { - const val NSTAG: String = "dc" - const val NSURI: String = "http://purl.org/dc/elements/1.1/" - - private const val ITEM = "item" - private const val DATE = "date" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Itunes.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Itunes.kt deleted file mode 100644 index 25e2fd57..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Itunes.kt +++ /dev/null @@ -1,67 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import android.util.Log -import androidx.core.text.HtmlCompat -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DurationParser.inMillis -import org.xml.sax.Attributes - -class Itunes : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { - if (IMAGE == localName) { - val url: String? = attributes.getValue(IMAGE_HREF) - if (state.currentItem != null) state.currentItem!!.imageUrl = url - // this is the feed image - // prefer to all other images - else if (!url.isNullOrEmpty()) state.feed.imageUrl = url - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { - if (state.contentBuf == null) return - val content = state.contentBuf.toString() - if (content.isEmpty()) return - - when { - AUTHOR == localName && state.tagstack.size <= 3 -> { - val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - state.feed.author = contentFromHtml - } - DURATION == localName -> { - try { - val durationMs = inMillis(content) - state.tempObjects[DURATION] = durationMs.toInt() - } catch (e: NumberFormatException) { Log.e(NSTAG, String.format("Duration '%s' could not be parsed", content)) } - } - SUBTITLE == localName -> { - when { - state.currentItem != null && state.currentItem?.description.isNullOrEmpty() -> state.currentItem!!.setDescriptionIfLonger(content) - state.feed.description.isNullOrEmpty() -> state.feed.description = content - } - } - SUMMARY == localName -> { - when { - state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content) - Rss20.CHANNEL == state.secondTag.name -> state.feed.description = content - } - } - NEW_FEED_URL == localName && content.trim { it <= ' ' }.startsWith("http") -> state.redirectUrl = content.trim { it <= ' ' } - } - } - - companion object { - const val NSTAG: String = "itunes" - const val NSURI: String = "http://www.itunes.com/dtds/podcast-1.0.dtd" - - private const val IMAGE = "image" - private const val IMAGE_HREF = "href" - - private const val AUTHOR = "author" - const val DURATION: String = "duration" - private const val SUBTITLE = "subtitle" - private const val SUMMARY = "summary" - private const val NEW_FEED_URL = "new-feed-url" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Media.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Media.kt deleted file mode 100644 index 3d2bc019..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Media.kt +++ /dev/null @@ -1,124 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.AtomText -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.getMimeType -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isImageFile -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isMediaFile -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.util.Logd -import android.util.Log -import org.xml.sax.Attributes -import java.util.concurrent.TimeUnit - -/** Processes tags from the http://search.yahoo.com/mrss/ namespace. */ -class Media : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { -// Log.d(TAG, "handleElementStart $localName") - when (localName) { - CONTENT -> { - val url: String? = attributes.getValue(DOWNLOAD_URL) - val defaultStr: String? = attributes.getValue(DEFAULT) - val medium: String? = attributes.getValue(MEDIUM) - var validTypeMedia = false - var validTypeImage = false - val isDefault = "true" == defaultStr - var mimeType = getMimeType(attributes.getValue(MIME_TYPE), url) - - when { - MEDIUM_AUDIO == medium -> { - validTypeMedia = true - mimeType = "audio/*" - } - MEDIUM_VIDEO == medium -> { - validTypeMedia = true - mimeType = "video/*" - } - MEDIUM_IMAGE == medium && (mimeType == null || (!mimeType.startsWith("audio/") && !mimeType.startsWith("video/"))) -> { - // Apparently, some publishers explicitly specify the audio file as an image - validTypeImage = true - mimeType = "image/*" - } - else -> { - when { - isMediaFile(mimeType) -> validTypeMedia = true - isImageFile(mimeType) -> validTypeImage = true - } - } - } - - when { - state.currentItem != null && (state.currentItem!!.media == null || isDefault) && url != null && validTypeMedia -> { - var size: Long = 0 - val sizeStr: String? = attributes.getValue(SIZE) - if (!sizeStr.isNullOrEmpty()) { - try { size = sizeStr.toLong() } catch (e: NumberFormatException) { Log.e(TAG, "Size \"$sizeStr\" could not be parsed.") } - } - var durationMs = 0 - val durationStr: String? = attributes.getValue(DURATION) - if (!durationStr.isNullOrEmpty()) { - try { - val duration = durationStr.toLong() - durationMs = TimeUnit.MILLISECONDS.convert(duration, TimeUnit.SECONDS).toInt() - } catch (e: NumberFormatException) { Log.e(TAG, "Duration \"$durationStr\" could not be parsed") } - } - Logd(TAG, "handleElementStart creating media: ${state.currentItem?.title} $url $size $mimeType") - val media = EpisodeMedia(state.currentItem, url, size, mimeType) - if (durationMs > 0) media.setDuration( durationMs) - state.currentItem!!.media = media - } - state.currentItem != null && url != null && validTypeImage -> state.currentItem!!.imageUrl = url - } - } - IMAGE -> { - val url: String? = attributes.getValue(IMAGE_URL) - if (url != null) { - when { - state.currentItem != null -> state.currentItem!!.imageUrl = url - else -> if (state.feed.imageUrl == null) state.feed.imageUrl = url - } - } - } - DESCRIPTION -> { - val type: String? = attributes.getValue(DESCRIPTION_TYPE) - return AtomText(localName, this, type) - } - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { -// Log.d(TAG, "handleElementEnd $localName") - if (DESCRIPTION == localName) { - val content = state.contentBuf.toString() - state.currentItem?.setDescriptionIfLonger(content) - } - } - - companion object { - private val TAG: String = Media::class.simpleName ?: "Anonymous" - - const val NSTAG: String = "media" - const val NSURI: String = "http://search.yahoo.com/mrss/" - - private const val CONTENT = "content" - private const val DOWNLOAD_URL = "url" - private const val SIZE = "fileSize" - private const val MIME_TYPE = "type" - private const val DURATION = "duration" - private const val DEFAULT = "isDefault" - private const val MEDIUM = "medium" - - private const val MEDIUM_IMAGE = "image" - private const val MEDIUM_AUDIO = "audio" - private const val MEDIUM_VIDEO = "video" - - private const val IMAGE = "thumbnail" - private const val IMAGE_URL = "url" - - private const val DESCRIPTION = "description" - private const val DESCRIPTION_TYPE = "type" - } -} - diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Namespace.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Namespace.kt deleted file mode 100644 index 18fe1adb..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Namespace.kt +++ /dev/null @@ -1,24 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import org.xml.sax.Attributes - -abstract class Namespace { - /** Called by a Feedhandler when in startElement and it detects a namespace element - * @return The SyndElement to push onto the stack - */ - abstract fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement - - /** Called by a Feedhandler when in endElement and it detects a namespace element - */ - abstract fun handleElementEnd(localName: String, state: HandlerState) - - /** - * Trims all whitespace from beginning and ending of a String. {[String.trim]} only trims spaces. - */ - fun trimAllWhitespace(string: String): String { - return string.replace("(^\\s*)|(\\s*$)".toRegex(), "") - } - -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/PodcastIndex.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/PodcastIndex.kt deleted file mode 100644 index e5b449cd..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/PodcastIndex.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.storage.model.FeedFunding -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import org.xml.sax.Attributes - -class PodcastIndex : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { - when (localName) { - FUNDING -> { - val href: String? = attributes.getValue(URL) - val funding = FeedFunding(href, "") - state.currentFunding = funding - state.feed.addPayment(state.currentFunding!!) - } - CHAPTERS -> { - val href: String? = attributes.getValue(URL) - if (state.currentItem != null && !href.isNullOrEmpty()) state.currentItem!!.podcastIndexChapterUrl = href - } - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { - if (state.contentBuf == null) return - val content = state.contentBuf.toString() - if (FUNDING == localName && state.currentFunding != null && content.isNotEmpty()) state.currentFunding!!.setContent(content) - } - - companion object { - const val NSTAG: String = "podcast" - const val NSURI: String = "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" - const val NSURI2: String = "https://podcastindex.org/namespace/1.0" - private const val URL = "url" - private const val FUNDING = "funding" - private const val CHAPTERS = "chapters" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Rss20.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Rss20.kt deleted file mode 100644 index 9173759b..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/Rss20.kt +++ /dev/null @@ -1,126 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseOrNullIfFuture -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.getMimeType -import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils.isMediaFile -import ac.mdiq.podcini.storage.model.Episode -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.util.Logd -import androidx.core.text.HtmlCompat -import org.xml.sax.Attributes - -/** - * SAX-Parser for reading RSS-Feeds. - */ -class Rss20 : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { -// Log.d(TAG, "handleElementStart $localName") - when { - ITEM == localName && CHANNEL == state.tagstack.lastElement()?.name -> { - state.currentItem = Episode() - state.items.add(state.currentItem!!) -// state.currentItem!!.feed = state.feed - } - ENCLOSURE == localName && ITEM == state.tagstack.peek()?.name -> { - val url: String? = attributes.getValue(ENC_URL) - val mimeType: String? = getMimeType(attributes.getValue(ENC_TYPE), url) - val validUrl = !url.isNullOrBlank() - if (state.currentItem?.media == null && isMediaFile(mimeType) && validUrl) { - var size: Long = 0 - try { - size = attributes.getValue(ENC_LEN)?.toLong() ?: 0 - // less than 16kb is suspicious, check manually - if (size < 16384) size = 0 - } catch (e: NumberFormatException) { Logd(TAG, "Length attribute could not be parsed.") } - val media = EpisodeMedia(state.currentItem, url, size, mimeType) - if(state.currentItem != null) state.currentItem!!.media = media - } - } - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) { -// Log.d(TAG, "handleElementEnd $localName") - when { - ITEM == localName -> { - if (state.currentItem != null) { - val currentItem = state.currentItem!! - // the title tag is optional in RSS 2.0. The description is used - // as a title if the item has no title-tag. - if (currentItem.title == null) currentItem.title = currentItem.description - - if (state.tempObjects.containsKey(Itunes.DURATION)) { - if (currentItem.media != null) { - val duration = state.tempObjects[Itunes.DURATION] as? Int - if (duration != null) currentItem.media!!.setDuration(duration) - } - state.tempObjects.remove(Itunes.DURATION) - } - } - state.currentItem = null - } - state.tagstack.size >= 2 && state.contentBuf != null -> { - val contentRaw = state.contentBuf.toString() - val content = trimAllWhitespace(contentRaw) - val topElement = state.tagstack.peek() - val top = topElement.name - val secondElement = state.secondTag - val second = secondElement.name - var third: String? = null - if (state.tagstack.size >= 3) third = state.thirdTag.name - - when { - // some feed creators include an empty or non-standard guid-element in their feed, - // which should be ignored - GUID == top && ITEM == second -> if (contentRaw.isNotEmpty() && state.currentItem != null) state.currentItem!!.identifier = contentRaw - TITLE == top -> { - val contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - when { - ITEM == second && state.currentItem != null -> state.currentItem!!.title = contentFromHtml - CHANNEL == second -> state.feed.title = contentFromHtml - } - } - LINK == top -> { - when { - CHANNEL == second -> state.feed.link = content - ITEM == second && state.currentItem != null -> state.currentItem!!.link = content - } - } - PUBDATE == top && ITEM == second && state.currentItem != null -> state.currentItem!!.pubDate = parseOrNullIfFuture(content)?.time ?: 0 - // prefer itunes:image - URL == top && IMAGE == second && CHANNEL == third -> if (state.feed.imageUrl == null) state.feed.imageUrl = content - DESCR == localName -> { - when { - CHANNEL == second -> state.feed.description = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString() - ITEM == second && state.currentItem != null -> state.currentItem!!.setDescriptionIfLonger(content) // fromHtml here breaks \n when not html - } - } - LANGUAGE == localName -> state.feed.language = content.lowercase() - } - } - } - } - - companion object { - private val TAG: String = Rss20::class.simpleName ?: "Anonymous" - - const val CHANNEL: String = "channel" - const val ITEM: String = "item" - private const val GUID = "guid" - private const val TITLE = "title" - private const val LINK = "link" - private const val DESCR = "description" - private const val PUBDATE = "pubDate" - private const val ENCLOSURE = "enclosure" - private const val IMAGE = "image" - private const val URL = "url" - private const val LANGUAGE = "language" - - private const val ENC_URL = "url" - private const val ENC_LEN = "length" - private const val ENC_TYPE = "type" - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/SimpleChapters.kt b/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/SimpleChapters.kt deleted file mode 100644 index a2c09bcf..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/net/feed/parser/namespace/SimpleChapters.kt +++ /dev/null @@ -1,47 +0,0 @@ -package ac.mdiq.podcini.net.feed.parser.namespace - -import ac.mdiq.podcini.net.feed.parser.HandlerState -import android.util.Log -import ac.mdiq.podcini.storage.model.Chapter -import ac.mdiq.podcini.net.feed.parser.element.SyndElement -import ac.mdiq.podcini.net.feed.parser.utils.DateUtils.parseTimeString -import org.xml.sax.Attributes - -class SimpleChapters : Namespace() { - override fun handleElementStart(localName: String, state: HandlerState, attributes: Attributes): SyndElement { - val currentItem = state.currentItem - if (currentItem != null) { - when { - localName == CHAPTERS -> currentItem.chapters.clear() - localName == CHAPTER && !attributes.getValue(START).isNullOrEmpty() -> { - // if the chapter's START is empty, we don't need to do anything - try { - val start= parseTimeString(attributes.getValue(START)) - val title: String? = attributes.getValue(TITLE) - val link: String? = attributes.getValue(HREF) - val imageUrl: String? = attributes.getValue(IMAGE) - val chapter = Chapter(start, title, link, imageUrl) - currentItem.chapters?.add(chapter) - } catch (e: NumberFormatException) { Log.e(TAG, "Unable to read chapter", e) } - } - } - } - return SyndElement(localName, this) - } - - override fun handleElementEnd(localName: String, state: HandlerState) {} - - companion object { - private val TAG: String = SimpleChapters::class.simpleName ?: "Anonymous" - - const val NSTAG: String = "psc|sc" - const val NSURI: String = "http://podlove.org/simple-chapters" - - private const val CHAPTERS = "chapters" - private const val CHAPTER = "chapter" - private const val START = "start" - private const val TITLE = "title" - private const val HREF = "href" - private const val IMAGE = "image" - } -} 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 d3c37b28..5b1a4e5e 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 @@ -336,9 +336,6 @@ object Feeds { Logd(TAG, "setRating called $rating") // return runOnIOScope { val result = upsertBlk(feed) { it.rating = rating } -// val slog = realm.query(SubscriptionLog::class).query("itemId == $0", feed.id).first().find() -// if (slog != null) upsertBlk(slog) { it.rating = rating } -// EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating)) // } } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt index 0fd22dff..914e5742 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Chapter.kt @@ -1,10 +1,10 @@ package ac.mdiq.podcini.storage.model +import ac.mdiq.podcini.storage.model.Feed.Companion.newId import io.realm.kotlin.types.EmbeddedRealmObject import io.realm.kotlin.types.annotations.Index class Chapter : EmbeddedRealmObject { - @Index var id: Long = 0 @@ -21,9 +21,12 @@ class Chapter : EmbeddedRealmObject { var episode: Episode? = null - constructor() + constructor() { +// id = newId() + } constructor(start: Long, title: String?, link: String?, imageUrl: String?) { +// id = newId() this.start = start this.title = title this.link = link diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt index 510e97af..85fce98d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/Feed.kt @@ -302,6 +302,9 @@ class Feed : RealmObject { const val PREFIX_LOCAL_FOLDER: String = "podcini_local:" const val PREFIX_GENERATIVE_COVER: String = "podcini_generative_cover:" + const val MAX_NATURAL_SYNTHETIC_ID: Long = 100L + const val MAX_SYNTHETIC_ID: Long = 1000L + fun newId(): Long { return Date().time * 100 } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt index 4b5b6603..c41a881e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/model/FeedPreferences.kt @@ -34,7 +34,7 @@ class FeedPreferences : EmbeddedRealmObject { field = value videoMode = field.code } - var videoMode: Int = 0 + var videoMode: Int = VideoMode.NONE.code var playSpeed: Float = SPEED_USE_GLOBAL diff --git a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt index ca1835ea..fbd4a51d 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/storage/utils/ChapterUtils.kt @@ -68,22 +68,16 @@ object ChapterUtils { val chapters = readId3ChaptersFrom(inVal) if (chapters.isNotEmpty()) return chapters } - } catch (e: IOException) { - Log.e(TAG, "Unable to load ID3 chapters: " + e.message) - } catch (e: ID3ReaderException) { - Log.e(TAG, "Unable to load ID3 chapters: " + e.message) - } + } catch (e: IOException) { Log.e(TAG, "Unable to load ID3 chapters: " + e.message) + } catch (e: ID3ReaderException) { Log.e(TAG, "Unable to load ID3 chapters: " + e.message) } try { openStream(playable, context).use { inVal -> val chapters = readOggChaptersFromInputStream(inVal) if (chapters.isNotEmpty()) return chapters } - } catch (e: IOException) { - Log.e(TAG, "Unable to load vorbis chapters: " + e.message) - } catch (e: VorbisCommentReaderException) { - Log.e(TAG, "Unable to load vorbis chapters: " + e.message) - } + } catch (e: IOException) { Log.e(TAG, "Unable to load vorbis chapters: " + e.message) + } catch (e: VorbisCommentReaderException) { Log.e(TAG, "Unable to load vorbis chapters: " + e.message) } return listOf() } @@ -128,11 +122,8 @@ object ChapterUtils { response = getHttpClient().newCall(request).execute() if (response.isSuccessful && response.body != null) return parse(response.body!!.string()) - } catch (e: IOException) { - e.printStackTrace() - } finally { - response?.close() - } + } catch (e: IOException) { e.printStackTrace() + } finally { response?.close() } return listOf() } @@ -158,7 +149,6 @@ object ChapterUtils { chapters = chapters.sortedWith(ChapterStartTimeComparator()) enumerateEmptyChapterTitles(chapters) if (chaptersValid(chapters)) return chapters - return emptyList() } @@ -174,7 +164,6 @@ object ChapterUtils { private fun chaptersValid(chapters: List): Boolean { if (chapters.isEmpty()) return false - for (c in chapters) { if (c.start < 0) return false } @@ -240,9 +229,7 @@ object ChapterUtils { chapters.add(Chapter(startTime * 1000L, title, link, img)) } return chapters - } catch (e: JSONException) { - e.printStackTrace() - } + } catch (e: JSONException) { e.printStackTrace() } return listOf() } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt index 058e0ae4..302e5f95 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/MainActivity.kt @@ -32,7 +32,6 @@ import ac.mdiq.podcini.ui.statistics.StatisticsFragment import ac.mdiq.podcini.ui.utils.LockableBottomSheetBehavior import ac.mdiq.podcini.ui.utils.ThemeUtils.getDrawableFromAttr import ac.mdiq.podcini.ui.utils.TransitionEffect -import ac.mdiq.podcini.ui.view.EpisodesRecyclerView import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.Logd @@ -173,7 +172,7 @@ class MainActivity : CastEnabledActivity() { // init shared preferences ioScope.launch { // RealmDB.apply { } - EpisodesRecyclerView.getSharedPrefs(this@MainActivity) +// EpisodesRecyclerView.getSharedPrefs(this@MainActivity) NavDrawerFragment.getSharedPrefs(this@MainActivity) SwipeActions.getSharedPrefs(this@MainActivity) QueuesFragment.getSharedPrefs(this@MainActivity) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt index d7d029b6..b0263005 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/activity/VideoplayerActivity.kt @@ -32,8 +32,9 @@ import ac.mdiq.podcini.storage.model.Playable import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong import ac.mdiq.podcini.storage.utils.TimeSpeedConverter import ac.mdiq.podcini.ui.activity.starter.MainActivityStarter +import ac.mdiq.podcini.ui.compose.ChaptersDialog +import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.dialog.* -import ac.mdiq.podcini.ui.fragment.ChaptersFragment import ac.mdiq.podcini.ui.utils.PictureInPictureUtil import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.view.ShownotesWebView @@ -66,6 +67,9 @@ import android.widget.SeekBar.OnSeekBarChangeListener import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.ComposeView import androidx.core.app.ActivityCompat.invalidateOptionsMenu import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment @@ -297,7 +301,19 @@ class VideoplayerActivity : CastEnabledActivity() { return true } R.id.player_show_chapters -> { - ChaptersFragment().show(supportFragmentManager, ChaptersFragment.TAG) + val composeView = ComposeView(this).apply { + setContent { + val showDialog = remember { mutableStateOf(true) } + CustomTheme(this@VideoplayerActivity) { + ChaptersDialog(curMedia!!, onDismissRequest = { + showDialog.value = false + (binding.root as? ViewGroup)?.removeView(this@apply) + }) + } + } + } + (binding.root as? ViewGroup)?.addView(composeView) +// ChaptersFragment().show(supportFragmentManager, ChaptersFragment.TAG) return true } else -> { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt new file mode 100644 index 00000000..73b29336 --- /dev/null +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/ChaptersDialog.kt @@ -0,0 +1,73 @@ +package ac.mdiq.podcini.ui.compose + +import ac.mdiq.podcini.R +import ac.mdiq.podcini.playback.base.MediaPlayerBase +import ac.mdiq.podcini.playback.base.PlayerStatus +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause +import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo +import ac.mdiq.podcini.storage.model.Playable +import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLocalized +import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@Composable +fun ChaptersDialog(media: Playable, onDismissRequest: () -> Unit) { + val lazyListState = rememberLazyListState() + val chapters = media.getChapters() + val textColor = MaterialTheme.colorScheme.onSurface + Dialog(onDismissRequest = onDismissRequest) { + Surface(shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(stringResource(R.string.chapters_label)) + var currentChapterIndex by remember { mutableIntStateOf(-1) } + LazyColumn(state = lazyListState, modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(chapters.size, key = {index -> chapters[index].start}) { index -> + val ch = chapters[index] + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { +// if (!ch.imageUrl.isNullOrEmpty()) { +// val imgUrl = ch.imageUrl +// AsyncImage(model = imgUrl, contentDescription = "imgvCover", +// placeholder = painterResource(R.mipmap.ic_launcher), +// error = painterResource(R.mipmap.ic_launcher), +// modifier = Modifier.width(56.dp).height(56.dp)) +// } + Column(modifier = Modifier.weight(1f)) { + Text(getDurationStringLong(ch.start.toInt()), color = textColor) + Text(ch.title ?: "No title", color = textColor, fontWeight = FontWeight.Bold) +// Text(ch.link?: "") + val duration = if (index + 1 < chapters.size) chapters[index + 1].start - ch.start + else (media.getDuration() ?: 0) - ch.start + Text(stringResource(R.string.chapter_duration0) + getDurationStringLocalized(LocalContext.current, duration), color = textColor) + } + val playRes = if (index == currentChapterIndex) R.drawable.ic_replay else R.drawable.ic_play_48dp + Icon(painter = painterResource(playRes), tint = textColor, contentDescription = "play button", + modifier = Modifier.width(28.dp).height(32.dp).clickable { + if (MediaPlayerBase.status != PlayerStatus.PLAYING) playPause() + seekTo(ch.start.toInt()) + currentChapterIndex = index + }) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt index 79c50746..eb962f1e 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/compose/EpisodesVM.kt @@ -475,8 +475,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList, } val velocityTracker = remember { VelocityTracker() } val offsetX = remember { Animatable(0f) } - Box( - modifier = Modifier.fillMaxWidth().pointerInput(Unit) { + Box(modifier = Modifier.fillMaxWidth().pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = { velocityTracker.resetTracking() }, onHorizontalDrag = { change, dragAmount -> diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt index 3a33a761..95710dbc 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/CustomFeedNameDialog.kt @@ -30,7 +30,6 @@ class CustomFeedNameDialog(activity: Activity, private var feed: Feed) { .setTitle(R.string.rename_feed_label) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> val newTitle = binding.editText.text.toString() -// feed = unmanaged(feed) feed = upsertBlk(feed) { it.setCustomTitle1(newTitle) } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt index d8866aec..0167aacd 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/dialog/SwipeActionsDialog.kt @@ -96,17 +96,11 @@ class SwipeActionsDialog(private val context: Context, private val tag: String) view.swipeDirectionLabel.setText(if (direction == LEFT) R.string.swipe_left else R.string.swipe_right) view.swipeActionLabel.text = action!!.getTitle(context) - populateMockEpisode(view.mockEpisode) - if (direction == RIGHT && view.previewContainer.getChildAt(0) !== view.swipeIcon) { - view.previewContainer.removeView(view.swipeIcon) - view.previewContainer.addView(view.swipeIcon, 0) - } view.swipeIcon.setImageResource(action.getActionIcon()) view.swipeIcon.setColorFilter(getColorFromAttr(context, action.getActionColor())) view.changeButton.setOnClickListener { showPicker(view, direction) } - view.previewContainer.setOnClickListener { showPicker(view, direction) } } private fun showPicker(view: SwipeactionsRowBinding, direction: Int) { @@ -148,14 +142,6 @@ class SwipeActionsDialog(private val context: Context, private val tag: String) picker.pickerGridLayout.rowCount = (keys.size + 1) / 2 } - private fun populateMockEpisode(view: FeeditemlistItemBinding) { - view.container.alpha = 0.3f - view.secondaryActionButton.secondaryAction.visibility = View.GONE - view.dragHandle.visibility = View.GONE - view.txtvTitle.text = "███████" - view.txtvPosition.text = "█████" - } - private fun savePrefs(tag: String, right: String?, left: String?) { getSharedPrefs(context) SwipeActions.prefs!!.edit().putString(SwipeActions.KEY_PREFIX_SWIPEACTIONS + tag, "$right,$left").apply() 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 0b236af5..c2d0cdd0 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 @@ -1,5 +1,6 @@ package ac.mdiq.podcini.ui.fragment +//import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.AudioplayerFragmentBinding import ac.mdiq.podcini.net.utils.NetworkUtils.fetchHtmlSource @@ -32,10 +33,10 @@ import ac.mdiq.podcini.storage.utils.ChapterUtils import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.TimeSpeedConverter -//import ac.mdiq.podcini.ui.actions.EpisodeMenuHandler import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.activity.VideoplayerActivity.Companion.videoMode import ac.mdiq.podcini.ui.activity.starter.VideoPlayerActivityStarter +import ac.mdiq.podcini.ui.compose.ChaptersDialog import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.dialog.* @@ -55,13 +56,16 @@ import android.widget.Toast import androidx.appcompat.widget.Toolbar import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.material3.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.app.ShareCompat @@ -87,9 +91,6 @@ import java.text.NumberFormat import kotlin.math.max import kotlin.math.min -/** - * Shows the audio player. - */ @UnstableApi class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { var _binding: AudioplayerFragmentBinding? = null @@ -123,8 +124,6 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var shownotesCleaner: ShownotesCleaner? = null - private var displayedChapterIndex = -1 - private var cleanedNotes by mutableStateOf(null) private var isLoading = false private var homeText: String? = null @@ -132,9 +131,11 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var readerhtml: String? = null private var txtvPodcastTitle by mutableStateOf("") private var episodeDate by mutableStateOf("") - private var chapterControlVisible by mutableStateOf(false) +// private var chapterControlVisible by mutableStateOf(false) private var hasNextChapter by mutableStateOf(true) var rating by mutableStateOf(currentItem?.rating ?: 0) + + private var displayedChapterIndex by mutableIntStateOf(-1) private val currentChapter: Chapter? get() { if (currentMedia == null || currentMedia!!.getChapters().isEmpty() || displayedChapterIndex == -1) return null @@ -337,6 +338,8 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (showChooseRatingDialog) ChooseRatingDialog(listOf(currentItem!!)) { showChooseRatingDialog = false } + var showChaptersDialog by remember { mutableStateOf(false) } + if (showChaptersDialog) ChaptersDialog(media = currentMedia!!, onDismissRequest = {showChaptersDialog = false}) val scrollState = rememberScrollState() Column(modifier = Modifier.fillMaxWidth().verticalScroll(scrollState)) { @@ -358,10 +361,13 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { } } }, onLongClick = { copyText(currentMedia?.getFeedTitle()?:"") })) - Row(modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp), ) { + Row(modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 2.dp)) { Spacer(modifier = Modifier.weight(0.2f)) - var ratingIconRes = Rating.fromCode(rating).res - Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", modifier = Modifier.width(15.dp).height(15.dp).clickable(onClick = { + val ratingIconRes = Rating.fromCode(rating).res + Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.tertiary, contentDescription = "rating", + modifier = Modifier.background(MaterialTheme.colorScheme.tertiaryContainer).width(24.dp).height(24.dp).clickable(onClick = { showChooseRatingDialog = true })) Spacer(modifier = Modifier.weight(0.4f)) @@ -370,26 +376,26 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { } Text(titleText, textAlign = TextAlign.Center, color = textColor, style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 5.dp) .combinedClickable(onClick = {}, onLongClick = { copyText(currentItem?.title?:"") })) - fun restoreFromPreference(): Boolean { - if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false - Logd(TAG, "Restoring from preferences") - val activity: Activity? = activity - if (activity != null) { - val id = prefs!!.getString(PREF_PLAYABLE_ID, "") - val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) - if (scrollY != -1) { - if (id == curMedia?.getIdentifier()?.toString()) { - Logd(TAG, "Restored scroll Position: $scrollY") -// binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) - return true - } - Logd(TAG, "reset scroll Position: 0") -// binding.itemDescriptionFragment.scrollTo(0, 0) - return true - } - } - return false - } +// fun restoreFromPreference(): Boolean { +// if ((activity as MainActivity).bottomSheet.state != BottomSheetBehavior.STATE_EXPANDED) return false +// Logd(TAG, "Restoring from preferences") +// val activity: Activity? = activity +// if (activity != null) { +// val id = prefs!!.getString(PREF_PLAYABLE_ID, "") +// val scrollY = prefs!!.getInt(PREF_SCROLL_Y, -1) +// if (scrollY != -1) { +// if (id == curMedia?.getIdentifier()?.toString()) { +// Logd(TAG, "Restored scroll Position: $scrollY") +//// binding.itemDescriptionFragment.scrollTo(binding.itemDescriptionFragment.scrollX, scrollY) +// return true +// } +// Logd(TAG, "reset scroll Position: 0") +//// binding.itemDescriptionFragment.scrollTo(0, 0) +// return true +// } +// } +// return false +// } AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> ShownotesWebView(context).apply { setTimecodeSelectedListener { time: Int -> seekTo(time) } @@ -402,14 +408,19 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "AndroidView update: $cleanedNotes") webView.loadDataWithBaseURL("https://127.0.0.1", cleanedNotes?:"No notes", "text/html", "utf-8", "about:blank") }) - if (chapterControlVisible) { - Row { + if (displayedChapterIndex >= 0) { + Row(modifier = Modifier.padding(start = 20.dp, end = 20.dp), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { Icon(painter = painterResource(R.drawable.ic_chapter_prev), tint = textColor, contentDescription = "prev_chapter", - modifier = Modifier.width(36.dp).height(36.dp).padding(end = 10.dp).clickable(onClick = { seekToPrevChapter() })) - Text(stringResource(id = R.string.chapters_label), modifier = Modifier.weight(1f) - .clickable(onClick = { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) })) - if (hasNextChapter) Icon(painter = painterResource(R.drawable.ic_chapter_prev), tint = textColor, contentDescription = "prev_chapter", - modifier = Modifier.width(36.dp).height(36.dp).padding(end = 10.dp).clickable(onClick = { seekToNextChapter() })) + modifier = Modifier.width(36.dp).height(36.dp).clickable(onClick = { seekToPrevChapter() })) + Text("Ch " + displayedChapterIndex.toString() + ": " + currentChapter?.title, + color = textColor, style = MaterialTheme.typography.bodyMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(start = 10.dp, end = 10.dp) +// .clickable(onClick = { ChaptersFragment().show(childFragmentManager, ChaptersFragment.TAG) })) + .clickable(onClick = { showChaptersDialog = true })) + if (hasNextChapter) Icon(painter = painterResource(R.drawable.ic_chapter_next), tint = textColor, contentDescription = "next_chapter", + modifier = Modifier.width(36.dp).height(36.dp).clickable(onClick = { seekToNextChapter() })) } } AsyncImage(model = imgLoc, contentDescription = "imgvCover", placeholder = painterResource(R.mipmap.ic_launcher), error = painterResource(R.mipmap.ic_launcher), @@ -419,7 +430,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { } fun setIsShowPlay(showPlay: Boolean) { - Logd(TAG, "setIsShowPlay: ${isShowPlay} $showPlay") + Logd(TAG, "setIsShowPlay: $isShowPlay $showPlay") if (isShowPlay != showPlay) { isShowPlay = showPlay playButRes = when { @@ -492,9 +503,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { if (isPlayingVideoLocally && (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy != VideoMode.AUDIO_ONLY) { (activity as MainActivity).bottomSheet.setLocked(true) (activity as MainActivity).bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED) - } else { - (activity as MainActivity).bottomSheet.setLocked(false) - } + } else (activity as MainActivity).bottomSheet.setLocked(false) prevMedia = media } @@ -512,6 +521,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { homeText = null } if (currentItem != null) { + rating = currentItem!!.rating currentMedia = currentItem!!.media if (prevItem?.identifyingValue != currentItem!!.identifyingValue) cleanedNotes = null Logd(TAG, "updateInfo ${cleanedNotes == null} ${prevItem?.identifyingValue} ${currentItem!!.identifyingValue}") @@ -588,25 +598,19 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { titleText = currentItem?.title ?:"" displayedChapterIndex = -1 refreshChapterData(ChapterUtils.getCurrentChapterIndex(media, media.getPosition())) //calls displayCoverImage - updateChapterControlVisibility() +// updateChapterControlVisibility() } - private fun updateChapterControlVisibility() { - when { - currentMedia?.getChapters() != null -> chapterControlVisible = currentMedia!!.getChapters().isNotEmpty() - currentMedia is EpisodeMedia -> { - val item_ = (currentMedia as EpisodeMedia).episodeOrFetch() - // If an item has chapters but they are not loaded yet, still display the button. - chapterControlVisible = !item_?.chapters.isNullOrEmpty() - } - } -// val newVisibility = if (chapterControlVisible) View.VISIBLE else View.GONE -// if (binding.chapterButton.visibility != newVisibility) { -// binding.chapterButton.visibility = newVisibility -// ObjectAnimator.ofFloat(binding.chapterButton, "alpha", -// (if (chapterControlVisible) 0 else 1).toFloat(), (if (chapterControlVisible) 1 else 0).toFloat()).start() -// } - } +// private fun updateChapterControlVisibility() { +//// when { +//// currentMedia?.getChapters() != null -> chapterControlVisible = currentMedia!!.getChapters().isNotEmpty() +//// currentMedia is EpisodeMedia -> { +//// val item_ = (currentMedia as EpisodeMedia).episodeOrFetch() +//// // If an item has chapters but they are not loaded yet, still display the button. +//// chapterControlVisible = !item_?.chapters.isNullOrEmpty() +//// } +//// } +// } private fun refreshChapterData(chapterIndex: Int) { Logd(TAG, "in refreshChapterData $chapterIndex") @@ -783,6 +787,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { Logd(TAG, "setItem ${item_.title}") if (currentItem?.identifyingValue != item_.identifyingValue) { currentItem = item_ + rating = currentItem!!.rating showHomeText = false homeText = null } @@ -934,7 +939,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { val isEpisodeMedia = currentMedia is EpisodeMedia toolbar.menu?.findItem(R.id.open_feed_item)?.setVisible(isEpisodeMedia) - val item = if (isEpisodeMedia) (currentMedia as EpisodeMedia).episodeOrFetch() else null +// val item = if (isEpisodeMedia) (currentMedia as EpisodeMedia).episodeOrFetch() else null // EpisodeMenuHandler.onPrepareMenu(toolbar.menu, item) val mediaType = curMedia?.getMediaType() @@ -995,10 +1000,10 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener { return true } - fun scrollToTop() { -// binding.itemDescriptionFragment.scrollTo(0, 0) - savePreference() - } +// fun scrollToTop() { +//// binding.itemDescriptionFragment.scrollTo(0, 0) +// savePreference() +// } fun fadePlayerToToolbar(slideOffset: Float) { val playerFadeProgress = (max(0.0, min(0.2, (slideOffset - 0.2f).toDouble())) / 0.2f).toFloat() diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt deleted file mode 100644 index 9441f06b..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/ChaptersFragment.kt +++ /dev/null @@ -1,324 +0,0 @@ -package ac.mdiq.podcini.ui.fragment - -import ac.mdiq.podcini.R -import ac.mdiq.podcini.databinding.SimpleListFragmentBinding -import ac.mdiq.podcini.databinding.SimplechapterItemBinding -import ac.mdiq.podcini.playback.ServiceStatusHandler -import ac.mdiq.podcini.playback.base.MediaPlayerBase -import ac.mdiq.podcini.playback.base.PlayerStatus -import ac.mdiq.podcini.storage.model.EpisodeMedia -import ac.mdiq.podcini.storage.model.Playable -import ac.mdiq.podcini.storage.utils.ChapterUtils.getCurrentChapterIndex -import ac.mdiq.podcini.storage.utils.ChapterUtils.loadChapters -import ac.mdiq.podcini.playback.base.InTheatre.curMedia -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.curPositionFB -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.playPause -import ac.mdiq.podcini.playback.service.PlaybackService.Companion.seekTo -import ac.mdiq.podcini.storage.model.Chapter -import ac.mdiq.podcini.storage.model.EmbeddedChapterImage -import ac.mdiq.podcini.ui.view.CircularProgressBar -import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLocalized -import ac.mdiq.podcini.storage.utils.DurationConverter.getDurationStringLong -import ac.mdiq.podcini.util.IntentUtils.openInBrowser -import ac.mdiq.podcini.util.Logd -import ac.mdiq.podcini.util.EventFlow -import ac.mdiq.podcini.util.FlowEvent -import android.app.Dialog -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatDialogFragment -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import androidx.media3.common.util.UnstableApi -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import coil.ImageLoader -import coil.load -import coil.request.ImageRequest -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.elevation.SurfaceColors -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.collectLatest -import kotlin.math.max -import kotlin.math.min - -@UnstableApi -class ChaptersFragment : AppCompatDialogFragment() { - - private var _binding: SimpleListFragmentBinding? = null - private val binding get() = _binding!! - - private lateinit var layoutManager: LinearLayoutManager - private lateinit var progressBar: ProgressBar - private lateinit var adapter: ChaptersListAdapter - - private var controller: ServiceStatusHandler? = null - private var focusedChapter = -1 - private var media: Playable? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.chapters_label)) - .setView(onCreateView(layoutInflater)) - .setPositiveButton(getString(R.string.close_label), null) //dismisses - .setNeutralButton(getString(R.string.refresh_label), null) - .create() - dialog.show() - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.INVISIBLE - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener { - progressBar.visibility = View.VISIBLE - loadMediaInfo(true) - } - return dialog - } - - fun onCreateView(inflater: LayoutInflater): View { - _binding = SimpleListFragmentBinding.inflate(inflater) - binding.toolbar.visibility = View.GONE - - Logd(TAG, "fragment onCreateView") - val recyclerView = binding.recyclerView - progressBar = binding.progLoading - layoutManager = LinearLayoutManager(activity) - recyclerView.layoutManager = layoutManager - recyclerView.addItemDecoration(DividerItemDecoration(recyclerView.context, layoutManager.orientation)) - - adapter = ChaptersListAdapter(requireContext(), object : ChaptersListAdapter.Callback { - override fun onPlayChapterButtonClicked(pos: Int) { - if (MediaPlayerBase.status != PlayerStatus.PLAYING) playPause() - - val chapter = adapter.getItem(pos) - if (chapter != null) seekTo(chapter.start.toInt()) - updateChapterSelection(pos, true) - } - }) - recyclerView.adapter = adapter - progressBar.visibility = View.VISIBLE - val wrapHeight = CoordinatorLayout.LayoutParams(CoordinatorLayout.LayoutParams.MATCH_PARENT, CoordinatorLayout.LayoutParams.WRAP_CONTENT) - recyclerView.layoutParams = wrapHeight - controller = object : ServiceStatusHandler(requireActivity()) { - override fun loadMediaInfo() { - this@ChaptersFragment.loadMediaInfo(false) - } - } - controller?.init() - return binding.root - } - - override fun onStart() { - super.onStart() - procFlowEvents() - loadMediaInfo(false) - } - - override fun onStop() { - super.onStop() - cancelFlowEvents() - } - - override fun onDestroyView() { - Logd(TAG, "onDestroyView") - _binding = null - controller?.release() - controller = null - super.onDestroyView() - } - - private var eventSink: Job? = null - private fun cancelFlowEvents() { - eventSink?.cancel() - eventSink = null - } - private fun procFlowEvents() { - if (eventSink != null) return - eventSink = lifecycleScope.launch { - EventFlow.events.collectLatest { event -> - Logd(TAG, "Received event: ${event.TAG}") - when (event) { - is FlowEvent.PlaybackPositionEvent -> onEventMainThread(event) - else -> {} - } - } - } - } - - fun onEventMainThread(event: FlowEvent.PlaybackPositionEvent) { - if (event.media?.getIdentifier() != media?.getIdentifier()) return - updateChapterSelection(getCurrentChapter(media), false) - adapter.notifyTimeChanged(event.position.toLong()) - } - - private fun getCurrentChapter(media: Playable?): Int { - if (controller == null) return -1 - return getCurrentChapterIndex(media, curPositionFB) - } - - private fun loadMediaInfo(forceRefresh: Boolean) { - lifecycleScope.launch { - val media = withContext(Dispatchers.IO) { - val media_ = curMedia - if (media_ != null) loadChapters(media_, requireContext(), forceRefresh) - media_ - } - onMediaChanged(media as Playable) - }.invokeOnCompletion { throwable -> - if (throwable!= null) Logd(TAG, Log.getStackTraceString(throwable)) - } - } - - private fun onMediaChanged(media: Playable) { - this.media = media - focusedChapter = -1 - - if (media.getChapters().isEmpty()) { - dismiss() - Toast.makeText(context, R.string.no_chapters_label, Toast.LENGTH_LONG).show() - } else progressBar.visibility = View.GONE - adapter.setMedia(media) - (dialog as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.INVISIBLE - if ((media is EpisodeMedia) && !media.episodeOrFetch()?.podcastIndexChapterUrl.isNullOrEmpty()) - (dialog as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL).visibility = View.VISIBLE - - val positionOfCurrentChapter = getCurrentChapter(media) - updateChapterSelection(positionOfCurrentChapter, true) - } - - private fun updateChapterSelection(position: Int, scrollTo: Boolean) { - if (position != -1 && focusedChapter != position) { - focusedChapter = position - adapter.notifyChapterChanged(focusedChapter) - if (scrollTo && (layoutManager.findFirstCompletelyVisibleItemPosition() >= position - || layoutManager.findLastCompletelyVisibleItemPosition() <= position)) { - layoutManager.scrollToPositionWithOffset(position, 100) - } - } - } - - class ChaptersListAdapter(private val context: Context, private val callback: Callback?) : RecyclerView.Adapter() { - private var media: Playable? = null - private var currentChapterIndex = -1 - private var currentChapterPosition: Long = -1 - private var hasImages = false - - fun setMedia(media: Playable) { - this.media = media - hasImages = false - for (chapter in media.getChapters()) { - if (!chapter.imageUrl.isNullOrEmpty()) hasImages = true - } - notifyDataSetChanged() - } - - override fun onBindViewHolder(holder: ChapterHolder, position: Int) { - val sc = getItem(position)?: return - holder.title.text = sc.title - holder.start.text = getDurationStringLong(sc.start.toInt()) - val duration = if (position + 1 < itemCount) media!!.getChapters()[position + 1].start - sc.start - else (media?.getDuration()?:0) - sc.start - - holder.duration.text = context.getString(R.string.chapter_duration, - getDurationStringLocalized(context, duration.toInt().toLong())) - - if (sc.link.isNullOrEmpty()) { - holder.link.visibility = View.GONE - } else { - holder.link.visibility = View.VISIBLE - holder.link.text = sc.link - holder.link.setOnClickListener { - if (sc.link!=null) openInBrowser(context, sc.link!!) - } - } - holder.secondaryActionIcon.setImageResource(R.drawable.ic_play_48dp) - holder.secondaryActionButton.contentDescription = context.getString(R.string.play_chapter) - holder.secondaryActionButton.setOnClickListener { - callback?.onPlayChapterButtonClicked(position) - } - - if (position == currentChapterIndex) { - val density = context.resources.displayMetrics.density - holder.itemView.setBackgroundColor(SurfaceColors.getColorForElevation(context, 32 * density)) - var progress = ((currentChapterPosition - sc.start).toFloat()) / duration - progress = max(progress.toDouble(), CircularProgressBar.MINIMUM_PERCENTAGE.toDouble()).toFloat() - progress = min(progress.toDouble(), CircularProgressBar.MAXIMUM_PERCENTAGE.toDouble()).toFloat() - holder.progressBar.setPercentage(progress, position) - holder.secondaryActionIcon.setImageResource(R.drawable.ic_replay) - } else { - holder.itemView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent)) - holder.progressBar.setPercentage(0f, null) - } - - if (hasImages) { - holder.image.visibility = View.VISIBLE - if (sc.imageUrl.isNullOrEmpty()) { - val imageLoader = ImageLoader.Builder(context).build() - imageLoader.enqueue(ImageRequest.Builder(context).data(null).target(holder.image).build()) - } else { - if (media != null) { - val imgUrl = EmbeddedChapterImage.getModelFor(media!!,position) - holder.image.load(imgUrl) - } - } - } else holder.image.visibility = View.GONE - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChapterHolder { - val inflater = LayoutInflater.from(context) - return ChapterHolder(inflater.inflate(R.layout.simplechapter_item, parent, false)) - } - - override fun getItemCount(): Int { - return media?.getChapters()?.size?:0 - } - - class ChapterHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val binding: SimplechapterItemBinding = SimplechapterItemBinding.bind(itemView) - val title: TextView = binding.txtvTitle - val start: TextView = binding.txtvStart - val link: TextView = binding.txtvLink - val duration: TextView = binding.txtvDuration - val image: ImageView = binding.imgvCover - val secondaryActionButton: View = binding.secondaryActionLayout.secondaryAction - val secondaryActionIcon: ImageView = binding.secondaryActionLayout.secondaryActionIcon - val progressBar: CircularProgressBar = binding.secondaryActionLayout.secondaryActionProgress - } - - fun notifyChapterChanged(newChapterIndex: Int) { - currentChapterIndex = newChapterIndex - currentChapterPosition = getItem(newChapterIndex)?.start?:0 - notifyDataSetChanged() - } - - fun notifyTimeChanged(timeMs: Long) { - currentChapterPosition = timeMs - // Passing an argument prevents flickering. - // See EpisodeItemListAdapter.notifyItemChangedCompat. - notifyItemChanged(currentChapterIndex, "foo") - } - - fun getItem(position: Int): Chapter? { - val chapters = media?.getChapters()?: return null - if (position < 0 || position >= chapters.size) return null - return chapters[position] - } - - interface Callback { - fun onPlayChapterButtonClicked(position: Int) - } - } - - companion object { - val TAG = ChaptersFragment::class.simpleName ?: "Anonymous" - } -} 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 1a368995..e37999d7 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 @@ -32,6 +32,7 @@ import ac.mdiq.podcini.storage.utils.DurationConverter import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.ui.actions.* import ac.mdiq.podcini.ui.activity.MainActivity +import ac.mdiq.podcini.ui.compose.ChaptersDialog import ac.mdiq.podcini.ui.compose.ChooseRatingDialog import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.LargeTextEditingDialog @@ -59,24 +60,24 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.OptIn import androidx.appcompat.widget.Toolbar -import androidx.compose.foundation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.core.app.ShareCompat import androidx.core.text.HtmlCompat import androidx.core.view.MenuProvider @@ -91,8 +92,11 @@ import com.skydoves.balloon.ArrowOrientation import com.skydoves.balloon.ArrowOrientationRules import com.skydoves.balloon.Balloon import com.skydoves.balloon.BalloonAnimation -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.dankito.readability4j.extended.Readability4JExtended import okhttp3.Request.Builder import java.io.File @@ -117,9 +121,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private var txtvSize by mutableStateOf("") private var txtvDuration by mutableStateOf("") private var itemLink by mutableStateOf("") - var hasMedia by mutableStateOf(true) + private var hasMedia by mutableStateOf(true) var rating by mutableStateOf(episode?.rating ?: Rating.UNRATED.code) - var inQueue by mutableStateOf(if (episode != null) curQueue.contains(episode!!) else false) + private var inQueue by mutableStateOf(if (episode != null) curQueue.contains(episode!!) else false) var isPlayed by mutableStateOf(episode?.isPlayed() ?: false) private var webviewData by mutableStateOf("") @@ -179,6 +183,9 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { showChooseRatingDialog = false } + var showChaptersDialog by remember { mutableStateOf(false) } + if (showChaptersDialog && episode?.media != null) ChaptersDialog(media = episode!!.media!!, onDismissRequest = {showChaptersDialog = false}) + Column { Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null @@ -284,6 +291,8 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { }, update = { it.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") }) + if (!episode?.chapters.isNullOrEmpty()) Text(stringResource(id = R.string.chapters_label), color = textColor, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 15.dp, top = 10.dp, bottom = 5.dp).clickable(onClick = { showChaptersDialog = true })) Text(stringResource(R.string.my_opinion_label) + if (commentTextState.text.isEmpty()) " (Add)" else "", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 15.dp, top = 10.dp, bottom = 5.dp).clickable { showEditComment = true }) @@ -473,14 +482,14 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { private fun updateButtons() { // binding.circularProgressBar.visibility = View.GONE val dls = DownloadServiceInterface.get() - if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { - val url = episode!!.media!!.downloadUrl!! -// if (dls != null && dls.isDownloadingEpisode(url)) { -// binding.circularProgressBar.visibility = View.VISIBLE -// binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) -// binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) -// } - } +// if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { +// val url = episode!!.media!!.downloadUrl!! +//// if (dls != null && dls.isDownloadingEpisode(url)) { +//// binding.circularProgressBar.visibility = View.VISIBLE +//// binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) +//// binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) +//// } +// } val media: EpisodeMedia? = episode?.media if (media == null) { @@ -610,7 +619,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { lifecycleScope.launch { try { withContext(Dispatchers.IO) { - if (episode != null) episode = realm.query(Episode::class).query("id == $0", episode!!.id).first().find() + if (episode != null && !episode!!.isRemote.value) episode = realm.query(Episode::class).query("id == $0", episode!!.id).first().find() if (episode != null) { val duration = episode!!.media?.getDuration() ?: Int.MAX_VALUE Logd(TAG, "description: ${episode?.description}") @@ -629,9 +638,12 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } withContext(Dispatchers.Main) { // binding.progbarLoading.visibility = View.GONE - rating = episode!!.rating - inQueue = curQueue.contains(episode!!) - isPlayed = episode!!.isPlayed() + Logd(TAG, "chapters: ${episode?.chapters?.size}") + if (episode != null) { + rating = episode!!.rating + inQueue = curQueue.contains(episode!!) + isPlayed = episode!!.isPlayed() + } onFragmentLoaded() itemLoaded = true } diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt index b09dda31..105b772b 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedEpisodesFragment.kt @@ -21,7 +21,10 @@ import ac.mdiq.podcini.ui.actions.SwipeAction import ac.mdiq.podcini.ui.actions.SwipeActions import ac.mdiq.podcini.ui.activity.MainActivity import ac.mdiq.podcini.ui.compose.* -import ac.mdiq.podcini.ui.dialog.* +import ac.mdiq.podcini.ui.dialog.CustomFeedNameDialog +import ac.mdiq.podcini.ui.dialog.EpisodeFilterDialog +import ac.mdiq.podcini.ui.dialog.EpisodeSortDialog +import ac.mdiq.podcini.ui.dialog.SwitchQueueDialog import ac.mdiq.podcini.ui.utils.TransitionEffect import ac.mdiq.podcini.util.* import android.app.Activity @@ -35,6 +38,7 @@ import androidx.annotation.OptIn 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.Text import androidx.compose.runtime.* @@ -42,11 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.RenderEffect -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -247,11 +247,11 @@ import java.util.concurrent.Semaphore start.linkTo(parent.start) }, verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Modifier.weight(1f)) - Image(painter = painterResource(R.drawable.ic_filter_white), colorFilter = ColorFilter.tint(filterButColor), contentDescription = "butFilter", + Icon(painter = painterResource(R.drawable.ic_filter_white), tint = if (filterButColor == Color.White) textColor else filterButColor, contentDescription = "butFilter", modifier = Modifier.width(40.dp).height(40.dp).padding(3.dp).combinedClickable(onClick = filterClickCB, onLongClick = filterLongClickCB)) Spacer(modifier = Modifier.width(15.dp)) - Image(painter = painterResource(R.drawable.ic_settings_white), contentDescription = "butShowSettings", - Modifier.width(40.dp).height(40.dp).padding(3.dp).clickable(onClick = { + Icon(painter = painterResource(R.drawable.ic_settings_white), tint = textColor, contentDescription = "butShowSettings", + modifier = Modifier.width(40.dp).height(40.dp).padding(3.dp).clickable(onClick = { if (feed != null) { val fragment = FeedSettingsFragment.newInstance(feed) activity.loadChildFragment(fragment, TransitionEffect.SLIDE) @@ -260,16 +260,16 @@ import java.util.concurrent.Semaphore Spacer(modifier = Modifier.weight(1f)) Text(feed?.episodes?.size?.toString()?:"", textAlign = TextAlign.Center, color = Color.White, style = MaterialTheme.typography.bodyLarge) } - Image(painter = painterResource(R.drawable.ic_rounded_corner_left), contentDescription = "left_corner", - Modifier.width(12.dp).height(12.dp).constrainAs(image1) { - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }) - Image(painter = painterResource(R.drawable.ic_rounded_corner_right), contentDescription = "right_corner", - Modifier.width(12.dp).height(12.dp).constrainAs(image2) { - bottom.linkTo(parent.bottom) - end.linkTo(parent.end) - }) +// Image(painter = painterResource(R.drawable.ic_rounded_corner_left), contentDescription = "left_corner", +// Modifier.width(12.dp).height(12.dp).constrainAs(image1) { +// bottom.linkTo(parent.bottom) +// start.linkTo(parent.start) +// }) +// Image(painter = painterResource(R.drawable.ic_rounded_corner_right), contentDescription = "right_corner", +// Modifier.width(12.dp).height(12.dp).constrainAs(image2) { +// bottom.linkTo(parent.bottom) +// end.linkTo(parent.end) +// }) AsyncImage(model = feed?.imageUrl?:"", contentDescription = "imgvCover", error = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) { bottom.linkTo(parent.bottom) @@ -280,7 +280,7 @@ import java.util.concurrent.Semaphore activity.loadChildFragment(fragment, TransitionEffect.SLIDE) } })) - Column(Modifier.constrainAs(taColumn) { + Column(Modifier.fillMaxWidth().constrainAs(taColumn) { top.linkTo(imgvCover.top) start.linkTo(imgvCover.end) }) { Text(feed?.title?:"", color = textColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt index 73d981f5..f5b0f9a3 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedInfoFragment.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -103,21 +102,10 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { toolbar.setOnMenuItemClickListener(this) refreshToolbarState() -// val appBar: AppBarLayout = binding.appBar -// val collapsingToolbar: CollapsingToolbarLayout = binding.collapsingToolbar -// val iconTintManager: ToolbarIconTintManager = object : ToolbarIconTintManager(requireContext(), toolbar, collapsingToolbar) { -// override fun doTint(themedContext: Context) { -// toolbar.menu.findItem(R.id.visit_website_item).setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_web)) -// toolbar.menu.findItem(R.id.share_item).setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_share)) -// } -// } -// iconTintManager.updateTint() -// appBar.addOnOffsetChangedListener(iconTintManager) - txtvAuthor = feed.author ?: "" txtvUrl = feed.downloadUrl - binding.detailUI.setContent { + binding.mainUI.setContent { CustomTheme(requireContext()) { if (showRemoveFeedDialog) RemoveFeedDialog(listOf(feed), onDismissRequest = {showRemoveFeedDialog = false}) { (activity as MainActivity).loadFragment(UserPreferences.defaultPage, null) @@ -161,7 +149,7 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } ConstraintLayout(modifier = Modifier.fillMaxWidth().height(130.dp)) { val (bgImage, bgColor, controlRow, image1, image2, imgvCover, taColumn) = createRefs() - AsyncImage(model = feed?.imageUrl?:"", contentDescription = "bgImage", contentScale = ContentScale.FillBounds, + AsyncImage(model = feed.imageUrl ?:"", contentDescription = "bgImage", contentScale = ContentScale.FillBounds, error = painterResource(R.drawable.teaser), modifier = Modifier.fillMaxSize().blur(radiusX = 15.dp, radiusY = 15.dp) .constrainAs(bgImage) { @@ -199,16 +187,16 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { } Spacer(modifier = Modifier.width(15.dp)) } - Image(painter = painterResource(R.drawable.ic_rounded_corner_left), contentDescription = "left_corner", - Modifier.width(12.dp).height(12.dp).constrainAs(image1) { - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }) - Image(painter = painterResource(R.drawable.ic_rounded_corner_right), contentDescription = "right_corner", - Modifier.width(12.dp).height(12.dp).constrainAs(image2) { - bottom.linkTo(parent.bottom) - end.linkTo(parent.end) - }) +// Image(painter = painterResource(R.drawable.ic_rounded_corner_left), contentDescription = "left_corner", +// Modifier.width(12.dp).height(12.dp).constrainAs(image1) { +// bottom.linkTo(parent.bottom) +// start.linkTo(parent.start) +// }) +// Image(painter = painterResource(R.drawable.ic_rounded_corner_right), contentDescription = "right_corner", +// Modifier.width(12.dp).height(12.dp).constrainAs(image2) { +// bottom.linkTo(parent.bottom) +// end.linkTo(parent.end) +// }) AsyncImage(model = feed.imageUrl?:"", contentDescription = "imgvCover", error = painterResource(R.mipmap.ic_launcher), modifier = Modifier.width(120.dp).height(120.dp).padding(start = 16.dp, end = 16.dp, bottom = 12.dp).constrainAs(imgvCover) { bottom.linkTo(parent.bottom) @@ -232,18 +220,12 @@ class FeedInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener { fun DetailUI() { val scrollState = rememberScrollState() var showEditComment by remember { mutableStateOf(false) } - var commentTextState by remember { mutableStateOf(TextFieldValue(feed?.comment?:"")) } + var commentTextState by remember { mutableStateOf(TextFieldValue(feed.comment)) } if (showEditComment) LargeTextEditingDialog(textState = commentTextState, onTextChange = { commentTextState = it }, onDismissRequest = {showEditComment = false}, onSave = { runOnIOScope { feed = upsert(feed) { it.comment = commentTextState.text } rating = feed.rating -// val slog = realm.query(SubscriptionLog::class).query("itemId == $0", feed.id).first().find() -// if (slog != null) { -// upsert(slog) { -// it.comment = commentTextState.text -// } -// } } }) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt index 6708b38c..531e2349 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/FeedSettingsFragment.kt @@ -12,6 +12,8 @@ import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences import ac.mdiq.podcini.storage.database.RealmDB.realm import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk import ac.mdiq.podcini.storage.model.* +import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_NATURAL_SYNTHETIC_ID +import ac.mdiq.podcini.storage.model.Feed.Companion.MAX_SYNTHETIC_ID import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDeleteAction import ac.mdiq.podcini.storage.model.FeedPreferences.AutoDownloadPolicy import ac.mdiq.podcini.storage.model.FeedPreferences.Companion.FeedAutoDeleteOptions @@ -96,7 +98,7 @@ class FeedSettingsFragment : Fragment() { CustomTheme(requireContext()) { val textColor = MaterialTheme.colorScheme.onSurface Column(modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - if ((feed?.id ?: 0) > 10) { + if ((feed?.id ?: 0) > MAX_SYNTHETIC_ID) { // refresh Column { Row(Modifier.fillMaxWidth()) { @@ -117,7 +119,7 @@ class FeedSettingsFragment : Fragment() { Text(text = stringResource(R.string.keep_updated_summary), style = MaterialTheme.typography.bodyMedium, color = textColor) } } - if ((feed?.id?:0) > 10 && feed?.hasVideoMedia == true) { + if ((feed?.id?:0) > MAX_NATURAL_SYNTHETIC_ID && feed?.hasVideoMedia == true) { // video mode Column { Row(Modifier.fillMaxWidth()) { diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt index 7850165f..cf85e7d7 100644 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt +++ b/app/src/main/kotlin/ac/mdiq/podcini/ui/fragment/NavDrawerFragment.kt @@ -257,7 +257,6 @@ class NavDrawerFragment : Fragment(), OnSharedPreferenceChangeListener { navMap[LogsFragment.TAG]?.count = realm.query(ShareLog::class).count().find().toInt() + realm.query(SubscriptionLog::class).count().find().toInt() + realm.query(DownloadResult::class).count().find().toInt() -// navMap[SubscriptionLogFragment.TAG]?.count = realm.query(SubscriptionLog::class).count().find().toInt() } } } 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 a6796db7..9585427f 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 @@ -3,6 +3,7 @@ package ac.mdiq.podcini.ui.fragment import ac.mdiq.podcini.R import ac.mdiq.podcini.databinding.* import ac.mdiq.podcini.net.feed.FeedUpdateManager +import ac.mdiq.podcini.playback.base.VideoMode import ac.mdiq.podcini.preferences.OpmlTransporter.OpmlWriter import ac.mdiq.podcini.preferences.UserPreferences import ac.mdiq.podcini.preferences.UserPreferences.appPrefs @@ -318,8 +319,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener { R.id.new_synth_yt -> { val feed = createSynthetic(0, "") feed.type = Feed.FeedType.YOUTUBE.name -// feed.hasVideoMedia = video -// feed.preferences!!.videoModePolicy = if (video) VideoMode.WINDOW_VIEW else VideoMode.AUDIO_ONLY + feed.hasVideoMedia = true + feed.preferences!!.videoModePolicy = VideoMode.WINDOW_VIEW CustomFeedNameDialog(activity as Activity, feed).show() } R.id.refresh_item -> FeedUpdateManager.runOnceOrAsk(requireContext()) diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ChapterSeekBar.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ChapterSeekBar.kt deleted file mode 100644 index c8e1af24..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/ChapterSeekBar.kt +++ /dev/null @@ -1,141 +0,0 @@ -package ac.mdiq.podcini.ui.view - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.os.Handler -import android.os.Looper -import android.util.AttributeSet -import androidx.appcompat.widget.AppCompatSeekBar -import ac.mdiq.podcini.R -import ac.mdiq.podcini.ui.utils.ThemeUtils.getColorFromAttr - -class ChapterSeekBar : AppCompatSeekBar { - private var top = 0f - private var width = 0f - private var center = 0f - private var bottom = 0f - private var density = 0f - private var progressPrimary = 0f - private var progressSecondary = 0f - private var dividerPos: FloatArray? = null - private var isHighlighted = false - private val paintBackground = Paint() - private val paintProgressPrimary = Paint() - - constructor(context: Context) : super(context) { - init(context) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context) - } - - constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) { - init(context) - } - - private fun init(context: Context) { - background = null // Removes the thumb shadow - dividerPos = null - density = context.resources.displayMetrics.density - - paintBackground.color = getColorFromAttr(getContext(), com.google.android.material.R.attr.colorSurfaceVariant) - paintBackground.alpha = 128 - paintProgressPrimary.color = getColorFromAttr(getContext(), androidx.appcompat.R.attr.colorPrimary) - } - - /** - * Sets the relative positions of the chapter dividers. - * @param dividerPos of the chapter dividers relative to the duration of the media. - */ - fun setDividerPos(dividerPos: FloatArray?) { - if (dividerPos != null) { - this.dividerPos = FloatArray(dividerPos.size + 2) - this.dividerPos!![0] = 0f - System.arraycopy(dividerPos, 0, this.dividerPos!!, 1, dividerPos.size) - this.dividerPos!![this.dividerPos!!.size - 1] = 1f - } else this.dividerPos = null - - invalidate() - } - - fun highlightCurrentChapter() { - isHighlighted = true - Handler(Looper.getMainLooper()).postDelayed( - { isHighlighted = false; invalidate() }, - 1000) - } - - @Synchronized - override fun onDraw(canvas: Canvas) { - center = (getBottom() - paddingBottom - getTop() - paddingTop) / 2.0f - top = center - density * 1.5f - bottom = center + density * 1.5f - width = (right - paddingRight - left - paddingLeft).toFloat() - progressSecondary = secondaryProgress / max.toFloat() * width - progressPrimary = progress / max.toFloat() * width - - if (dividerPos == null) drawProgress(canvas) - else drawProgressChapters(canvas) - - drawThumb(canvas) - } - - private fun drawProgress(canvas: Canvas) { - val saveCount = canvas.save() - canvas.translate(paddingLeft.toFloat(), paddingTop.toFloat()) - canvas.drawRect(0f, top, width, bottom, paintBackground) - canvas.drawRect(0f, top, progressSecondary, bottom, paintBackground) - canvas.drawRect(0f, top, progressPrimary, bottom, paintProgressPrimary) - canvas.restoreToCount(saveCount) - } - - private fun drawProgressChapters(canvas: Canvas) { - val saveCount = canvas.save() - var currChapter = 1 - val chapterMargin = density * 1.2f - val topExpanded = center - density * 2.0f - val bottomExpanded = center + density * 2.0f - - canvas.translate(paddingLeft.toFloat(), paddingTop.toFloat()) - - if (dividerPos != null && dividerPos!!.isNotEmpty()) { - for (i in 1 until dividerPos!!.size) { - val right = dividerPos!![i] * width - chapterMargin - val left = dividerPos!![i - 1] * width - val rightCurr = dividerPos!![currChapter] * width - chapterMargin - val leftCurr = dividerPos!![currChapter - 1] * width - - canvas.drawRect(left, top, right, bottom, paintBackground) - - if (progressSecondary > 0 && progressSecondary < width) { - when { - right < progressSecondary -> canvas.drawRect(left, top, right, bottom, paintBackground) - progressSecondary > left -> canvas.drawRect(left, top, progressSecondary, bottom, paintBackground) - } - } - - when { - right < progressPrimary -> { - currChapter = i + 1 - canvas.drawRect(left, top, right, bottom, paintProgressPrimary) - } - isHighlighted || isPressed -> { - canvas.drawRect(leftCurr, topExpanded, rightCurr, bottomExpanded, paintBackground) - canvas.drawRect(leftCurr, topExpanded, progressPrimary, bottomExpanded, paintProgressPrimary) - } - else -> canvas.drawRect(leftCurr, top, progressPrimary, bottom, paintProgressPrimary) - } - } - } - canvas.restoreToCount(saveCount) - } - - private fun drawThumb(canvas: Canvas) { - val saveCount = canvas.save() - canvas.translate((paddingLeft - thumbOffset).toFloat(), paddingTop.toFloat()) - thumb.draw(canvas) - canvas.restoreToCount(saveCount) - } -} diff --git a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt b/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt deleted file mode 100644 index fb6267f7..00000000 --- a/app/src/main/kotlin/ac/mdiq/podcini/ui/view/EpisodesRecyclerView.kt +++ /dev/null @@ -1,81 +0,0 @@ -package ac.mdiq.podcini.ui.view - -import android.content.Context -import android.content.res.Configuration -import android.util.AttributeSet -import androidx.appcompat.view.ContextThemeWrapper -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import ac.mdiq.podcini.R -import android.content.SharedPreferences - -class EpisodesRecyclerView : RecyclerView { - private lateinit var layoutManager: LinearLayoutManager - - val isScrolledToBottom: Boolean - get() { - val visibleEpisodeCount = childCount - val totalEpisodeCount = layoutManager.itemCount - val firstVisibleEpisode = layoutManager.findFirstVisibleItemPosition() - return (totalEpisodeCount - visibleEpisodeCount) <= (firstVisibleEpisode + 3) - } - - constructor(context: Context) : super(ContextThemeWrapper(context, R.style.FastScrollRecyclerView)) { - setup() - } - - constructor(context: Context, attrs: AttributeSet?) : - super(ContextThemeWrapper(context, R.style.FastScrollRecyclerView), attrs) { - setup() - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : - super(ContextThemeWrapper(context, R.style.FastScrollRecyclerView), attrs, defStyleAttr) { - setup() - } - - private fun setup() { - layoutManager = LinearLayoutManager(context) - layoutManager.recycleChildrenOnDetach = true - setLayoutManager(layoutManager) - setHasFixedSize(true) - addItemDecoration(DividerItemDecoration(context, layoutManager.orientation)) - clipToPadding = false - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - val horizontalSpacing = resources.getDimension(R.dimen.additional_horizontal_spacing).toInt() - setPadding(horizontalSpacing, paddingTop, horizontalSpacing, paddingBottom) - } - - fun saveScrollPosition(tag: String) { - val firstItem = layoutManager.findFirstVisibleItemPosition() - val firstItemView = layoutManager.findViewByPosition(firstItem) - val topOffset = firstItemView?.top?.toFloat() ?: 0f - - prefs!!.edit() - .putInt(PREF_PREFIX_SCROLL_POSITION + tag, firstItem) - .putInt(PREF_PREFIX_SCROLL_OFFSET + tag, topOffset.toInt()) - .apply() - } - - fun restoreScrollPosition(tag: String) { - val position = prefs!!.getInt(PREF_PREFIX_SCROLL_POSITION + tag, 0) - val offset = prefs!!.getInt(PREF_PREFIX_SCROLL_OFFSET + tag, 0) - if (position > 0 || offset > 0) layoutManager.scrollToPositionWithOffset(position, offset) - } - - companion object { - private val TAG: String = EpisodesRecyclerView::class.simpleName ?: "Anonymous" - private const val PREF_PREFIX_SCROLL_POSITION = "scroll_position_" - private const val PREF_PREFIX_SCROLL_OFFSET = "scroll_offset_" - - var prefs: SharedPreferences? = null - - fun getSharedPrefs(context: Context) { - if (prefs == null) prefs = context.getSharedPreferences(TAG, Context.MODE_PRIVATE) - } - } -} diff --git a/app/src/main/res/layout/feedinfo.xml b/app/src/main/res/layout/feedinfo.xml index cc7449c1..a2a01973 100644 --- a/app/src/main/res/layout/feedinfo.xml +++ b/app/src/main/res/layout/feedinfo.xml @@ -25,14 +25,10 @@ app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" /> - - - - diff --git a/app/src/main/res/layout/feeditemlist_item.xml b/app/src/main/res/layout/feeditemlist_item.xml deleted file mode 100644 index d85a3078..00000000 --- a/app/src/main/res/layout/feeditemlist_item.xml +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/simple_list_fragment.xml b/app/src/main/res/layout/simple_list_fragment.xml deleted file mode 100644 index a7cc1eec..00000000 --- a/app/src/main/res/layout/simple_list_fragment.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/simplechapter_item.xml b/app/src/main/res/layout/simplechapter_item.xml deleted file mode 100644 index f95777ac..00000000 --- a/app/src/main/res/layout/simplechapter_item.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/swipeactions_row.xml b/app/src/main/res/layout/swipeactions_row.xml index 2e5323e1..f62d5b4c 100644 --- a/app/src/main/res/layout/swipeactions_row.xml +++ b/app/src/main/res/layout/swipeactions_row.xml @@ -1,83 +1,53 @@ - + android:layout_marginTop="16dp" + android:layout_marginBottom="8dp"> - - - - - - -