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" testApplicationId "ac.mdiq.podcini.tests"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
versionCode 3020266 versionCode 3020267
versionName "6.9.1" versionName "6.9.2"
applicationId "ac.mdiq.podcini.R" applicationId "ac.mdiq.podcini.R"
def commit = "" def commit = ""
@ -174,6 +174,7 @@ dependencies {
implementation libs.androidx.material3 implementation libs.androidx.material3
// implementation libs.androidx.ui.viewbinding // implementation libs.androidx.ui.viewbinding
implementation libs.androidx.fragment.compose implementation libs.androidx.fragment.compose
implementation libs.androidx.material.icons.extended
/** Desugaring for using VistaGuide **/ /** Desugaring for using VistaGuide **/
coreLibraryDesugaring libs.desugar.jdk.libs.nio 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 e.feedId = feed_.id
eList.add(e) eList.add(e)
} }
feed_.episodes.addAll(eList)
if (nextPage == null || feed_.episodes.size > 1000) break if (nextPage == null || feed_.episodes.size > 1000) break
try { try {
val page = PlaylistInfo.getMoreItems(service, url, nextPage) ?: break 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, "") } withContext(Dispatchers.Main) { showError(e.message, "") }
break break
} }
feed_.episodes.addAll(eList)
} }
feed_.isBuilding = false feed_.isBuilding = false
} }
@ -123,6 +123,7 @@ class FeedBuilder(val context: Context, val showError: (String?, String)->Unit)
e.feedId = feed_.id e.feedId = feed_.id
eList.add(e) eList.add(e)
} }
feed_.episodes.addAll(eList)
if (nextPage == null || feed_.episodes.size > 1000) break if (nextPage == null || feed_.episodes.size > 1000) break
try { try {
val page = ChannelTabInfo.getMoreItems(service, channelInfo.tabs.first(), nextPage) 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, "") } withContext(Dispatchers.Main) { showError(e.message, "") }
break break
} }
feed_.episodes.addAll(eList)
} }
feed_.isBuilding = false feed_.isBuilding = false
} }

View File

@ -1,31 +1,26 @@
package ac.mdiq.podcini.net.feed package ac.mdiq.podcini.net.feed
import ac.mdiq.podcini.R 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.ID3ReaderException
import ac.mdiq.podcini.net.feed.parser.media.id3.Id3MetadataReader 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.VorbisCommentMetadataReader
import ac.mdiq.podcini.net.feed.parser.media.vorbis.VorbisCommentReaderException 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.DateUtils
import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils import ac.mdiq.podcini.net.feed.parser.utils.MimeTypeUtils
import ac.mdiq.podcini.storage.database.Feeds import ac.mdiq.podcini.storage.database.Feeds
import ac.mdiq.podcini.storage.database.LogsAndStats 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.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 ac.mdiq.podcini.util.Logd
import android.content.Context import android.content.Context
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import org.apache.commons.io.input.CountingInputStream import org.apache.commons.io.input.CountingInputStream
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.IOException import java.io.IOException
import java.text.ParseException import java.text.ParseException
@ -227,4 +222,34 @@ object LocalFeedUpdater {
fun interface UpdaterProgressListener { fun interface UpdaterProgressListener {
fun onLocalFileScanned(scanned: Int, totalFiles: Int) 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!!.setPosition(action.position * 1000)
it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.playedDuration = action.playedDuration * 1000
it.media!!.setLastPlayedTime(action.timestamp!!.time) 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 it.playState = action.playState
if (hasAlmostEnded(it.media!!)) { if (hasAlmostEnded(it.media!!)) {
Logd(TAG, "Marking as played") Logd(TAG, "Marking as played")

View File

@ -35,6 +35,7 @@ import android.os.Bundle
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.text.format.Formatter import android.text.format.Formatter
import android.util.Log import android.util.Log
import android.util.Rational
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -941,7 +942,7 @@ class ImportExportPreferencesFragment : PreferenceFragmentCompat() {
it.media!!.setPosition(action.position * 1000) it.media!!.setPosition(action.position * 1000)
it.media!!.playedDuration = action.playedDuration * 1000 it.media!!.playedDuration = action.playedDuration * 1000
it.media!!.setLastPlayedTime(action.timestamp!!.time) 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 it.playState = action.playState
if (hasAlmostEnded(it.media!!)) { if (hasAlmostEnded(it.media!!)) {
Logd(TAG, "Marking as played: $action") 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.net.sync.wifi.WifiSyncService.Companion.startInstantSync
import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName import ac.mdiq.podcini.storage.utils.FileNameGenerator.generateFileName
import ac.mdiq.podcini.ui.activity.PreferenceActivity 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.Logd
import ac.mdiq.podcini.util.EventFlow import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent import ac.mdiq.podcini.util.FlowEvent
@ -31,6 +30,8 @@ import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.text.method.HideReturnsTransformationMethod
import android.text.method.PasswordTransformationMethod
import android.view.KeyEvent import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -239,6 +240,46 @@ class SynchronizationPreferencesFragment : PreferenceFragmentCompat() {
(activity as PreferenceActivity).supportActionBar!!.subtitle = status (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. * Guides the user through the authentication process.
*/ */

View File

@ -251,8 +251,16 @@ object Episodes {
fun setFavorite(episode: Episode, stat: Boolean?) : Job { fun setFavorite(episode: Episode, stat: Boolean?) : Job {
Logd(TAG, "setFavorite called $stat") Logd(TAG, "setFavorite called $stat")
return runOnIOScope { return runOnIOScope {
val result = upsert(episode) { it.isFavorite = stat ?: !it.isFavorite } val result = upsert(episode) { it.rating = if (stat ?: !it.isFavorite) Episode.Rating.FAVORITE.code else Episode.Rating.NEUTRAL.code }
EventFlow.postEvent(FlowEvent.FavoritesEvent(result)) 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.Realm
import io.realm.kotlin.RealmConfiguration import io.realm.kotlin.RealmConfiguration
import io.realm.kotlin.UpdatePolicy 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.ext.isManaged
import io.realm.kotlin.migration.AutomaticSchemaMigration
import io.realm.kotlin.types.RealmObject import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.TypedRealmObject import io.realm.kotlin.types.TypedRealmObject
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -18,8 +22,6 @@ import kotlin.coroutines.ContinuationInterceptor
object RealmDB { object RealmDB {
private val TAG: String = RealmDB::class.simpleName ?: "Anonymous" private val TAG: String = RealmDB::class.simpleName ?: "Anonymous"
private const val SCHEMA_VERSION_NUMBER = 24L
private val ioScope = CoroutineScope(Dispatchers.IO) private val ioScope = CoroutineScope(Dispatchers.IO)
val realm: Realm val realm: Realm
@ -38,7 +40,21 @@ object RealmDB {
ShareLog::class, ShareLog::class,
Chapter::class)) Chapter::class))
.name("Podcini.realm") .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() .build()
realm = Realm.open(config) realm = Realm.open(config)
} }

View File

@ -1,5 +1,6 @@
package ac.mdiq.podcini.storage.model package ac.mdiq.podcini.storage.model
import ac.mdiq.podcini.R
import ac.mdiq.podcini.storage.database.Feeds.getFeed import ac.mdiq.podcini.storage.database.Feeds.getFeed
import ac.mdiq.vista.extractor.Vista import ac.mdiq.vista.extractor.Vista
import ac.mdiq.vista.extractor.stream.StreamInfo import ac.mdiq.vista.extractor.stream.StreamInfo
@ -83,10 +84,13 @@ class Episode : RealmObject {
*/ */
var chapters: RealmList<Chapter> = realmListOf() var chapters: RealmList<Chapter> = realmListOf()
var isFavorite: Boolean = false var rating: Int = Rating.NEUTRAL.code
// 0 : neutral, -1 : dislike, 1 : like @Ignore
var opinion: Int = 0 var isFavorite: Boolean = (rating == 2)
private set
var comment: String = ""
@Ignore @Ignore
val isNew: Boolean val isNew: Boolean
@ -281,7 +285,7 @@ class Episode : RealmObject {
if (isAutoDownloadEnabled != other.isAutoDownloadEnabled) return false if (isAutoDownloadEnabled != other.isAutoDownloadEnabled) return false
if (tags != other.tags) return false if (tags != other.tags) return false
if (chapters != other.chapters) 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 (isInProgress != other.isInProgress) return false
if (isDownloaded != other.isDownloaded) return false if (isDownloaded != other.isDownloaded) return false
@ -305,12 +309,31 @@ class Episode : RealmObject {
result = 31 * result + isAutoDownloadEnabled.hashCode() result = 31 * result + isAutoDownloadEnabled.hashCode()
result = 31 * result + tags.hashCode() result = 31 * result + tags.hashCode()
result = 31 * result + chapters.hashCode() result = 31 * result + chapters.hashCode()
result = 31 * result + isFavorite.hashCode() result = 31 * result + rating.hashCode()
result = 31 * result + isInProgress.hashCode() result = 31 * result + isInProgress.hashCode()
result = 31 * result + isDownloaded.hashCode() result = 31 * result + isDownloaded.hashCode()
return result 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) { enum class PlayState(val code: Int) {
UNSPECIFIED(-2), UNSPECIFIED(-2),
NEW(-1), NEW(-1),
@ -319,6 +342,7 @@ class Episode : RealmObject {
BUILDING(2), BUILDING(2),
ABANDONED(3) ABANDONED(3)
} }
companion object { companion object {
val TAG: String = Episode::class.simpleName ?: "Anonymous" 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() 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.R
import ac.mdiq.podcini.playback.base.InTheatre.curQueue import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.storage.database.Episodes.deleteMediaSync 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.setPlayState
import ac.mdiq.podcini.storage.database.Episodes.setPlayStateSync 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.Episodes.shouldDeleteRemoveFromQueue
import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem import ac.mdiq.podcini.storage.database.Feeds.shouldAutoDeleteItem
import ac.mdiq.podcini.storage.database.Queues.addToQueue import ac.mdiq.podcini.storage.database.Queues.addToQueue
@ -117,7 +117,7 @@ open class SwipeActions(private val fragment: Fragment, private val tag: String)
@JvmField @JvmField
val swipeActions: List<SwipeAction> = listOf( val swipeActions: List<SwipeAction> = listOf(
NoActionSwipeAction(), AddToQueueSwipeAction(), NoActionSwipeAction(), AddToQueueSwipeAction(),
StartDownloadSwipeAction(), MarkFavoriteSwipeAction(), StartDownloadSwipeAction(), ShiftRatingSwipeAction(),
TogglePlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(), TogglePlaybackStateSwipeAction(), RemoveFromQueueSwipeAction(),
DeleteSwipeAction(), RemoveFromHistorySwipeAction()) 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 { override fun getId(): String {
return SwipeAction.MARK_FAV 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 { 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) @OptIn(UnstableApi::class)
override fun performAction(item: Episode, fragment: Fragment, filter: EpisodeFilter) { 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 { override fun willRemove(filter: EpisodeFilter, item: Episode): Boolean {

View File

@ -1,6 +1,5 @@
package ac.mdiq.podcini.ui.compose package ac.mdiq.podcini.ui.compose
import ac.mdiq.podcini.R
import ac.mdiq.podcini.preferences.ThemeSwitcher.readThemeValue import ac.mdiq.podcini.preferences.ThemeSwitcher.readThemeValue
import ac.mdiq.podcini.preferences.UserPreferences.ThemePreference import ac.mdiq.podcini.preferences.UserPreferences.ThemePreference
import ac.mdiq.podcini.util.Logd import ac.mdiq.podcini.util.Logd
@ -9,13 +8,9 @@ import android.util.TypedValue
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -57,157 +52,26 @@ fun getColorFromAttr(context: Context, @AttrRes attrColor: Int): Int {
} }
} }
fun getPrimaryColor(context: Context): Color { private val LightColors = lightColorScheme()
return Color(getColorFromAttr(context, R.attr.colorPrimary)) private val DarkColors = darkColorScheme()
} //private val LightColors = dynamicLightColorScheme()
//private val DarkColors = dynamicDarkColorScheme()
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,
)
@Composable @Composable
fun CustomTheme(context: Context, content: @Composable () -> Unit) { fun CustomTheme(context: Context, content: @Composable () -> Unit) {
// val primaryColor = getPrimaryColor(context)
// val secondaryColor = getSecondaryColor(context)
val colors = when (readThemeValue(context)) { val colors = when (readThemeValue(context)) {
ThemePreference.LIGHT -> { ThemePreference.LIGHT -> {
Logd(TAG, "Light theme") Logd(TAG, "Light theme")
LightColors LightColors
} }
ThemePreference.DARK, ThemePreference.BLACK -> { ThemePreference.DARK -> {
Logd(TAG, "Dark theme") Logd(TAG, "Dark theme")
DarkColors DarkColors
} }
ThemePreference.BLACK -> {
Logd(TAG, "Dark theme")
DarkColors.copy(surface = Color(0xFF000000))
}
ThemePreference.SYSTEM -> { ThemePreference.SYSTEM -> {
if (isSystemInDarkTheme()) { if (isSystemInDarkTheme()) {
Logd(TAG, "System Dark theme") 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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 positionState by mutableStateOf(episode.media?.position?:0)
var playedState by mutableStateOf(episode.isPlayed()) var playedState by mutableStateOf(episode.isPlayed())
var isPlayingState by mutableStateOf(false) var isPlayingState by mutableStateOf(false)
var farvoriteState by mutableStateOf(episode.isFavorite) var ratingState by mutableIntStateOf(episode.rating)
var inProgressState by mutableStateOf(episode.isInProgress) 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 downloadState by mutableIntStateOf(if (episode.media?.downloaded == true) DownloadStatus.State.COMPLETED.ordinal else DownloadStatus.State.UNKNOWN.ordinal)
var isRemote by mutableStateOf(false) var isRemote by mutableStateOf(false)
@ -112,8 +112,8 @@ class EpisodeVM(var episode: Episode) {
var isSelected by mutableStateOf(false) var isSelected by mutableStateOf(false)
var prog by mutableFloatStateOf(0f) var prog by mutableFloatStateOf(0f)
var episodeMonitor: Job? by mutableStateOf(null) private var episodeMonitor: Job? by mutableStateOf(null)
var mediaMonitor: Job? by mutableStateOf(null) private var mediaMonitor: Job? by mutableStateOf(null)
fun stopMonitoring() { fun stopMonitoring() {
episodeMonitor?.cancel() episodeMonitor?.cancel()
@ -136,7 +136,7 @@ class EpisodeVM(var episode: Episode) {
if (episode.id == changes.obj.id) { if (episode.id == changes.obj.id) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
playedState = changes.obj.isPlayed() playedState = changes.obj.isPlayed()
farvoriteState = changes.obj.isFavorite ratingState = changes.obj.rating
episode = changes.obj // direct assignment doesn't update member like media?? episode = changes.obj // direct assignment doesn't update member like media??
} }
Logd("EpisodeVM", "episodeMonitor $playedState $playedState ") Logd("EpisodeVM", "episodeMonitor $playedState $playedState ")
@ -193,7 +193,28 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
}) })
@Composable @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) } var isExpanded by remember { mutableStateOf(false) }
val options = mutableListOf<@Composable () -> Unit>( val options = mutableListOf<@Composable () -> Unit>(
{ Row(modifier = Modifier.padding(horizontal = 16.dp) { Row(modifier = Modifier.padding(horizontal = 16.dp)
@ -202,11 +223,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
selectMode = false selectMode = false
Logd(TAG, "ic_delete: ${selected.size}") Logd(TAG, "ic_delete: ${selected.size}")
LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected) LocalDeleteModal.deleteEpisodesWarnLocal(activity, selected)
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_delete), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false 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() if (episode.media != null && episode.feed != null && !episode.feed!!.isLocalFeed) DownloadServiceInterface.get()
?.download(activity, episode) ?.download(activity, episode)
} }
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_download), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false isExpanded = false
selectMode = false selectMode = false
Logd(TAG, "ic_mark_played: ${selected.size}") Logd(TAG, "ic_mark_played: ${selected.size}")
setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray()) setPlayState(Episode.PlayState.UNSPECIFIED.code, false, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_mark_played), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false isExpanded = false
selectMode = false selectMode = false
Logd(TAG, "ic_playlist_remove: ${selected.size}") Logd(TAG, "ic_playlist_remove: ${selected.size}")
removeFromQueue(*selected.toTypedArray()) removeFromQueue(*selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_remove), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false isExpanded = false
selectMode = false selectMode = false
Logd(TAG, "ic_playlist_play: ${selected.size}") Logd(TAG, "ic_playlist_play: ${selected.size}")
Queues.addToQueue(true, *selected.toTypedArray()) Queues.addToQueue(true, *selected.toTypedArray())
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false isExpanded = false
selectMode = false selectMode = false
Logd(TAG, "ic_playlist_play: ${selected.size}") Logd(TAG, "ic_playlist_play: ${selected.size}")
PutToQueueDialog(activity, selected).show() PutToQueueDialog(activity, selected).show()
}, verticalAlignment = Alignment.CenterVertically }, verticalAlignment = Alignment.CenterVertically) {
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_playlist_play), "") 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) { Row(modifier = Modifier.padding(horizontal = 16.dp)
.clickable { .clickable {
isExpanded = false
selectMode = false selectMode = false
Logd(TAG, "ic_star: ${selected.size}") Logd(TAG, "ic_star: ${selected.size}")
for (item in selected) { showChooseRatingDialog = true
Episodes.setFavorite(item, null) isExpanded = false
} }, verticalAlignment = Alignment.CenterVertically) {
}, verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_star), "") 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) if (selected.isNotEmpty() && selected[0].isRemote.value)
options.add({ Row(modifier = Modifier.padding(horizontal = 16.dp) options.add {
.clickable { Row(modifier = Modifier.padding(horizontal = 16.dp)
isExpanded = false .clickable {
selectMode = false isExpanded = false
Logd(TAG, "reserve: ${selected.size}") selectMode = false
CoroutineScope(Dispatchers.IO).launch { Logd(TAG, "reserve: ${selected.size}")
youtubeUrls.clear() CoroutineScope(Dispatchers.IO).launch {
for (e in selected) { youtubeUrls.clear()
Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}") for (e in selected) {
val url = URL(e.media?.downloadUrl?: "") Logd(TAG, "downloadUrl: ${e.media?.downloadUrl}")
if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) { val url = URL(e.media?.downloadUrl ?: "")
youtubeUrls.add(e.media!!.downloadUrl!!) if ((isYoutubeURL(url) && url.path.startsWith("/watch")) || isYoutubeServiceURL(url)) {
} else addToMiscSyndicate(e) 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}") }, verticalAlignment = Alignment.CenterVertically) {
withContext(Dispatchers.Main) { Icon(Icons.Filled.AddCircle, "")
showConfirmYoutubeDialog.value = youtubeUrls.isNotEmpty() 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() val scrollState = rememberScrollState()
Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) { Column(modifier = modifier.verticalScroll(scrollState), verticalArrangement = Arrangement.Bottom) {
@ -312,7 +318,8 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
containerColor = Color.LightGray, containerColor = Color.LightGray,
onClick = {}) { button() } onClick = {}) { button() }
} }
FloatingActionButton(containerColor = Color.Green, FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary,
onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") }
} }
} }
@ -437,8 +444,9 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Row { Row {
if (vm.episode.media?.getMediaType() == MediaType.VIDEO) 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)) Icon(painter = painterResource(R.drawable.ic_videocam), tint = textColor, contentDescription = "isVideo", modifier = Modifier.width(14.dp).height(14.dp))
if (vm.farvoriteState) val ratingIconRes = Episode.Rating.fromCode(vm.ratingState).res
Icon(painter = painterResource(R.drawable.ic_star), tint = textColor, contentDescription = "isFavorite", modifier = Modifier.width(14.dp).height(14.dp)) 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) if (vm.inQueueState)
Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp)) Icon(painter = painterResource(R.drawable.ic_playlist_play), tint = textColor, contentDescription = "ivInPlaylist", modifier = Modifier.width(14.dp).height(14.dp))
val curContext = LocalContext.current val curContext = LocalContext.current
@ -532,7 +540,7 @@ fun EpisodeLazyColumn(activity: MainActivity, vms: SnapshotStateList<EpisodeVM>,
Logd(TAG, "selectedIds: ${selected.size}") 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) { if (showDialog) {
Dialog(onDismissRequest = { onDismissRequest() }) { 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) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.Center) {
var audioOnly by remember { mutableStateOf(false) } var audioOnly by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth()) { 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) onPlaybackServiceChanged(event)
} }
is FlowEvent.PlayEvent -> onPlayEvent(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.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()) loadMediaInfo(false)
is FlowEvent.SleepTimerUpdatedEvent -> if (event.isCancelled || event.wasJustEnabled()) setupOptionsMenu() 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) 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}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.SwipeActionsChangedEvent -> refreshSwipeTelltale() 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 -> {} 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.DurationConverter
import ac.mdiq.podcini.storage.utils.ImageResourceUtils import ac.mdiq.podcini.storage.utils.ImageResourceUtils
import ac.mdiq.podcini.ui.actions.* 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.activity.MainActivity
import ac.mdiq.podcini.ui.compose.CustomTheme import ac.mdiq.podcini.ui.compose.CustomTheme
import ac.mdiq.podcini.ui.utils.ShownotesCleaner import ac.mdiq.podcini.ui.utils.ShownotesCleaner
@ -46,24 +45,27 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.*
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource 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.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.app.ShareCompat
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
@ -78,11 +80,8 @@ import com.skydoves.balloon.ArrowOrientation
import com.skydoves.balloon.ArrowOrientationRules import com.skydoves.balloon.ArrowOrientationRules
import com.skydoves.balloon.Balloon import com.skydoves.balloon.Balloon
import com.skydoves.balloon.BalloonAnimation import com.skydoves.balloon.BalloonAnimation
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.dankito.readability4j.extended.Readability4JExtended import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.Request.Builder import okhttp3.Request.Builder
import java.io.File import java.io.File
@ -123,19 +122,16 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
toolbar = binding.toolbar toolbar = binding.toolbar
toolbar.title = "" toolbar.title = ""
toolbar.inflateMenu(R.menu.feeditem_options) toolbar.inflateMenu(R.menu.episode_info)
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
toolbar.setOnMenuItemClickListener(this) toolbar.setOnMenuItemClickListener(this)
binding.composeView.setContent{ binding.composeView.setContent{
CustomTheme(requireContext()) { 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 = binding.webvDescription
// webvDescription.setTimecodeSelectedListener { time: Int? -> // webvDescription.setTimecodeSelectedListener { time: Int? ->
// val cMedia = curMedia // val cMedia = curMedia
@ -144,40 +140,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
// } // }
// registerForContextMenu(webvDescription) // 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()) shownotesCleaner = ShownotesCleaner(requireContext())
onFragmentLoaded() onFragmentLoaded()
load() load()
@ -185,16 +147,55 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
} }
@Composable @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 { Column {
val textColor = MaterialTheme.colorScheme.onSurface
Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically) {
val imgLoc = if (episode != null) ImageResourceUtils.getEpisodeListImageLocation(episode!!) else null 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() })) AsyncImage(model = imgLoc, contentDescription = "imgvCover", Modifier.width(56.dp).height(56.dp).clickable(onClick = { openPodcast() }))
Column(modifier = Modifier.padding(start = 10.dp)) { Column(modifier = Modifier.padding(start = 10.dp)) {
Text(txtvPodcast, color = textColor, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.clickable { openPodcast() }) 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(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) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -250,6 +251,10 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
}, update = { }, update = {
it.loadDataWithBaseURL("https://127.0.0.1", webviewData, "text/html", "utf-8", "about:blank") 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) Text(itemLink, color = textColor, style = MaterialTheme.typography.bodySmall)
} }
} }
@ -339,7 +344,6 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
@OptIn(UnstableApi::class) override fun onDestroyView() { @OptIn(UnstableApi::class) override fun onDestroyView() {
Logd(TAG, "onDestroyView") Logd(TAG, "onDestroyView")
// binding.root.removeView(webvDescription)
episode = null episode = null
// webvDescription.clearHistory() // webvDescription.clearHistory()
// webvDescription.clearCache(true) // webvDescription.clearCache(true)
@ -432,11 +436,11 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
val dls = DownloadServiceInterface.get() val dls = DownloadServiceInterface.get()
if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) { if (episode != null && episode!!.media != null && episode!!.media!!.downloadUrl != null) {
val url = episode!!.media!!.downloadUrl!! val url = episode!!.media!!.downloadUrl!!
if (dls != null && dls.isDownloadingEpisode(url)) { // if (dls != null && dls.isDownloadingEpisode(url)) {
// binding.circularProgressBar.visibility = View.VISIBLE // binding.circularProgressBar.visibility = View.VISIBLE
// binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode) // binding.circularProgressBar.setPercentage(0.01f * max(1.0, dls.getProgress(url).toDouble()).toFloat(), episode)
// binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url)) // binding.circularProgressBar.setIndeterminate(dls.isEpisodeQueued(url))
} // }
} }
val media: EpisodeMedia? = episode?.media 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 (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 { // override fun onContextItemSelected(item: MenuItem): Boolean {
@ -506,7 +501,7 @@ class EpisodeInfoFragment : Fragment(), Toolbar.OnMenuItemClickListener {
Logd(TAG, "Received event: ${event.TAG}") Logd(TAG, "Received event: ${event.TAG}")
when (event) { when (event) {
is FlowEvent.QueueEvent -> onQueueEvent(event) is FlowEvent.QueueEvent -> onQueueEvent(event)
is FlowEvent.FavoritesEvent -> onFavoriteEvent(event) is FlowEvent.RatingEvent -> onFavoriteEvent(event)
is FlowEvent.EpisodeEvent -> onEpisodeEvent(event) is FlowEvent.EpisodeEvent -> onEpisodeEvent(event)
is FlowEvent.PlayerSettingsEvent -> updateButtons() is FlowEvent.PlayerSettingsEvent -> updateButtons()
is FlowEvent.EpisodePlayedEvent -> load() 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) { if (episode?.id == event.episode.id) {
episode = unmanaged(episode!!) 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 // episode = event.episode
prepareMenu() prepareMenu()
} }

View File

@ -359,6 +359,13 @@ import java.util.concurrent.Semaphore
}.start() }.start()
} }
R.id.sort_items -> SingleFeedSortDialog(feed).show(childFragmentManager, "SortDialog") 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.rename_feed -> CustomFeedNameDialog(activity as Activity, feed!!).show()
R.id.remove_feed -> { R.id.remove_feed -> {
RemoveFeedDialog.show(requireContext(), 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons 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.filled.Edit
import androidx.compose.material.icons.outlined.AddCircle
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
@ -802,7 +803,8 @@ class SubscriptionsFragment : Fragment(), Toolbar.OnMenuItemClickListener {
containerColor = Color.LightGray, containerColor = Color.LightGray,
onClick = {}) { button() } onClick = {}) { button() }
} }
FloatingActionButton(containerColor = Color.Green, FloatingActionButton(containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.secondary,
onClick = { isExpanded = !isExpanded }) { Icon(Icons.Filled.Edit, "Edit") } 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)) 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), modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 16.dp, end = 16.dp),
onClick = { onClick = {
if (activity is MainActivity) (activity as MainActivity).loadChildFragment(OnlineSearchFragment()) 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 // TODO: need better handling at receving end
data class EpisodePlayedEvent(val episode: Episode? = null) : FlowEvent() 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() data class AllEpisodesFilterEvent(val filterValues: Set<String?>?) : FlowEvent()

View File

@ -11,10 +11,24 @@
<item <item
android:id="@+id/sort_items" android:id="@+id/sort_items"
android:icon="@drawable/arrows_sort" android:icon="@drawable/arrows_sort"
android:menuCategory="container"
android:title="@string/sort" android:title="@string/sort"
custom:showAsAction="always"> custom:showAsAction="always">
</item> </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 <item
android:id="@+id/refresh_feed" android:id="@+id/refresh_feed"
android:menuCategory="container" android:menuCategory="container"

View File

@ -192,7 +192,6 @@
<string name="new_episode_notification_group_text">Your subscriptions have new episodes.</string> <string name="new_episode_notification_group_text">Your subscriptions have new episodes.</string>
<!-- Actions on feeds --> <!-- 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_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_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> <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> </plurals>
<string name="javasript_label">JavaScript</string> <string name="javasript_label">JavaScript</string>
<string name="no_action_label">No action</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="removed_inbox_label">Removed from inbox</string>
<string name="mark_read_label">Mark as played</string> <string name="mark_read_label">Mark as played</string>
@ -287,7 +286,9 @@
</plurals> </plurals>
<string name="show_video_label">Play video</string> <string name="show_video_label">Play video</string>
<string name="add_to_favorite_label">Add to favorites</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="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="remove_from_favorite_label">Remove from favorites</string>
<string name="visit_website_label">Visit website</string> <string name="visit_website_label">Visit website</string>
<string name="skip_episode_label">Skip episode</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.feed.LocalFeedUpdater.tryUpdateFeed
import ac.mdiq.podcini.net.download.service.DownloadServiceInterface import ac.mdiq.podcini.net.download.service.DownloadServiceInterface
import ac.mdiq.podcini.net.download.serviceinterface.DownloadServiceInterfaceTestStub 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.preferences.UserPreferences
import ac.mdiq.podcini.storage.database.Feeds.getFeedList import ac.mdiq.podcini.storage.database.Feeds.getFeedList
import ac.mdiq.podcini.storage.model.Feed 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.ApplicationCallbacks
import ac.mdiq.podcini.util.config.ClientConfig import ac.mdiq.podcini.util.config.ClientConfig
import android.app.Application import android.app.Application
@ -233,7 +232,7 @@ class LocalFeedUpdaterTest {
* @param localFeedDir assets local feed folder with media files * @param localFeedDir assets local feed folder with media files
*/ */
private fun callUpdateFeed(localFeedDir: String) { private fun callUpdateFeed(localFeedDir: String) {
Mockito.mockStatic(FastDocumentFile::class.java).use { dfMock -> Mockito.mockStatic(LocalFeedUpdater.FastDocumentFile::class.java).use { dfMock ->
// mock external storage // mock external storage
dfMock.`when`<Any> { list(ArgumentMatchers.any(), ArgumentMatchers.any()) } dfMock.`when`<Any> { list(ArgumentMatchers.any(), ArgumentMatchers.any()) }
.thenReturn(mockLocalFolder(localFeedDir)) .thenReturn(mockLocalFolder(localFeedDir))
@ -283,16 +282,16 @@ class LocalFeedUpdaterTest {
/** /**
* Create a DocumentFile mock object. * Create a DocumentFile mock object.
*/ */
private fun mockDocumentFile(fileName: String, mimeType: String): FastDocumentFile { private fun mockDocumentFile(fileName: String, mimeType: String): LocalFeedUpdater.FastDocumentFile {
return FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0) return LocalFeedUpdater.FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/$fileName"), 0, 0)
} }
private fun mockLocalFolder(folderName: String): List<FastDocumentFile> { private fun mockLocalFolder(folderName: String): List<LocalFeedUpdater.FastDocumentFile> {
val files: MutableList<FastDocumentFile> = ArrayList() val files: MutableList<LocalFeedUpdater.FastDocumentFile> = ArrayList()
for (f in Objects.requireNonNull<Array<File>>(File(folderName).listFiles())) { for (f in Objects.requireNonNull<Array<File>>(File(folderName).listFiles())) {
val extension = MimeTypeMap.getFileExtensionFromUrl(f.path) val extension = MimeTypeMap.getFileExtensionFromUrl(f.path)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) 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())) Uri.parse(f.toURI().toString()), f.length(), f.lastModified()))
} }
return files 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 # 6.9.1
* added logging for shared actions * 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 logging for shared actions
* added simple fragment for viewing shared logs and repairing failed share 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" #material = "1.7.2"
material3 = "1.3.0" material3 = "1.3.0"
#material3Android = "1.3.0" #material3Android = "1.3.0"
materialIconsExtended = "1.7.3"
materialVersion = "1.12.0" materialVersion = "1.12.0"
media3Common = "1.4.1" media3Common = "1.4.1"
media3Session = "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-junit = { module = "androidx.test.ext:junit", version.ref = "junit" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } 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 = { 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 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
androidx-material3-android = { group = "androidx.compose.material3", name = "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" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }