6.14.2 commit

This commit is contained in:
Xilin Jia 2024-11-18 21:57:07 +01:00
parent a213284e40
commit 737577a15a
19 changed files with 270 additions and 195 deletions

View File

@ -11,6 +11,7 @@ An open source podcast instrument, attuned to Puccini ![Puccini](./images/Puccin
[<img src="./images/external/getItf-droid.png" alt="F-Droid" height="50">](https://f-droid.org/packages/ac.mdiq.podcini.R/)
[<img src="./images/external/amazon.png" alt="Amazon" height="40">](https://www.amazon.com/%E8%B4%BE%E8%A5%BF%E6%9E%97-Podcini-R/dp/B0D9WR8P13)
#### The play app of Podcini.R 6.14 allows casting audio-only Youtube media to a Chromecast speaker
#### Podcini.R 6.10 allows creating synthetic podcast and shelving any episdes to any synthetic podcasts
#### Podcini.R 6.5 as a major step forward brings YouTube contents in the app. Channels can be searched, received from share, subscribed. Podcasts, playlists as well as single media from Youtube and YT Music can be shared to Podcini. For more see the Youtube section below or the changelogs
That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs)
@ -18,7 +19,8 @@ That means finally: [Nessun dorma](https://www.youtube.com/watch?v=cWc7vYjgnTs)
#### If you need to cast to an external speaker, you should install the "play" apk, not the "free" apk, that's about the difference between the two.
#### Podcini.R requests for permission for unrestricted background activities for uninterrupted background play of a playlist. For more see [this issue](https://github.com/XilinJia/Podcini/issues/88)
#### If you intend to sync through a server, be cautious as it's not well tested with Podcini. Welcome any ideas and contribution on this.
#### If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions.
If you are migrating from Podcini version 5, please read the migrationTo5.md file for migration instructions.
This project was developed from a fork of [AntennaPod](<https://github.com/AntennaPod/AntennaPod>) as of Feb 5 2024.

View File

@ -26,8 +26,8 @@ android {
vectorDrawables.useSupportLibrary false
vectorDrawables.generatedDensities = []
versionCode 3020300
versionName "6.14.1"
versionCode 3020301
versionName "6.14.2"
applicationId "ac.mdiq.podcini.R"
def commit = ""

View File

@ -5,8 +5,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
/**
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the
* network.
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the network.
*/
abstract class CastEnabledActivity : AppCompatActivity() {
val TAG = this::class.simpleName ?: "Anonymous"

View File

@ -136,14 +136,14 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
bufferingUpdateListener = null
}
private fun setAudioStreamType(i: Int) {
val a = exoPlayer!!.audioAttributes
val b = AudioAttributes.Builder()
b.setContentType(i)
b.setFlags(a.flags)
b.setUsage(a.usage)
exoPlayer?.setAudioAttributes(b.build(), true)
}
// private fun setAudioStreamType(i: Int) {
// val a = exoPlayer!!.audioAttributes
// val b = AudioAttributes.Builder()
// b.setContentType(i)
// b.setFlags(a.flags)
// b.setUsage(a.usage)
// exoPlayer?.setAudioAttributes(b.build(), true)
// }
/**
* Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing
@ -208,7 +208,8 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
val metadata = buildMetadata(curMedia!!)
try {
callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(false)
// TODO: test
callback.onMediaChanged(true)
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
CoroutineScope(Dispatchers.IO).launch {
when {
@ -484,7 +485,13 @@ class LocalMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaP
status = PlayerStatus.STOPPED
return
}
setAudioStreamType(C.AUDIO_CONTENT_TYPE_SPEECH)
val i = (curMedia as? EpisodeMedia)?.episode?.feed?.preferences?.audioType?: C.AUDIO_CONTENT_TYPE_SPEECH
val a = exoPlayer!!.audioAttributes
val b = AudioAttributes.Builder()
b.setContentType(i)
b.setFlags(a.flags)
b.setUsage(a.usage)
exoPlayer?.setAudioAttributes(b.build(), true)
setMediaPlayerListeners()
}

View File

@ -125,7 +125,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
}
@Throws(IllegalArgumentException::class, IllegalStateException::class)
protected fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
protected open fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
Logd(TAG, "setDataSource1 called")
val url = media.getStreamUrl() ?: return
val preferences = media.episodeOrFetch()?.feed?.preferences
@ -185,8 +185,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
private fun setSourceCredentials(user: String?, password: String?) {
if (!user.isNullOrEmpty() && !password.isNullOrEmpty()) {
if (httpDataSourceFactory == null)
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory)
.setUserAgent(ClientConfig.USER_AGENT)
httpDataSourceFactory = OkHttpDataSource.Factory(PodciniHttpClient.getHttpClient() as okhttp3.Call.Factory).setUserAgent(ClientConfig.USER_AGENT)
val requestProperties = HashMap<String, String>()
requestProperties["Authorization"] = HttpCredentialEncoder.encode(user, password, "ISO-8859-1")
@ -211,7 +210,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
* @param preferVideoOnlyStreams if video-only streams should preferred when both video-only streams and normal video streams are available
* @return the sorted list
*/
private fun getSortedStreamVideosList(videoStreams: List<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
protected fun getSortedStreamVideosList(videoStreams: List<VideoStream>?, videoOnlyStreams: List<VideoStream>?, ascendingOrder: Boolean,
preferVideoOnlyStreams: Boolean): List<VideoStream> {
val videoStreamsOrdered = if (preferVideoOnlyStreams) listOf(videoStreams, videoOnlyStreams) else listOf(videoOnlyStreams, videoStreams)
val allInitialStreams = videoStreamsOrdered.filterNotNull().flatten().toList()
@ -228,7 +227,7 @@ abstract class MediaPlayerBase protected constructor(protected val context: Cont
}
}
private fun getFilteredAudioStreams(audioStreams: List<AudioStream>?): List<AudioStream> {
protected fun getFilteredAudioStreams(audioStreams: List<AudioStream>?): List<AudioStream> {
if (audioStreams == null) return listOf()
val collectedStreams = mutableSetOf<AudioStream>()
for (stream in audioStreams) {

View File

@ -65,6 +65,7 @@ import ac.mdiq.podcini.util.IntentUtils.sendLocalBroadcast
import ac.mdiq.podcini.util.Logd
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
@ -220,8 +221,8 @@ class PlaybackService : MediaLibraryService() {
private val taskManagerCallback: TaskManager.PSTMCallback = object : TaskManager.PSTMCallback {
override fun positionSaverTick() {
Logd(TAG, "positionSaverTick currentPosition: $curPosition, currentPlaybackSpeed: $curSpeed")
if (curPosition != prevPosition) {
// Log.d(TAG, "positionSaverTick currentPosition: $currentPosition, currentPlaybackSpeed: $currentPlaybackSpeed")
if (curMedia != null) EventFlow.postEvent(FlowEvent.PlaybackPositionEvent(curMedia, curPosition, curDuration))
skipEndingIfNecessary()
persistCurrentPosition(true, null, Playable.INVALID_TIME)
@ -356,15 +357,6 @@ class PlaybackService : MediaLibraryService() {
if (ended || smartMarkAsPlayed || autoSkipped || (skipped && !shouldSkipKeepEpisode)) {
Logd(TAG, "onPostPlayback ended: $ended smartMarkAsPlayed: $smartMarkAsPlayed autoSkipped: $autoSkipped skipped: $skipped")
// only mark the item as played if we're not keeping it anyways
// item = setPlayStateSync(PlayState.PLAYED.code, item!!, ended || (skipped && smartMarkAsPlayed), false)
// if (playable is EpisodeMedia && (ended || skipped || playingNext)) {
// item = upsert(item!!) {
// it.media?.playbackCompletionDate = Date()
// }
// EventFlow.postEvent(FlowEvent.HistoryEvent())
// }
if (playable !is EpisodeMedia)
item = setPlayStateSync(PlayState.PLAYED.code, item!!, ended || (skipped && smartMarkAsPlayed), false)
else {
@ -784,12 +776,9 @@ class PlaybackService : MediaLibraryService() {
val keycode = intent?.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1) ?: -1
val customAction = intent?.getStringExtra(MediaButtonReceiver.EXTRA_CUSTOM_ACTION)
val hardwareButton = intent?.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false) == true
val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(EXTRA_KEY_EVENT, KeyEvent::class.java)
else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_KEY_EVENT)
}
val keyEvent: KeyEvent? = if (Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) intent?.getParcelableExtra(EXTRA_KEY_EVENT, KeyEvent::class.java)
else intent?.getParcelableExtra(EXTRA_KEY_EVENT)
val playable = curMedia
Log.d(TAG, "onStartCommand flags=$flags startId=$startId keycode=$keycode keyEvent=$keyEvent customAction=$customAction hardwareButton=$hardwareButton action=${intent?.action.toString()} ${playable?.getEpisodeTitle()}")
if (keycode == -1 && playable == null && customAction == null) {
@ -817,6 +806,19 @@ class PlaybackService : MediaLibraryService() {
return super.onStartCommand(intent, flags, startId)
}
playable != null -> {
if (Build.VERSION.SDK_INT >= 26) {
val CHANNEL_ID = "podcini playback service"
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(CHANNEL_ID, "Title", NotificationManager.IMPORTANCE_LOW).apply {
setSound(null, null)
enableVibration(false)
}
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID).setContentTitle("").setContentText("").build()
startForeground(1, notification)
}
recreateMediaSessionIfNeeded()
Logd(TAG, "onStartCommand status: $status")
val allowStreamThisTime = intent?.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false) == true
@ -825,7 +827,8 @@ class PlaybackService : MediaLibraryService() {
if (allowStreamAlways) isAllowMobileStreaming = true
startPlaying(allowStreamThisTime)
// return super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
// return START_NOT_STICKY
return START_STICKY
}
else -> Logd(TAG, "onStartCommand case when not (keycode != -1 and playable != null)")
}
@ -1163,7 +1166,7 @@ class PlaybackService : MediaLibraryService() {
} else duration_ = playable?.getDuration() ?: Playable.INVALID_TIME
if (position != Playable.INVALID_TIME && duration_ != Playable.INVALID_TIME && playable != null) {
// Log.d(TAG, "Saving current position to $position $duration")
Logd(TAG, "persistCurrentPosition to $position $duration_ ${playable.getEpisodeTitle()}")
playable.setPosition(position)
playable.setLastPlayedTime(System.currentTimeMillis())

View File

@ -40,7 +40,7 @@ object RealmDB {
SubscriptionLog::class,
Chapter::class))
.name("Podcini.realm")
.schemaVersion(32)
.schemaVersion(33)
.migration({ mContext ->
val oldRealm = mContext.oldRealm // old realm using the previous schema
val newRealm = mContext.newRealm // new realm using the new schema

View File

@ -300,12 +300,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
override fun onPlaybackPause(context: Context) {
Logd(TAG, "onPlaybackPause $position $duration")
if (position > startPosition) {
// playedDuration = playedDurationWhenStarted + position - startPosition
// playedDurationWhenStarted = playedDuration
playedDuration = playedDurationWhenStarted + position - startPosition
// playedDurationWhenStarted = playedDuration
}
if (position > startPosition) playedDuration = playedDurationWhenStarted + position - startPosition
timeSpent = timeSpentOnStart + (System.currentTimeMillis() - startTime).toInt()
startPosition = position
}
@ -321,9 +316,7 @@ class EpisodeMedia: EmbeddedRealmObject, Playable {
override fun setChapters(chapters: List<Chapter>) {
if (episode != null) {
episode!!.chapters.clear()
for (c in chapters) {
c.episode = episode
}
for (c in chapters) c.episode = episode
episode!!.chapters.addAll(chapters)
}
}

View File

@ -5,14 +5,12 @@ import ac.mdiq.podcini.playback.base.InTheatre.curQueue
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.model.VolumeAdaptionSetting.Companion.fromInteger
import androidx.media3.common.C
import io.realm.kotlin.ext.realmSetOf
import io.realm.kotlin.types.EmbeddedRealmObject
import io.realm.kotlin.types.RealmSet
import io.realm.kotlin.types.annotations.Ignore
/**
* Contains preferences for a single feed.
*/
class FeedPreferences : EmbeddedRealmObject {
var feedID: Long = 0L
@ -50,6 +48,15 @@ class FeedPreferences : EmbeddedRealmObject {
}
var autoDelete: Int = AutoDeleteAction.GLOBAL.code
@Ignore
var audioTypeSetting: AudioType = AudioType.SPEECH
get() = AudioType.fromCode(audioType)
set(value) {
field = value
audioType = field.code
}
var audioType: Int = AudioType.SPEECH.code
@Ignore
var volumeAdaptionSetting: VolumeAdaptionSetting = VolumeAdaptionSetting.OFF
get() = fromInteger(volumeAdaption)
@ -132,7 +139,7 @@ class FeedPreferences : EmbeddedRealmObject {
autoDLInclude = value?.includeFilterRaw ?: ""
autoDLExclude = value?.excludeFilterRaw ?: ""
autoDLMinDuration = value?.minimalDurationFilter ?: -1
markExcludedPlayed = value?.markExcludedPlayed ?: false
markExcludedPlayed = value?.markExcludedPlayed == true
}
var autoDLInclude: String? = ""
var autoDLExclude: String? = ""
@ -140,8 +147,7 @@ class FeedPreferences : EmbeddedRealmObject {
var markExcludedPlayed: Boolean = false
var autoDLMaxEpisodes: Int = 3
var countingPlayed: Boolean = true
var countingPlayed: Boolean = true // relates to autoDLMaxEpisodes
@Ignore
var autoDLPolicy: AutoDownloadPolicy = AutoDownloadPolicy.ONLY_NEW
@ -215,6 +221,22 @@ class FeedPreferences : EmbeddedRealmObject {
}
}
enum class AudioType(val code: Int, val tag: String) {
UNKNOWN(C.AUDIO_CONTENT_TYPE_UNKNOWN, "Unknown"),
SPEECH(C.AUDIO_CONTENT_TYPE_SPEECH, "Speech"),
MUSIC(C.AUDIO_CONTENT_TYPE_MUSIC, "Music"),
MOVIE(C.AUDIO_CONTENT_TYPE_MOVIE, "Movie");
companion object {
fun fromCode(code: Int): AudioType {
return enumValues<AudioType>().firstOrNull { it.code == code } ?: SPEECH
}
fun fromTag(tag: String): AudioType {
return enumValues<AudioType>().firstOrNull { it.tag == tag } ?: SPEECH
}
}
}
enum class AVQuality(val code: Int, val tag: String) {
GLOBAL(0, "Global"),
LOW(1, "Low"),

View File

@ -217,40 +217,3 @@ fun NonlazyGrid(columns: Int, itemCount: Int, modifier: Modifier = Modifier, con
}
}
}
@Composable
fun AutoCompleteTextField(suggestions: List<String>) {
var text by remember { mutableStateOf("") }
var filteredSuggestions by remember { mutableStateOf(suggestions) }
var showSuggestions by remember { mutableStateOf(false) }
Column {
TextField(value = text, onValueChange = {
text = it
filteredSuggestions = suggestions.filter { item ->
item.contains(text, ignoreCase = true)
}
showSuggestions = text.isNotEmpty() && filteredSuggestions.isNotEmpty()
},
placeholder = { Text("Type something...") },
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
}
),
modifier = Modifier.fillMaxWidth()
)
if (showSuggestions) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(min = 0.dp, max = 200.dp)) {
items(filteredSuggestions.size) { index ->
Text(text = filteredSuggestions[index], modifier = Modifier.clickable(onClick = {
text = filteredSuggestions[index]
showSuggestions = false
}).padding(8.dp))
}
}
}
}
}

View File

@ -8,6 +8,7 @@ import ac.mdiq.podcini.net.feed.FeedUpdateManager.runOnce
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.playback.base.VideoMode.Companion.videoModeTags
import ac.mdiq.podcini.preferences.UserPreferences.isEnableAutodownload
import ac.mdiq.podcini.storage.database.Feeds.getTags
import ac.mdiq.podcini.storage.database.Feeds.persistFeedPreferences
import ac.mdiq.podcini.storage.database.RealmDB.realm
import ac.mdiq.podcini.storage.database.RealmDB.upsertBlk
@ -39,16 +40,20 @@ import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -115,6 +120,22 @@ class FeedSettingsFragment : Fragment() {
Text(text = stringResource(R.string.keep_updated_summary), style = MaterialTheme.typography.bodyMedium, color = textColor)
}
}
Column {
var showDialog by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf(feed?.preferences?.audioTypeSetting?.tag ?: FeedPreferences.AudioType.SPEECH.tag) }
if (showDialog) SetAudioType(selectedOption = selectedOption, onDismissRequest = { showDialog = false })
Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.baseline_audiotrack_24), "", tint = textColor)
Spacer(modifier = Modifier.width(20.dp))
Text(text = stringResource(R.string.pref_feed_audio_type), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = {
selectedOption = feed!!.preferences?.audioTypeSetting?.tag ?: FeedPreferences.AudioType.SPEECH.tag
showDialog = true
})
)
}
Text(text = stringResource(R.string.pref_feed_audio_type_sum), style = MaterialTheme.typography.bodyMedium, color = textColor)
}
if ((feed?.id?:0) >= MAX_NATURAL_SYNTHETIC_ID && feed?.hasVideoMedia == true) {
// video mode
Column {
@ -240,11 +261,14 @@ class FeedSettingsFragment : Fragment() {
}
// tags
Column {
var showDialog by remember { mutableStateOf(false) }
if (showDialog) TagSettingDialog(onDismiss = { showDialog = false })
Row(Modifier.fillMaxWidth()) {
Icon(ImageVector.vectorResource(id = R.drawable.ic_tag), "", tint = textColor)
Spacer(modifier = Modifier.width(20.dp))
Text(text = stringResource(R.string.feed_tags_label), style = MaterialTheme.typography.titleLarge, color = textColor,
modifier = Modifier.clickable(onClick = {
// showDialog = true
val dialog = TagSettingsDialog.newInstance(listOf(feed!!))
dialog.show(parentFragmentManager, TagSettingsDialog.TAG)
})
@ -632,6 +656,31 @@ class FeedSettingsFragment : Fragment() {
}
}
@Composable
private fun SetAudioType(selectedOption: String, onDismissRequest: () -> Unit) {
var selected by remember {mutableStateOf(selectedOption)}
Dialog(onDismissRequest = { onDismissRequest() }) {
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FeedPreferences.AudioType.entries.forEach { option ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = option.tag == selected,
onCheckedChange = { isChecked ->
selected = option.tag
if (isChecked) Logd(TAG, "$option is checked")
val type = FeedPreferences.AudioType.fromTag(selected)
feed = upsertBlk(feed!!) { it.preferences?.audioType = type.code }
onDismissRequest()
}
)
Text(option.tag)
}
}
}
}
}
}
@Composable
private fun SetAudioQuality(selectedOption: String, onDismissRequest: () -> Unit) {
var selected by remember {mutableStateOf(selectedOption)}
@ -644,26 +693,10 @@ class FeedSettingsFragment : Fragment() {
onCheckedChange = { isChecked ->
selected = option.tag
if (isChecked) Logd(TAG, "$option is checked")
when (selected) {
FeedPreferences.AVQuality.LOW.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.LOW.code }
onDismissRequest()
}
FeedPreferences.AVQuality.MEDIUM.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.MEDIUM.code }
onDismissRequest()
}
FeedPreferences.AVQuality.HIGH.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.HIGH.code }
onDismissRequest()
}
else -> {
feed = upsertBlk(feed!!) { it.preferences?.audioQuality = FeedPreferences.AVQuality.GLOBAL.code }
onDismissRequest()
}
}
}
)
val type = FeedPreferences.AVQuality.fromTag(selected)
feed = upsertBlk(feed!!) { it.preferences?.audioQuality = type.code }
onDismissRequest()
})
Text(option.tag)
}
}
@ -684,26 +717,10 @@ class FeedSettingsFragment : Fragment() {
onCheckedChange = { isChecked ->
selected = option.tag
if (isChecked) Logd(TAG, "$option is checked")
when (selected) {
FeedPreferences.AVQuality.LOW.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.LOW.code }
onDismissRequest()
}
FeedPreferences.AVQuality.MEDIUM.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.MEDIUM.code }
onDismissRequest()
}
FeedPreferences.AVQuality.HIGH.tag -> {
feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.HIGH.code }
onDismissRequest()
}
else -> {
feed = upsertBlk(feed!!) { it.preferences?.videoQuality = FeedPreferences.AVQuality.GLOBAL.code }
onDismissRequest()
}
}
}
)
val type = FeedPreferences.AVQuality.fromTag(selected)
feed = upsertBlk(feed!!) { it.preferences?.videoQuality = type.code }
onDismissRequest()
})
Text(option.tag)
}
}
@ -712,6 +729,62 @@ class FeedSettingsFragment : Fragment() {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TagSettingDialog(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
val suggestions = remember { getTags() }
Card(modifier = Modifier.wrapContentSize(align = Alignment.Center).padding(16.dp), shape = RoundedCornerShape(16.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
var text by remember { mutableStateOf("") }
var filteredSuggestions by remember { mutableStateOf(suggestions) }
var showSuggestions by remember { mutableStateOf(false) }
var tags = remember { mutableStateListOf<String>() }
Column {
FlowRow {
tags.forEach {
FilterChip(onClick = { }, label = { Text(text) }, selected = false,
trailingIcon = { Icon(imageVector = Icons.Filled.Close, contentDescription = "Close icon", modifier = Modifier.size(FilterChipDefaults.IconSize).clickable(
onClick = {
})) })
}
}
TextField(value = text, onValueChange = {
text = it
filteredSuggestions = suggestions.filter { item ->
item.contains(text, ignoreCase = true)
}
showSuggestions = text.isNotEmpty() && filteredSuggestions.isNotEmpty()
},
placeholder = { Text("Type something...") },
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
tags.add(text)
}
),
modifier = Modifier.fillMaxWidth()
)
if (showSuggestions) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(min = 0.dp, max = 200.dp)) {
items(filteredSuggestions.size) { index ->
Text(text = filteredSuggestions[index], modifier = Modifier.clickable(onClick = {
text = filteredSuggestions[index]
showSuggestions = false
}).padding(8.dp))
}
}
}
}
Button(onClick = {
onDismiss()
}) { Text("Confirm") }
}
}
}
}
@Composable
fun AuthenticationDialog(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {

View File

@ -538,6 +538,8 @@
<string name="pref_feed_skip">Auto skip</string>
<string name="pref_feed_skip_sum">Skip introductions and ending credits.</string>
<string name="associated">Associated</string>
<string name="pref_feed_audio_type">Audio type</string>
<string name="pref_feed_audio_type_sum">Either Speech, Music or Movie for improved processing.</string>
<string name="pref_feed_audio_quality">Audio quality</string>
<string name="pref_feed_audio_quality_sum">Global generally equals high quality except when prefLowQualityMedia is set for metered network. Quality setting here takes precedence over the setting of prefLowQualityMedia for metered network.</string>
<string name="pref_feed_video_quality">Video quality</string>

View File

@ -22,8 +22,7 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
/**
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the
* network.
* Activity that allows for showing the MediaRouter button whenever there's a cast device in the network.
*/
abstract class CastEnabledActivity : AppCompatActivity() {
private var canCast by mutableStateOf(false)

View File

@ -1,23 +1,22 @@
package ac.mdiq.podcini.playback.cast
import ac.mdiq.podcini.net.utils.NetworkUtils.isNetworkRestricted
import ac.mdiq.podcini.playback.base.InTheatre.curMedia
import ac.mdiq.podcini.playback.base.MediaPlayerBase
import ac.mdiq.podcini.playback.base.MediaPlayerCallback
import ac.mdiq.podcini.playback.base.PlayerStatus
import ac.mdiq.podcini.preferences.UserPreferences.isSkipSilence
import ac.mdiq.podcini.storage.model.EpisodeMedia
import ac.mdiq.podcini.storage.model.MediaType
import ac.mdiq.podcini.storage.model.Playable
import ac.mdiq.podcini.storage.model.RemoteMedia
import ac.mdiq.podcini.util.Logd
import ac.mdiq.podcini.preferences.UserPreferences.prefLowQualityMedia
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.util.EventFlow
import ac.mdiq.podcini.util.FlowEvent
import ac.mdiq.podcini.util.Logd
import android.annotation.SuppressLint
import android.app.UiModeManager
import android.bluetooth.BluetoothClass.Service.AUDIO
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import androidx.media3.common.MediaMetadata
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
@ -30,7 +29,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.Volatile
import kotlin.math.max
import kotlin.math.min
@ -211,7 +209,6 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
var nextPlayable: Playable? = playable
do { nextPlayable = callback.getNextInQueue(nextPlayable)
} while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, castContext.sessionManager.currentCastSession))
if (nextPlayable != null) playMediaObject(nextPlayable, streaming, startWhenPrepared, prepareImmediately, forceReset)
return
}
@ -222,18 +219,10 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
Logd(TAG, "Method call to playMediaObject was ignored: media file already playing.")
return
} else {
// set temporarily to pause in order to update list with current position
val isPlaying = remoteMediaClient?.isPlaying ?: false
val position = remoteMediaClient?.approximateStreamPosition?.toInt() ?: 0
if (isPlaying) callback.onPlaybackPause(curMedia, position)
if (status == PlayerStatus.PLAYING) {
val pos = curMedia?.getPosition() ?: -1
seekTo(pos)
callback.onPlaybackPause(curMedia, pos)
if (curMedia?.getIdentifier() != prevMedia?.getIdentifier()) {
prevMedia = curMedia
callback.onPostPlayback(prevMedia, false, false, true)
}
if (prevMedia != null && curMedia!!.getIdentifier() != prevMedia?.getIdentifier())
callback.onPostPlayback(prevMedia, false, skipped = false, playingNext = true)
prevMedia = curMedia
setPlayerStatus(PlayerStatus.INDETERMINATE, null)
}
}
@ -246,34 +235,27 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
val metadata = buildMetadata(curMedia!!)
try {
callback.ensureMediaInfoLoaded(curMedia!!)
callback.onMediaChanged(false)
// TODO: test
callback.onMediaChanged(true)
setPlaybackParams(getCurrentPlaybackSpeed(curMedia), isSkipSilence)
CoroutineScope(Dispatchers.IO).launch {
when {
streaming -> {
val streamurl = curMedia!!.getStreamUrl()
if (streamurl != null) {
val media = curMedia
if (media is EpisodeMedia) {
mediaItem = null
mediaSource = null
setDataSource(metadata, media)
} else setDataSource(metadata, streamurl, null, null)
}
}
else -> {
val localMediaurl = curMedia!!.getLocalMediaUrl()
if (!localMediaurl.isNullOrEmpty()) setDataSource(metadata, localMediaurl, null, null)
else throw IOException("Unable to read local file $localMediaurl")
when {
streaming -> {
val streamurl = curMedia!!.getStreamUrl()
if (streamurl != null) {
val media = curMedia
if (media is EpisodeMedia) {
mediaItem = null
mediaSource = null
setDataSource(metadata, media)
} else setDataSource(metadata, streamurl, null, null)
}
}
mediaInfo = toMediaInfo(playable)
withContext(Dispatchers.Main) {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
if (prepareImmediately) prepare()
}
else -> {}
}
mediaInfo = toMediaInfo(playable)
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
if (uiModeManager.currentModeType != Configuration.UI_MODE_TYPE_CAR) setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
if (prepareImmediately) prepare()
} catch (e: IOException) {
e.printStackTrace()
setPlayerStatus(PlayerStatus.ERROR, null)
@ -283,11 +265,30 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
setPlayerStatus(PlayerStatus.ERROR, null)
EventFlow.postStickyEvent(FlowEvent.PlayerErrorEvent(e.localizedMessage ?: ""))
} finally { }
}
// callback.ensureMediaInfoLoaded(curMedia!!)
// callback.onMediaChanged(true)
// setPlayerStatus(PlayerStatus.INITIALIZED, curMedia)
// if (prepareImmediately) prepare()
@Throws(IllegalArgumentException::class, IllegalStateException::class)
override fun setDataSource(metadata: MediaMetadata, media: EpisodeMedia) {
Logd(TAG, "setDataSource1 called")
if (media.episode?.feed?.type == Feed.FeedType.YOUTUBE.name) {
Logd(TAG, "setDataSource1 setting for YouTube source")
try {
val streamInfo = media.episode!!.streamInfo ?: return
val audioStreamsList = getFilteredAudioStreams(streamInfo.audioStreams)
Logd(TAG, "setDataSource1 audioStreamsList ${audioStreamsList.size}")
val audioIndex = if (isNetworkRestricted && prefLowQualityMedia && media.episode?.feed?.preferences?.audioQualitySetting == FeedPreferences.AVQuality.GLOBAL) 0 else {
when (media.episode?.feed?.preferences?.audioQualitySetting) {
FeedPreferences.AVQuality.LOW -> 0
FeedPreferences.AVQuality.MEDIUM -> audioStreamsList.size / 2
FeedPreferences.AVQuality.HIGH -> audioStreamsList.size - 1
else -> audioStreamsList.size - 1
}
}
val audioStream = audioStreamsList[audioIndex]
Logd(TAG, "setDataSource1 use audio quality: ${audioStream.bitrate} forceVideo: ${media.forceVideo}")
media.audioUrl = audioStream.content
} catch (throwable: Throwable) { Log.e(TAG, "setDataSource1 error: ${throwable.message}") }
}
}
override fun resume() {
@ -330,13 +331,15 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
}
override fun getDuration(): Int {
var retVal = remoteMediaClient?.streamDuration?.toInt() ?: 0
// if (curMedia != null && remoteMediaClient?.currentItem?.media?.entity != curMedia?.getIdentifier().toString()) return curMedia!!.getDuration()
var retVal = remoteMediaClient?.streamDuration?.toInt() ?: Playable.INVALID_TIME
if (retVal == Playable.INVALID_TIME && curMedia != null && curMedia!!.getDuration() > 0) retVal = curMedia!!.getDuration()
return retVal
}
override fun getPosition(): Int {
var retVal = remoteMediaClient?.approximateStreamPosition?.toInt() ?: 0
// Logd(TAG, "getPosition: $status ${remoteMediaClient?.approximateStreamPosition} ${curMedia?.getPosition()} ${remoteMediaClient?.currentItem?.media?.entity} ${curMedia?.getIdentifier().toString()} ${curMedia?.getEpisodeTitle()}")
var retVal = remoteMediaClient?.approximateStreamPosition?.toInt() ?: Playable.INVALID_TIME
if (retVal <= 0 && curMedia != null && curMedia!!.getPosition() >= 0) retVal = curMedia!!.getPosition()
return retVal
}
@ -347,8 +350,7 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
}
override fun getPlaybackSpeed(): Float {
val status = remoteMediaClient?.mediaStatus
return status?.playbackRate?.toFloat() ?: 1.0f
return remoteMediaClient?.mediaStatus?.playbackRate?.toFloat() ?: 1.0f
}
override fun setVolume(volumeLeft: Float, volumeRight: Float) {
@ -357,11 +359,12 @@ class CastMediaPlayer(context: Context, callback: MediaPlayerCallback) : MediaPl
}
override fun shutdown() {
remoteMediaClient?.stop()
remoteMediaClient?.unregisterCallback(remoteMediaClientCallback)
}
override fun setPlayable(playable: Playable?) {
if (playable !== curMedia) {
if (playable != null && playable !== curMedia) {
curMedia = playable
mediaInfo = toMediaInfo(playable)
}

View File

@ -1,5 +1,6 @@
package ac.mdiq.podcini.playback.cast
import ac.mdiq.podcini.playback.base.VideoMode
import ac.mdiq.podcini.storage.model.*
import ac.mdiq.podcini.util.Logd
import android.content.ContentResolver
@ -9,9 +10,6 @@ import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.framework.CastSession
/**
* Helper functions for Cast support.
*/
object CastUtils {
private val TAG: String = CastUtils::class.simpleName ?: "Anonymous"
@ -45,7 +43,11 @@ object CastUtils {
if (url.startsWith(ContentResolver.SCHEME_CONTENT)) return false /* Local feed */
return when (media.getMediaType()) {
MediaType.AUDIO -> castSession.castDevice!!.hasCapability(CastDevice.CAPABILITY_AUDIO_OUT)
MediaType.VIDEO -> castSession.castDevice!!.hasCapability(CastDevice.CAPABILITY_VIDEO_OUT)
MediaType.VIDEO -> {
if ((media as? EpisodeMedia)?.episode?.feed?.preferences?.videoModePolicy == VideoMode.AUDIO_ONLY)
castSession.castDevice!!.hasCapability(CastDevice.CAPABILITY_AUDIO_OUT)
else castSession.castDevice!!.hasCapability(CastDevice.CAPABILITY_VIDEO_OUT)
}
else -> false
}
}

View File

@ -91,12 +91,13 @@ object MediaInfoCreator {
metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE)
metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()!!)
Logd("MediaInfoCreator", "media.mimeType: ${media.mimeType} ${media.audioUrl}")
Logd("MediaInfoCreator", "media.mimeType: ${media.getIdentifier()} ${feedItem?.title}")
// TODO: these are hardcoded for audio only
// val builder = MediaInfo.Builder(media.getStreamUrl()!!)
// .setContentType(media.mimeType)
var url: String = if (media.getMediaType() == MediaType.AUDIO) media.getStreamUrl() ?: "" else media.audioUrl
val builder = MediaInfo.Builder(url)
.setEntity(media.getIdentifier().toString())
.setContentType("audio/*")
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata)

View File

@ -1,3 +1,9 @@
# 6.14.2
* in feed settings, added audio type setting (Speech, Music, Movie) for improved audio processing from media3
* improved the behavior of the cast player in the Play app
* casting youtube audio appears working fine
# 6.14.1
* changed the term "virtual queue" to "natural queue" in the literature to refer to the list of episodes in a given feed

View File

@ -0,0 +1,5 @@
Version 6.14.2
* in feed settings, added audio type setting (Speech, Music, Movie) for improved audio processing from media3
* improved the behavior of the cast player in the Play app
* casting youtube audio appears working fine

View File

@ -20,7 +20,6 @@ fyydlin = "v0.5.0"
googleMaterialTypeface = "4.0.0.3-kotlin"
googleMaterialTypefaceOutlined = "4.0.0.2-kotlin"
gradle = "8.6.1"
#gridlayout = "1.0.0"
groovyXml = "3.0.19"
iconicsCore = "5.5.0-b01"
iconicsViews = "5.5.0-b01"
@ -51,7 +50,6 @@ rxjavaVersion = "3.1.8"
searchpreference = "v2.5.0"
uiToolingPreview = "1.7.5"
uiTooling = "1.7.5"
#viewpager2 = "1.1.0"
vistaguide = "lv0.24.2.6"
wearable = "2.9.0"
webkit = "1.12.1"
@ -68,7 +66,6 @@ androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorl
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
#androidx-gridlayout = { module = "androidx.gridlayout:gridlayout", version.ref = "gridlayout" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-material3-android = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
@ -80,7 +77,6 @@ androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version
androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiTooling" }
androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiToolingPreview" }
#androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
androidx-window = { module = "androidx.window:window", version.ref = "window" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }