6.9.2 commit

This commit is contained in:
Xilin Jia 2024-10-08 18:57:28 +01:00
parent ceb6979d4a
commit 614d62dc58
31 changed files with 406 additions and 426 deletions

View File

@ -31,8 +31,8 @@ android {
testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020266
versionName "6.9.1"
versionCode 3020267
versionName "6.9.2"
applicationId "ac.mdiq.podcini.R"
def commit = ""
@ -174,6 +174,7 @@ dependencies {
implementation libs.androidx.material3
// implementation libs.androidx.ui.viewbinding
implementation libs.androidx.fragment.compose
implementation libs.androidx.material.icons.extended
/** Desugaring for using VistaGuide **/
coreLibraryDesugaring libs.desugar.jdk.libs.nio

View File

@ -77,6 +77,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
e.feedId = feed_.id
eList.add(e)
}
feed_.episodes.addAll(eList)
if (nextPage == null || feed_.episodes.size > 1000) break
try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break
@ -88,7 +89,6 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
}
@ -123,6 +123,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
e.feedId = feed_.id
eList.add(e)
}
feed_.episodes.addAll(eList)
if (nextPage == null || feed_.episodes.size > 1000) break
try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage)
@ -134,7 +135,6 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
withContext(Dispatchers.Main) { showError(e.message, "") }
break
}
feed_.episodes.addAll(eList)
}
feed_.isBuilding = false
}

View File

@ -1,31 +1,26 @@
package ac.mdiq.podcini.net.feed
import ac.mdiq.podcini.R
import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.feed.parser.media.id3.ID3ReaderException
import ac.mdiq.podcini.net.feed.parser.media.id3.Id3MetadataReader
import ac.mdiq.podcini.net.feed.parser.media.vorbis.VorbisCommentMetadataReader
import ac.mdiq.podcini.net.feed.parser.media.vorbis.VorbisCommentReaderException
import ac.mdiq.podcini.net.download.DownloadError
import ac.mdiq.podcini.net.feed.parser.utils.DateUtils
import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils
import ac.mdiq.podcini.storage.database.Feeds
import ac.mdiq.podcini.storage.database.LogsAndStats
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.storage.utils.MediaMetadataRetrieverCompat
import ac.mdiq.podcini.storage.model.DownloadResult
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.model.Episode
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.utils.FastDocumentFile
import ac.mdiq.podcini.util.Logd
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.DocumentsContract
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi
import org.apache.commons.io.input.CountingInputStream
import java.io.BufferedInputStream
import java.io.IOException
import java.text.ParseException
@ -227,4 +222,34 @@ object LocalFeedUpdater {
fun interface UpdaterProgressListener {
fun onLocalFileScanned(scanned: Int, totalFiles: Int)
}
/**
* Android's DocumentFile is slow because every single method call queries the ContentResolver.
* This queries the ContentResolver a single time with all the information.
*/
class FastDocumentFile(val name: String, val type: String, val uri: Uri, val length: Long, val lastModified: Long) {
companion object {
@JvmStatic
fun list(context: Context, folderUri: Uri?): List<FastDocumentFile> {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, DocumentsContract.getDocumentId(folderUri))
val cursor = context.contentResolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_MIME_TYPE), null, null, null)
val list = ArrayList<FastDocumentFile>()
while (cursor!!.moveToNext()) {
val id = cursor.getString(0)
val uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id)
val name = cursor.getString(1)
val size = cursor.getLong(2)
val lastModified = cursor.getLong(3)
val mimeType = cursor.getString(4)
list.add(FastDocumentFile(name, mimeType, uri, size, lastModified))
}
cursor.close()
return list
}
}
}
}

View File

@ -333,7 +333,7 @@ import kotlin.math.min
it.media!!.setPosition(action.position * 1000)
it.media!!.playedDuration = action.playedDuration * 1000
it.media!!.setLastPlayedTime(action.timestamp!!.time)
it.isFavorite = action.isFavorite
it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code
it.playState = action.playState
if (hasAlmostEnded(it.media!!)) {
Logd(TAG, "Marking as played")

View File

@ -35,6 +35,7 @@ import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.text.format.Formatter
import android.util.Log
import android.util.Rational
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@ -941,7 +942,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
it.media!!.setPosition(action.position * 1000)
it.media!!.playedDuration = action.playedDuration * 1000
it.media!!.setLastPlayedTime(action.timestamp!!.time)
it.isFavorite = action.isFavorite
it.rating = if (action.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code
it.playState = action.playState
if (hasAlmostEnded(it.media!!)) {
Logd(TAG, "Marking as played: $action")

View File

@ -18,7 +18,6 @@ import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.hostPort
import ac.mdiq.podcini.net.sync.wifi.WifiSyncService.Companion.startInstantSync
import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName
import ac.mdiq.podcini.ui.activity.PreferenceActivity
import ac.mdiq.podcini.ui.dialog.AuthenticationDialog
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
@ -31,6 +30,8 @@ import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.text.format.DateUtils
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
@ -239,6 +240,46 @@ class SynchronizationPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.subtitle = status
}
/**
* Displays a dialog with a username and password text field and an optional checkbox to save username and preferences.
*/
abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?)
: MaterialAlertDialogBuilder(context) {
var passwordHidden: Boolean = true
init {
setTitle(titleRes)
val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context))
setView(viewBinding.root)
viewBinding.usernameEditText.isEnabled = enableUsernameField
if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue)
if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue)
viewBinding.showPasswordButton.setOnClickListener {
if (passwordHidden) {
viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance()
viewBinding.showPasswordButton.alpha = 1.0f
} else {
viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance()
viewBinding.showPasswordButton.alpha = 0.6f
}
passwordHidden = !passwordHidden
}
setOnCancelListener { onCancelled() }
setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() }
setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString())
}
}
protected open fun onCancelled() {}
protected abstract fun onConfirmed(username: String, password: String)
}
/**
* Guides the user through the authentication process.
*/

View File

@ -251,8 +251,16 @@ object Episodes {
fun setFavorite(episode: Episode, stat: Boolean?) : Job {
Logd(TAG, "setFavorite called $stat")
return runOnIOScope {
val result = upsert(episode) { it.isFavorite = stat ?: !it.isFavorite }
EventFlow.postEvent(FlowEvent.FavoritesEvent(result))
val result = upsert(episode) { it.rating = if (stat ?: !it.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code }
EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating))
}
}
fun setRating(episode: Episode, rating: Int) : Job {
Logd(TAG, "setRating called $rating")
return runOnIOScope {
val result = upsert(episode) { it.rating = rating }
EventFlow.postEvent(FlowEvent.RatingEvent(result, result.rating))
}
}

View File

@ -9,7 +9,11 @@ import io.realm.kotlin.MutableRealm
import io.realm.kotlin.Realm
import io.realm.kotlin.RealmConfiguration
import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.dynamic.DynamicMutableRealmObject
import io.realm.kotlin.dynamic.DynamicRealmObject
import io.realm.kotlin.dynamic.getValue
import io.realm.kotlin.ext.isManaged
import io.realm.kotlin.migration.AutomaticSchemaMigration
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.TypedRealmObject
import kotlinx.coroutines.*
@ -18,8 +22,6 @@ import kotlin.coroutines.ContinuationInterceptor
object RealmDB {
private val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
private const val SCHEMA_VERSION_NUMBER = 24L
private val ioScope = CoroutineScope(Dispatchers.IO)
val realm: Realm
@ -38,7 +40,21 @@ object RealmDB {
ShareLog::class,
Chapter::class))
.name("Podcini.realm")
.schemaVersion(SCHEMA_VERSION_NUMBER)
.schemaVersion(25)
.migration({ mContext ->
val oldRealm = mContext.oldRealm // old realm using the previous schema
val newRealm = mContext.newRealm // new realm using the new schema
if (oldRealm.schemaVersion() < 25) {
mContext.enumerate(className = "Episode") { oldObject: DynamicRealmObject, newObject: DynamicMutableRealmObject? ->
newObject?.run {
set(
"rating",
if (oldObject.getValue<Boolean>(fieldName = "isFavorite")) 2L else 0L
)
}
}
}
})
.build()
realm = Realm.open(config)
}

View File

@ -1,5 +1,6 @@
package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo
@ -83,10 +84,13 @@ class Episode : RealmObject {
*/
var chapters: RealmList<Chapter> = realmListOf()
var isFavorite: Boolean = false
var rating: Int = Rating.NEUTRAL.code
// 0 : neutral, -1 : dislike, 1 : like
var opinion: Int = 0
@Ignore
var isFavorite: Boolean = (rating == 2)
private set
var comment: String = ""
@Ignore
val isNew: Boolean
@ -281,7 +285,7 @@ class Episode : RealmObject {
if (isAutoDownloadEnabled != other.isAutoDownloadEnabled) return false
if (tags != other.tags) return false
if (chapters != other.chapters) return false
if (isFavorite != other.isFavorite) return false
if (rating != other.rating) return false
if (isInProgress != other.isInProgress) return false
if (isDownloaded != other.isDownloaded) return false
@ -305,12 +309,31 @@ class Episode : RealmObject {
result = 31 * result + isAutoDownloadEnabled.hashCode()
result = 31 * result + tags.hashCode()
result = 31 * result + chapters.hashCode()
result = 31 * result + isFavorite.hashCode()
result = 31 * result + rating.hashCode()
result = 31 * result + isInProgress.hashCode()
result = 31 * result + isDownloaded.hashCode()
return result
}
fun shiftRating(): Int {
val nr = rating + 1
return if (nr <= Rating.FAVORITE.code) nr else Rating.TRASH.code
}
enum class Rating(val code: Int, val res: Int) {
TRASH(-2, R.drawable.ic_delete),
BAD(-1, androidx.media3.session.R.drawable.media3_icon_thumb_down_filled),
NEUTRAL(0, R.drawable.ic_questionmark),
GOOD(1, androidx.media3.session.R.drawable.media3_icon_thumb_up_filled),
FAVORITE(2, R.drawable.ic_star);
companion object {
fun fromCode(code: Int): Rating {
return enumValues<Rating>().firstOrNull { it.code == code } ?: NEUTRAL
}
}
}
enum class PlayState(val code: Int) {
UNSPECIFIED(-2),
NEW(-1),
@ -319,6 +342,7 @@ class Episode : RealmObject {
BUILDING(2),
ABANDONED(3)
}
companion object {
val TAG: String = Episode::class.simpleName ?: "Anonymous"
}

View File

@ -1,9 +0,0 @@
package ac.mdiq.podcini.storage.utils
import ac.mdiq.podcini.storage.model.Chapter
class ChapterStartTimeComparator : Comparator<Chapter> {
override fun compare(lhs: Chapter, rhs: Chapter): Int {
return lhs.start.compareTo(rhs.start)
}
}

View File

@ -245,4 +245,10 @@ object ChapterUtils {
}
return listOf()
}
class ChapterStartTimeComparator : Comparator<Chapter> {
override fun compare(lhs: Chapter, rhs: Chapter): Int {
return lhs.start.compareTo(rhs.start)
}
}
}

View File

@ -1,36 +0,0 @@
package ac.mdiq.podcini.storage.utils
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
/**
* Android's DocumentFile is slow because every single method call queries the ContentResolver.
* This queries the ContentResolver a single time with all the information.
*/
class FastDocumentFile(val name: String, val type: String, val uri: Uri, val length: Long, val lastModified: Long) {
companion object {
@JvmStatic
fun list(context: Context, folderUri: Uri?): List<FastDocumentFile> {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, DocumentsContract.getDocumentId(folderUri))
val cursor = context.contentResolver.query(childrenUri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_MIME_TYPE), null, null, null)
val list = ArrayList<FastDocumentFile>()
while (cursor!!.moveToNext()) {
val id = cursor.getString(0)
val uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id)
val name = cursor.getString(1)
val size = cursor.getLong(2)
val lastModified = cursor.getLong(3)
val mimeType = cursor.getString(4)
list.add(FastDocumentFile(name, mimeType, uri, size, lastModified))
}
cursor.close()
return list
}
}
}

View File

@ -3,9 +3,9 @@ package ac.mdiq.podcini.ui.actions
import ac.mdiq.podcini.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync
import ac.mdiq.podcini.storage.database.Episodes.setFavorite
import ac.mdiq.podcini.storage.database.Episodes.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync
import ac.mdiq.podcini.storage.database.Episodes.setRating
import ac.mdiq.podcini.storage.database.Episodes.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
import ac.mdiq.podcini.storage.database.Queues.addToQueue
@ -117,7 +117,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
@JvmField
val swipeActions: List<SwipeAction> = listOf(
NoActionSwipeAction(), AddToQueueSwipeAction(),
StartDownloadSwipeAction(), MarkFavoriteSwipeAction(),
StartDownloadSwipeAction(), ShiftRatingSwipeAction(),
TogglePlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(),
DeleteSwipeAction(), RemoveFromHistorySwipeAction())
@ -203,7 +203,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
}
}
class MarkFavoriteSwipeAction : SwipeAction {
class ShiftRatingSwipeAction : SwipeAction {
override fun getId(): String {
return SwipeAction.MARK_FAV
}
@ -217,12 +217,12 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
}
override fun getTitle(context: Context): String {
return context.getString(R.string.add_to_favorite_label)
return context.getString(R.string.switch_rating_label)
}
@OptIn(UnstableApi::class)
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) {
setFavorite(item, !item.isFavorite)
setRating(item, item.shiftRating())
}
override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.ui.compose
import ac.mdiq.podcini.R
import ac.mdiq.podcini.preferences.ThemeSwitcher.readThemeValue
import ac.mdiq.podcini.preferences.UserPreferences.ThemePreference
import ac.mdiq.podcini.util.Logd
@ -9,13 +8,9 @@ import android.util.TypedValue
import androidx.annotation.AttrRes
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@ -57,157 +52,26 @@ fun getColorFromAttr(context: Context, @AttrRes attrColor: Int): Int {
}
}
fun getPrimaryColor(context: Context): Color {
return Color(getColorFromAttr(context, R.attr.colorPrimary))
}
fun getSecondaryColor(context: Context): Color {
return Color(getColorFromAttr(context, R.attr.colorSecondary))
}
val md_theme_light_primary = Color(0xFF825500)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDDB3)
val md_theme_light_onPrimaryContainer = Color(0xFF291800)
val md_theme_light_secondary = Color(0xFF6F5B40)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFBDEBC)
val md_theme_light_onSecondaryContainer = Color(0xFF271904)
val md_theme_light_tertiary = Color(0xFF51643F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFD4EABB)
val md_theme_light_onTertiaryContainer = Color(0xFF102004)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFFFBFF)
val md_theme_light_onBackground = Color(0xFF1F1B16)
val md_theme_light_surface = Color(0xFFFFFBFF)
val md_theme_light_onSurface = Color(0xFF1F1B16)
val md_theme_light_surfaceVariant = Color(0xFFF0E0CF)
val md_theme_light_onSurfaceVariant = Color(0xFF4F4539)
val md_theme_light_outline = Color(0xFF817567)
val md_theme_light_inverseOnSurface = Color(0xFFF9EFE7)
val md_theme_light_inverseSurface = Color(0xFF34302A)
val md_theme_light_inversePrimary = Color(0xFFFFB951)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF825500)
val md_theme_light_outlineVariant = Color(0xFFD3C4B4)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFFFFB951)
val md_theme_dark_onPrimary = Color(0xFF452B00)
val md_theme_dark_primaryContainer = Color(0xFF633F00)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDDB3)
val md_theme_dark_secondary = Color(0xFFDDC2A1)
val md_theme_dark_onSecondary = Color(0xFF3E2D16)
val md_theme_dark_secondaryContainer = Color(0xFF56442A)
val md_theme_dark_onSecondaryContainer = Color(0xFFFBDEBC)
val md_theme_dark_tertiary = Color(0xFFB8CEA1)
val md_theme_dark_onTertiary = Color(0xFF243515)
val md_theme_dark_tertiaryContainer = Color(0xFF3A4C2A)
val md_theme_dark_onTertiaryContainer = Color(0xFFD4EABB)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1F1B16)
val md_theme_dark_onBackground = Color(0xFFEAE1D9)
val md_theme_dark_surface = Color(0xFF1F1B16)
val md_theme_dark_onSurface = Color(0xFFEAE1D9)
val md_theme_dark_surfaceVariant = Color(0xFF4F4539)
val md_theme_dark_onSurfaceVariant = Color(0xFFD3C4B4)
val md_theme_dark_outline = Color(0xFF9C8F80)
val md_theme_dark_inverseOnSurface = Color(0xFF1F1B16)
val md_theme_dark_inverseSurface = Color(0xFFEAE1D9)
val md_theme_dark_inversePrimary = Color(0xFF825500)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB951)
val md_theme_dark_outlineVariant = Color(0xFF4F4539)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF825500)
val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
//private val LightColors = dynamicLightColorScheme()
//private val DarkColors = dynamicDarkColorScheme()
@Composable
fun CustomTheme(context: Context, content: @Composable () -> Unit) {
// val primaryColor = getPrimaryColor(context)
// val secondaryColor = getSecondaryColor(context)
val colors = when (readThemeValue(context)) {
ThemePreference.LIGHT -> {
Logd(TAG, "Light theme")
LightColors
}
ThemePreference.DARK, ThemePreference.BLACK -> {
ThemePreference.DARK -> {
Logd(TAG, "Dark theme")
DarkColors
}
ThemePreference.BLACK -> {
Logd(TAG, "Dark theme")
DarkColors.copy(surface = Color(0xFF000000))
}
ThemePreference.SYSTEM -> {
if (isSystemInDarkTheme()) {
Logd(TAG, "System Dark theme")

View File

@ -2,9 +2,12 @@ package ac.mdiq.podcini.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -69,3 +72,33 @@ fun CustomToast(message: String, durationMillis: Long = 2000L, onDismiss: () ->
}
}
}
@Composable
fun AutoCompleteTextView(suggestions: List<String>, onItemSelected: (String) -> Unit, modifier: Modifier = Modifier) {
var text by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
Column(modifier = modifier) {
TextField(value = text,
onValueChange = { text = it },
label = { Text("Search") },
trailingIcon = {
IconButton(onClick = { expanded = !expanded }) {
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "Expand")
}
}
)
if (expanded) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
suggestions.forEach { suggestion ->
DropdownMenuItem(text = {Text(suggestion)}, onClick = {
onItemSelected(suggestion)
text = suggestion
expanded = false
})
}
}
}
}
}

View File

@ -100,7 +100,7 @@ class EpisodeVM(var episode: Episode) {
var positionState by mutableStateOf(episode.media?.position?:0)
var playedState by mutableStateOf(episode.isPlayed())
var isPlayingState by mutableStateOf(false)
var farvoriteState by mutableStateOf(episode.isFavorite)
var ratingState by mutableIntStateOf(episode.rating)
var inProgressState by mutableStateOf(episode.isInProgress)
var downloadState by mutableIntStateOf(if (episode.media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
var isRemote by mutableStateOf(false)
@ -112,8 +112,8 @@ class EpisodeVM(var episode: Episode) {
var isSelected by mutableStateOf(false)
var prog by mutableFloatStateOf(0f)
var episodeMonitor: Job? by mutableStateOf(null)
var mediaMonitor: Job? by mutableStateOf(null)
private var episodeMonitor: Job? by mutableStateOf(null)
private var mediaMonitor: Job? by mutableStateOf(null)
fun stopMonitoring() {
episodeMonitor?.cancel()
@ -136,7 +136,7 @@ class EpisodeVM(var episode: Episode) {
if (episode.id == changes.obj.id) {
withContext(Dispatchers.Main) {
playedState = changes.obj.isPlayed()
farvoriteState = changes.obj.isFavorite
ratingState = changes.obj.rating
episode = changes.obj // direct assignment doesn't update member like media??
}
Logd("EpisodeVM", "episodeMonitor $playedState $playedState ")
@ -193,7 +193,28 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
})
@Composable
fun EpisodeSpeedDial(activity: MainActivity, selected: SnapshotStateList<Episode>, modifier: Modifier = Modifier) {
fun ChooseRatingDialog(onDismissRequest: () -> Unit) {
Dialog(onDismissRequest = onDismissRequest) {
Surface(shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
for (rating in Episode.Rating.entries) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(4.dp).clickable {
for (item in selected) Episodes.setRating(item, rating.code)
onDismissRequest()
}) {
Icon(imageVector = ImageVector.vectorResource(id = rating.res), "")
Text(rating.name, Modifier.padding(start = 4.dp))
}
}
}
}
}
}
var showChooseRatingDialog by remember { mutableStateOf(false) }
if (showChooseRatingDialog) ChooseRatingDialog { showChooseRatingDialog = false }
@Composable
fun EpisodeSpeedDial(modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(false) }
val options = mutableListOf<@Composable () -> Unit>(
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
@ -202,11 +223,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
selectMode = false
Logd(TAG, "ic_delete: ${selected.size}")
LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected)
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "")
Text(stringResource(id = R.string.delete_episode_label))
} },
Text(stringResource(id = R.string.delete_episode_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
@ -216,94 +235,81 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get()
?.download(activity, episode)
}
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "")
Text(stringResource(id = R.string.download_label))
} },
Text(stringResource(id = R.string.download_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_mark_played: ${selected.size}")
setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "")
Text(stringResource(id = R.string.toggle_played_label))
} },
Text(stringResource(id = R.string.toggle_played_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_playlist_remove: ${selected.size}")
removeFromQueue(*selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "")
Text(stringResource(id = R.string.remove_from_queue_label))
} },
Text(stringResource(id = R.string.remove_from_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_playlist_play: ${selected.size}")
Queues.addToQueue(true, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "")
Text(stringResource(id = R.string.add_to_queue_label))
} },
Text(stringResource(id = R.string.add_to_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_playlist_play: ${selected.size}")
PutToQueueDialog(activity, selected).show()
}, verticalAlignment = Alignment.CenterVertically
) {
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "")
Text(stringResource(id = R.string.put_in_queue_label))
} },
Text(stringResource(id = R.string.put_in_queue_label)) } },
{ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "ic_star: ${selected.size}")
for (item in selected) {
Episodes.setFavorite(item, null)
}
}, verticalAlignment = Alignment.CenterVertically
) {
showChooseRatingDialog = true
isExpanded = false
}, verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "")
Text(stringResource(id = R.string.toggle_favorite_label))
} },
Text(stringResource(id = R.string.set_rating_label)) } },
)
if (selected.isNotEmpty() && selected[0].isRemote.value)
options.add({ Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "reserve: ${selected.size}")
CoroutineScope(Dispatchers.IO).launch {
youtubeUrls.clear()
for (e in selected) {
Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}")
val url = URL(e.media?.downloadUrl?: "")
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) {
youtubeUrls.add(e.media!!.downloadUrl!!)
} else addToMiscSyndicate(e)
options.add {
Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable {
isExpanded = false
selectMode = false
Logd(TAG, "reserve: ${selected.size}")
CoroutineScope(Dispatchers.IO).launch {
youtubeUrls.clear()
for (e in selected) {
Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}")
val url = URL(e.media?.downloadUrl ?: "")
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) {
youtubeUrls.add(e.media!!.downloadUrl!!)
} else addToMiscSyndicate(e)
}
Logd(TAG, "youtubeUrls: ${youtubeUrls.size}")
withContext(Dispatchers.Main) {
showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty()
}
}
Logd(TAG, "youtubeUrls: ${youtubeUrls.size}")
withContext(Dispatchers.Main) {
showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty()
}
}
}, verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.AddCircle, "")
Text(stringResource(id = R.string.reserve_episodes_label))
} })
}, verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AddCircle, "")
Text(stringResource(id = R.string.reserve_episodes_label))
}
}
val scrollState = rememberScrollState()
Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) {
@ -312,7 +318,8 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
containerColor = Color.LightGray,
onClick = {}) { button() }
}
FloatingActionButton(containerColor = Color.Green,
FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary,
onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") }
}
}
@ -437,8 +444,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Row {
if (vm.episode.media?.getMediaType() == MediaType.VIDEO)
Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp))
if (vm.farvoriteState)
Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp))
val ratingIconRes = Episode.Rating.fromCode(vm.ratingState).res
if (vm.ratingState != Episode.Rating.NEUTRAL.code)
Icon(painter = painterResource(ratingIconRes), tint = MaterialTheme.colorScheme.surfaceTint, contentDescription = "rating", modifier = Modifier.width(14.dp).height(14.dp))
if (vm.inQueueState)
Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp))
val curContext = LocalContext.current
@ -532,7 +540,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "selectedIds: ${selected.size}")
}))
}
EpisodeSpeedDial(activity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp))
EpisodeSpeedDial(modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp))
}
}
}
@ -546,7 +554,12 @@ fun confirmAddYoutubeEpisode(sharedUrls: List<String>, showDialog: Boolean, onDi
if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), ) {
Card(
modifier = Modifier
.wrapContentSize(align = Alignment.Center)
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
var audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) {

View File

@ -1,50 +0,0 @@
package ac.mdiq.podcini.ui.dialog
import ac.mdiq.podcini.R
import ac.mdiq.podcini.databinding.AuthenticationDialogBinding
import android.content.Context
import android.content.DialogInterface
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.view.LayoutInflater
import com.google.android.material.dialog.MaterialAlertDialogBuilder
/**
* Displays a dialog with a username and password text field and an optional checkbox to save username and preferences.
*/
abstract class AuthenticationDialog(context: Context, titleRes: Int, enableUsernameField: Boolean, usernameInitialValue: String?, passwordInitialValue: String?)
: MaterialAlertDialogBuilder(context) {
var passwordHidden: Boolean = true
init {
setTitle(titleRes)
val viewBinding = AuthenticationDialogBinding.inflate(LayoutInflater.from(context))
setView(viewBinding.root)
viewBinding.usernameEditText.isEnabled = enableUsernameField
if (usernameInitialValue != null) viewBinding.usernameEditText.setText(usernameInitialValue)
if (passwordInitialValue != null) viewBinding.passwordEditText.setText(passwordInitialValue)
viewBinding.showPasswordButton.setOnClickListener {
if (passwordHidden) {
viewBinding.passwordEditText.transformationMethod = HideReturnsTransformationMethod.getInstance()
viewBinding.showPasswordButton.alpha = 1.0f
} else {
viewBinding.passwordEditText.transformationMethod = PasswordTransformationMethod.getInstance()
viewBinding.showPasswordButton.alpha = 0.6f
}
passwordHidden = !passwordHidden
}
setOnCancelListener { onCancelled() }
setNegativeButton(R.string.cancel_label) { _: DialogInterface?, _: Int -> onCancelled() }
setPositiveButton(R.string.confirm_label) { _: DialogInterface?, _: Int ->
onConfirmed(viewBinding.usernameEditText.text.toString(), viewBinding.passwordEditText.text.toString())
}
}
protected open fun onCancelled() {}
protected abstract fun onConfirmed(username: String, password: String)
}

View File

@ -899,7 +899,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
onPlaybackServiceChanged(event)
}
is FlowEvent.PlayEvent -> onPlayEvent(event)
is FlowEvent.FavoritesEvent -> onFavoriteEvent(event)
is FlowEvent.RatingEvent -> onFavoriteEvent(event)
is FlowEvent.PlayerErrorEvent -> MediaPlayerErrorDialog.show(activity as Activity, event)
// is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) loadMediaInfo(false)
is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) setupOptionsMenu()
@ -911,7 +911,7 @@ class AudioPlayerFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
}
private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) {
private fun onFavoriteEvent(event: FlowEvent.RatingEvent) {
if (curEpisode?.id == event.episode.id) EpisodeMenuHandler.onPrepareMenu(toolbar.menu, event.episode)
}

View File

@ -189,7 +189,7 @@ abstract class BaseEpisodesFragment : Fragment(), Toolbar.OnMenuItemClickListene
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale()
is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent, is FlowEvent.FavoritesEvent -> loadItems()
is FlowEvent.FeedListEvent, is FlowEvent.EpisodePlayedEvent, is FlowEvent.PlayerSettingsEvent, is FlowEvent.RatingEvent -> loadItems()
else -> {}
}
}

View File

@ -21,7 +21,6 @@ import ac.mdiq.podcini.storage.model.Feed
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.actions.EpisodeMenuHandler
import ac.mdiq.podcini.ui.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.utils.ShownotesCleaner
@ -46,24 +45,27 @@ import android.widget.TextView
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.material3.*
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
@ -78,11 +80,8 @@ import com.skydoves.balloon.ArrowOrientation
import com.skydoves.balloon.ArrowOrientationRules
import com.skydoves.balloon.Balloon
import com.skydoves.balloon.BalloonAnimation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.*
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
@ -123,19 +122,16 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
toolbar = binding.toolbar
toolbar.title = ""
toolbar.inflateMenu(R.menu.feeditem_options)
toolbar.inflateMenu(R.menu.episode_info)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.setOnMenuItemClickListener(this)
binding.composeView.setContent{
CustomTheme(requireContext()) {
InfoView()
MainView()
}
}
// binding.txtvPodcast.setOnClickListener { openPodcast() }
// binding.txtvTitle.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_FULL)
// binding.txtvTitle.ellipsize = TextUtils.TruncateAt.END
// webvDescription = binding.webvDescription
// webvDescription.setTimecodeSelectedListener { time: Int? ->
// val cMedia = curMedia
@ -144,40 +140,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
// }
// registerForContextMenu(webvDescription)
// imgvCover = binding.imgvCover
// imgvCover.setOnClickListener { openPodcast() }
// butAction1 = binding.butAction1
// butAction2 = binding.butAction2
// binding.homeButton.setOnClickListener {
// if (!episode?.link.isNullOrEmpty()) {
// homeFragment = EpisodeHomeFragment.newInstance(episode!!)
// (activity as MainActivity).loadChildFragment(homeFragment!!)
// } else Toast.makeText(context, "Episode link is not valid ${episode?.link}", Toast.LENGTH_LONG).show()
// }
// butAction1.setOnClickListener(View.OnClickListener {
// when {
// actionButton1 is StreamActionButton && !UserPreferences.isStreamOverDownload
// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_STREAM) -> {
// showOnDemandConfigBalloon(true)
// return@OnClickListener
// }
// actionButton1 == null -> return@OnClickListener // Not loaded yet
// else -> actionButton1?.onClick(requireContext())
// }
// })
// butAction2.setOnClickListener(View.OnClickListener {
// when {
// actionButton2 is DownloadActionButton && UserPreferences.isStreamOverDownload
// && UsageStatistics.hasSignificantBiasTo(UsageStatistics.ACTION_DOWNLOAD) -> {
// showOnDemandConfigBalloon(false)
// return@OnClickListener
// }
// actionButton2 == null -> return@OnClickListener // Not loaded yet
// else -> actionButton2?.onClick(requireContext())
// }
// })
shownotesCleaner = ShownotesCleaner(requireContext())
onFragmentLoaded()
load()
@ -185,16 +147,55 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
@Composable
fun InfoView() {
fun MainView() {
val textColor = MaterialTheme.colorScheme.onSurface
var showEditComment by remember { mutableStateOf(false) }
@Composable
fun LargeTextEditingDialog(textState: TextFieldValue, onTextChange: (TextFieldValue) -> Unit, onDismissRequest: () -> Unit, onSave: (String) -> Unit) {
Dialog(onDismissRequest = { onDismissRequest() }, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = MaterialTheme.shapes.medium) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Add comment", color = textColor, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(16.dp))
BasicTextField(value = textState, onValueChange = { onTextChange(it) }, textStyle = TextStyle(fontSize = 16.sp, color = textColor),
modifier = Modifier.fillMaxWidth().height(300.dp).padding(10.dp).border(1.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { onDismissRequest() }) {
Text("Cancel")
}
TextButton(onClick = {
onSave(textState.text)
onDismissRequest()
}) {
Text("Save")
}
}
}
}
LaunchedEffect(Unit) {
while (true) {
delay(10000)
onSave(textState.text)
}
}
}
}
var commentTextState by remember { mutableStateOf(TextFieldValue(episode?.comment?:"")) }
if (showEditComment) LargeTextEditingDialog(textState = commentTextState, onTextChange = { commentTextState = it }, onDismissRequest = {showEditComment = false},
onSave = {
runOnIOScope { if (episode != null) episode = upsert(episode!!) { it.comment = commentTextState.text } }
})
Column {
val textColor = MaterialTheme.colorScheme.onSurface
Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) {
val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null
AsyncImage(model = imgLoc, contentDescription = "imgvCover", Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() }))
Column(modifier = Modifier.padding(start = 10.dp)) {
Text(txtvPodcast, color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.clickable { openPodcast() })
Text(txtvTitle, color = textColor, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), maxLines = 5, overflow = TextOverflow.Ellipsis)
Text(txtvPublished + " · " + txtvDuration + " · " + txtvSize, color = textColor, style = MaterialTheme.typography.bodyMedium)
Text("$txtvPublished · $txtvDuration · $txtvSize", color = textColor, style = MaterialTheme.typography.bodyMedium)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
@ -250,6 +251,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}, update = {
it.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank")
})
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 })
Text(commentTextState.text, color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 15.dp, bottom = 10.dp))
Text(itemLink, color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
@ -339,7 +344,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@OptIn(UnstableApi::class) override fun onDestroyView() {
Logd(TAG, "onDestroyView")
// binding.root.removeView(webvDescription)
episode = null
// webvDescription.clearHistory()
// webvDescription.clearCache(true)
@ -432,11 +436,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
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)) {
// 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
@ -470,15 +474,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
// if (actionButton2 != null && media.getMediaType() == MediaType.FLASH) actionButton2!!.visibility = View.GONE
}
}
if (actionButton1 != null) {
// butAction1.setImageResource(actionButton1!!.getDrawable())
// butAction1.visibility = actionButton1!!.visibility
}
if (actionButton2 != null) {
// butAction2.setImageResource(actionButton2!!.getDrawable())
// butAction2.visibility = actionButton2!!.visibility
}
}
// override fun onContextItemSelected(item: MenuItem): Boolean {
@ -506,7 +501,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "Received event: ${event.TAG}")
when (event) {
is FlowEvent.QueueEvent -> onQueueEvent(event)
is FlowEvent.FavoritesEvent -> onFavoriteEvent(event)
is FlowEvent.RatingEvent -> onFavoriteEvent(event)
is FlowEvent.EpisodeEvent -> onEpisodeEvent(event)
is FlowEvent.PlayerSettingsEvent -> updateButtons()
is FlowEvent.EpisodePlayedEvent -> load()
@ -525,10 +520,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
}
private fun onFavoriteEvent(event: FlowEvent.FavoritesEvent) {
private fun onFavoriteEvent(event: FlowEvent.RatingEvent) {
if (episode?.id == event.episode.id) {
episode = unmanaged(episode!!)
episode!!.isFavorite = event.episode.isFavorite
episode!!.rating = if (event.episode.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code
// episode = event.episode
prepareMenu()
}

View File

@ -359,6 +359,13 @@ import java.util.concurrent.Semaphore
}.start()
}
R.id.sort_items -> SingleFeedSortDialog(feed).show(childFragmentManager, "SortDialog")
// R.id.filter_items -> {}
// R.id.settings -> {
// if (feed != null) {
// val fragment = FeedSettingsFragment.newInstance(feed!!)
// (activity as MainActivity).loadChildFragment(fragment, TransitionEffect.SLIDE)
// }
// }
R.id.rename_feed -> CustomFeedNameDialog(activity as Activity, feed!!).show()
R.id.remove_feed -> {
RemoveFeedDialog.show(requireContext(), feed!!) {

View File

@ -54,11 +54,12 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.AddCircle
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
@ -802,7 +803,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
containerColor = Color.LightGray,
onClick = {}) { button() }
}
FloatingActionButton(containerColor = Color.Green,
FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary,
onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") }
}
}
@ -1014,11 +1016,13 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}
EpisodeSpeedDial(activity as MainActivity, selected.toMutableStateList(), modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 16.dp, start = 16.dp))
}
FloatingActionButton(containerColor = Color.Green,
FloatingActionButton(shape = CircleShape,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary,
modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 16.dp, end = 16.dp),
onClick = {
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(OnlineSearchFragment())
}) { Icon(Icons.Filled.AddCircle, "Add") }
}) { Icon(Icons.Outlined.AddCircle, "Add", modifier = Modifier.size(60.dp)) }
}
}

View File

@ -168,7 +168,7 @@ sealed class FlowEvent {
// TODO: need better handling at receving end
data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent()
data class FavoritesEvent(val episode: Episode) : FlowEvent()
data class RatingEvent(val episode: Episode, val rating: Int = Episode.Rating.FAVORITE.code) : FlowEvent()
data class AllEpisodesFilterEvent(val filterValues: Set<String?>?) : FlowEvent()

View File

@ -11,10 +11,24 @@
<item
android:id="@+id/sort_items"
android:icon="@drawable/arrows_sort"
android:menuCategory="container"
android:title="@string/sort"
custom:showAsAction="always">
</item>
<!-- <item-->
<!-- android:id="@+id/filter_items"-->
<!-- android:icon="@drawable/ic_filter"-->
<!-- android:title="@string/filter"-->
<!-- custom:showAsAction="always">-->
<!-- </item>-->
<!-- <item-->
<!-- android:id="@+id/settings"-->
<!-- android:icon="@drawable/ic_settings"-->
<!-- android:title="@string/settings_label"-->
<!-- custom:showAsAction="ifRoom">-->
<!-- </item>-->
<item
android:id="@+id/refresh_feed"
android:menuCategory="container"

View File

@ -192,7 +192,6 @@
<string name="new_episode_notification_group_text">Your subscriptions have new episodes.</string>
<!-- Actions on feeds -->
<string name="multi_select_operation_confirmation">Please confirm that you want to perform the action on all selected items.</string>
<string name="multi_select_toggle_played_confirmation">Please confirm that you want to toggle played status of all selected items.</string>
<string name="multi_select_mark_played_confirmation">Please confirm that you want to mark all selected items as played.</string>
@ -251,8 +250,8 @@
</plurals>
<string name="javasript_label">JavaScript</string>
<string name="no_action_label">No action</string>
<string name="my_opinion_label">My opinion</string>
<string name="removed_inbox_label">Removed from inbox</string>
<string name="mark_read_label">Mark as played</string>
@ -287,7 +286,9 @@
</plurals>
<string name="show_video_label">Play video</string>
<string name="add_to_favorite_label">Add to favorites</string>
<string name="switch_rating_label">Swtich rating</string>
<string name="toggle_favorite_label">Toggle favorites</string>
<string name="set_rating_label">Set rating</string>
<string name="remove_from_favorite_label">Remove from favorites</string>
<string name="visit_website_label">Visit website</string>
<string name="skip_episode_label">Skip episode</string>

View File

@ -5,11 +5,10 @@ import ac.mdiq.podcini.net.feed.LocalFeedUpdater.getImageUrl
import ac.mdiq.podcini.net.feed.LocalFeedUpdater.tryUpdateFeed
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterfaceTestStub
import ac.mdiq.podcini.net.feed.LocalFeedUpdater.FastDocumentFile.Companion.list
import ac.mdiq.podcini.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.model.Feed
import ac.mdiq.podcini.storage.utils.FastDocumentFile
import ac.mdiq.podcini.storage.utils.FastDocumentFile.Companion.list
import ac.mdiq.podcini.util.config.ApplicationCallbacks
import ac.mdiq.podcini.util.config.ClientConfig
import android.app.Application
@ -233,7 +232,7 @@ class LocalFeedUpdaterTest {
* @param localFeedDir assets local feed folder with media files
*/
private fun callUpdateFeed(localFeedDir: String) {
Mockito.mockStatic(FastDocumentFile::class.java).use { dfMock ->
Mockito.mockStatic(LocalFeedUpdater.FastDocumentFile::class.java).use { dfMock ->
// mock external storage
dfMock.`when`<Any> { list(ArgumentMatchers.any(), ArgumentMatchers.any()) }
.thenReturn(mockLocalFolder(localFeedDir))
@ -283,16 +282,16 @@ class LocalFeedUpdaterTest {
/**
* Create a DocumentFile mock object.
*/
private fun mockDocumentFile(fileName: String, mimeType: String): FastDocumentFile {
return FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0)
private fun mockDocumentFile(fileName: String, mimeType: String): LocalFeedUpdater.FastDocumentFile {
return LocalFeedUpdater.FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0)
}
private fun mockLocalFolder(folderName: String): List<FastDocumentFile> {
val files: MutableList<FastDocumentFile> = ArrayList()
private fun mockLocalFolder(folderName: String): List<LocalFeedUpdater.FastDocumentFile> {
val files: MutableList<LocalFeedUpdater.FastDocumentFile> = ArrayList()
for (f in Objects.requireNonNull<Array<File>>(File(folderName).listFiles())) {
val extension = MimeTypeMap.getFileExtensionFromUrl(f.path)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
files.add(FastDocumentFile(f.name, mimeType!!,
files.add(LocalFeedUpdater.FastDocumentFile(f.name, mimeType!!,
Uri.parse(f.toURI().toString()), f.length(), f.lastModified()))
}
return files

View File

@ -1,3 +1,14 @@
# 6.9.2
* fixed getting 0 episodes with Youtube playlist etc
* added new ratings for episodes: Trash, Bad, Neutral, Good, Favorite
* previous Favorite is migrated to the new ratings
* "Add to favorite" SwipeActions is changed to "Switch rating" SwipeActions, each swipe shifts the rating in circle
* "Add to favorite" in multi-selection is changed to "Set rating"
* in EpisodeInfo, added "My opinion" section under the Description text,
* by clicking on it you can add personal comments/notes on the episode, text entered is auto-saved every 10 seconds
* adopted Material3's built-in color scheme
# 6.9.1
* added logging for shared actions

View File

@ -1,4 +1,4 @@
Version 6.9.0
Version 6.9.1
* added logging for shared actions
* added simple fragment for viewing shared logs and repairing failed share actions

View File

@ -0,0 +1,10 @@
Version 6.9.2
* fixed getting 0 episodes with Youtube playlist etc
* added new ratings for episodes: Trash, Bad, Neutral, Good, Favorite
* previous Favorite is migrated to the new ratings
* "Add to favorite" SwipeActions is changed to "Switch rating" SwipeActions, each swipe shifts the rating in circle
* "Add to favorite" in multi-selection is changed to "Set rating"
* in EpisodeInfo, added "My opinion" section under the Description text,
* by clicking on it you can add personal comments/notes on the episode, text entered is auto-saved every 10 seconds
* adopted Material3's built-in color scheme

View File

@ -36,6 +36,7 @@ lifecycleRuntimeKtx = "2.8.6"
#material = "1.7.2"
material3 = "1.3.0"
#material3Android = "1.3.0"
materialIconsExtended = "1.7.3"
materialVersion = "1.12.0"
media3Common = "1.4.1"
media3Session = "1.4.1"
@ -92,6 +93,7 @@ androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref =
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
#androidx-material = { module = "androidx.compose.material:material", version.ref = "material" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }