Compare commits

...

8 Commits

Author SHA1 Message Date
ueen 3a1001c258
Merge 195eb0897c into 84b6f442fc 2024-05-18 14:24:25 -06:00
hades 84b6f442fc
Fix android auto resume on reconnect issues (#7156)
Previously the MediaSession object created in PlaybackService in onCreate would
be completely empty. This seemed to confuse Android Auto, and prevented it from
restarting playback.

Filling the MediaSession object using the data from the player state at
onCreate resolves this problem.

This is documented in Android Auto docs[1], albeit indirectly and somewhat
confusingly.

Also move the setSessionToken call to the end of onCreate handler to ensure
that the media session has already been completely filled by the time the
session token is made available to the framework. There is no evidence that
this is required; however intuitively, this is likely the trigger for the
framework to start querying the media session.

The change was tested both with Desktop Head Unit and with a real vehicle.

[1] https://developer.android.com/training/cars/media/#initial-playback-state
2024-05-18 19:34:36 +02:00
ByteHamster dd8bf381c4
Merge pull request #7186 from AntennaPod/transcript
Podcast:Transcript support
2024-05-18 19:26:39 +02:00
Tony Tam e856a9f118 Display transcript text and follow along the audio (#7103) 2024-05-18 18:58:36 +02:00
Tony Tam 7c4f19c979 Transcript semantic parsing (#6852) 2024-05-18 18:58:01 +02:00
Tony Tam 27e9bf36b1 Download and store transcript text (#6797) 2024-05-18 18:58:01 +02:00
Tony Tam 8adbad9b66 Parse podcast:transcript url and store in SQLite (#6739) 2024-05-18 18:57:57 +02:00
ueen 195eb0897c resize 2024-01-25 15:39:56 +01:00
37 changed files with 1352 additions and 34 deletions

View File

@ -69,6 +69,7 @@ dependencies {
implementation project(':net:ssl')
implementation project(':net:sync:service')
implementation project(':parser:feed')
implementation project(':parser:transcript')
implementation project(':playback:base')
implementation project(':playback:cast')
implementation project(':storage:database')
@ -88,6 +89,7 @@ dependencies {
implementation project(':net:sync:service-interface')
implementation project(':playback:service')
implementation project(':ui:chapters')
implementation project(':ui:transcript')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"

View File

@ -61,6 +61,7 @@ public class FeedItemMenuHandler {
final boolean fileDownloaded = hasMedia && selectedItem.getMedia().fileExists();
final boolean isLocalFile = hasMedia && selectedItem.getFeed().isLocalFeed();
final boolean isFavorite = selectedItem.isTagged(FeedItem.TAG_FAVORITE);
final boolean hasTranscript = selectedItem.hasTranscript();
setItemVisibility(menu, R.id.skip_episode_item, isPlaying);
setItemVisibility(menu, R.id.remove_from_queue_item, isInQueue);
@ -85,6 +86,7 @@ public class FeedItemMenuHandler {
setItemVisibility(menu, R.id.add_to_favorites_item, !isFavorite);
setItemVisibility(menu, R.id.remove_from_favorites_item, isFavorite);
setItemVisibility(menu, R.id.remove_item, fileDownloaded || isLocalFile);
setItemVisibility(menu, R.id.transcript_item, hasTranscript);
if (selectedItem.getFeed().getState() != Feed.STATE_SUBSCRIBED) {
setItemVisibility(menu, R.id.mark_read_item, false);

View File

@ -0,0 +1,145 @@
package de.danoeh.antennapod.ui.screen.playback;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.elevation.SurfaceColors;
import de.danoeh.antennapod.databinding.TranscriptItemBinding;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.model.feed.TranscriptSegment;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.ui.common.Converter;
import de.danoeh.antennapod.ui.transcript.TranscriptViewholder;
import java.util.Set;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.jsoup.internal.StringUtil;
public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder> {
public String tag = "TranscriptAdapter";
private final SegmentClickListener segmentClickListener;
private final Context context;
private FeedMedia media;
private int prevHighlightPosition = -1;
private int highlightPosition = -1;
public TranscriptAdapter(Context context, SegmentClickListener segmentClickListener) {
this.context = context;
this.segmentClickListener = segmentClickListener;
}
@NonNull
@Override
public TranscriptViewholder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
return new TranscriptViewholder(TranscriptItemBinding.inflate(LayoutInflater.from(context), viewGroup, false));
}
public void setMedia(Playable media) {
if (!(media instanceof FeedMedia)) {
return;
}
this.media = (FeedMedia) media;
notifyDataSetChanged();
}
@Override
public void onBindViewHolder(@NonNull TranscriptViewholder holder, int position) {
if (media == null || media.getTranscript() == null) {
return;
}
TranscriptSegment seg = media.getTranscript().getSegmentAt(position);
holder.viewContent.setOnClickListener(v -> {
if (segmentClickListener != null) {
segmentClickListener.onTranscriptClicked(position, seg);
}
});
String timecode = Converter.getDurationStringLong((int) seg.getStartTime());
if (!StringUtil.isBlank(seg.getSpeaker())) {
if (position > 0 && media.getTranscript()
.getSegmentAt(position - 1).getSpeaker().equals(seg.getSpeaker())) {
holder.viewTimecode.setVisibility(View.GONE);
holder.viewContent.setText(seg.getWords());
} else {
holder.viewTimecode.setVisibility(View.VISIBLE);
holder.viewTimecode.setText(timecode + "" + seg.getSpeaker());
holder.viewContent.setText(seg.getWords());
}
} else {
Set<String> speakers = media.getTranscript().getSpeakers();
if (speakers.isEmpty() && (position % 5 == 0)) {
holder.viewTimecode.setVisibility(View.VISIBLE);
holder.viewTimecode.setText(timecode);
}
holder.viewContent.setText(seg.getWords());
}
if (position == highlightPosition) {
float density = context.getResources().getDisplayMetrics().density;
holder.viewContent.setBackgroundColor(SurfaceColors.getColorForElevation(context, 32 * density));
holder.viewContent.setAlpha(1.0f);
holder.viewTimecode.setAlpha(1.0f);
holder.viewContent.setAlpha(1.0f);
} else {
holder.viewContent.setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent));
holder.viewContent.setAlpha(0.5f);
holder.viewTimecode.setAlpha(0.5f);
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEventMainThread(PlaybackPositionEvent event) {
if (media == null || media.getTranscript() == null) {
return;
}
int index = media.getTranscript().findSegmentIndexBefore(event.getPosition());
if (index < 0 || index > media.getTranscript().getSegmentCount()) {
return;
}
if (prevHighlightPosition != highlightPosition) {
prevHighlightPosition = highlightPosition;
}
if (index != highlightPosition) {
highlightPosition = index;
notifyItemChanged(prevHighlightPosition);
notifyItemChanged(highlightPosition);
}
}
@Override
public int getItemCount() {
if (media == null) {
return 0;
}
if (media.getTranscript() == null) {
return 0;
}
return media.getTranscript().getSegmentCount();
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
EventBus.getDefault().register(this);
}
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
EventBus.getDefault().unregister(this);
}
public interface SegmentClickListener {
void onTranscriptClicked(int position, TranscriptSegment seg);
}
}

View File

@ -0,0 +1,220 @@
package de.danoeh.antennapod.ui.screen.playback;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.databinding.TranscriptDialogBinding;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.model.feed.Transcript;
import de.danoeh.antennapod.model.feed.TranscriptSegment;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.playback.service.PlaybackController;
import de.danoeh.antennapod.ui.transcript.TranscriptUtils;
import io.reactivex.Maybe;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
public class TranscriptDialogFragment extends DialogFragment {
public static final String TAG = "TranscriptFragment";
private TranscriptDialogBinding viewBinding;
private PlaybackController controller;
private Disposable disposable;
private Playable media;
private Transcript transcript;
private TranscriptAdapter adapter = null;
private boolean doInitialScroll = true;
private LinearLayoutManager layoutManager;
@Override
public void onResume() {
ViewGroup.LayoutParams params;
params = getDialog().getWindow().getAttributes();
params.width = WindowManager.LayoutParams.MATCH_PARENT;
getDialog().getWindow().setAttributes((WindowManager.LayoutParams) params);
super.onResume();
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
viewBinding = TranscriptDialogBinding.inflate(getLayoutInflater());
layoutManager = new LinearLayoutManager(getContext());
viewBinding.transcriptList.setLayoutManager(layoutManager);
adapter = new TranscriptAdapter(getContext(), this::transcriptClicked);
viewBinding.transcriptList.setAdapter(adapter);
viewBinding.transcriptList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
viewBinding.followAudioCheckbox.setChecked(false);
}
}
});
AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext())
.setView(viewBinding.getRoot())
.setPositiveButton(getString(R.string.close_label), null)
.setNegativeButton(getString(R.string.refresh_label), null)
.setTitle(R.string.transcript)
.create();
viewBinding.followAudioCheckbox.setChecked(true);
dialog.setOnShowListener(dialog1 -> {
dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener(v -> {
viewBinding.progLoading.setVisibility(View.VISIBLE);
v.setClickable(false);
v.setEnabled(false);
loadMediaInfo(true);
});
});
viewBinding.progLoading.setVisibility(View.VISIBLE);
doInitialScroll = true;
return dialog;
}
private void transcriptClicked(int pos, TranscriptSegment segment) {
long startTime = segment.getStartTime();
long endTime = segment.getEndTime();
scrollToPosition(pos);
if (!(controller.getPosition() >= startTime && controller.getPosition() <= endTime)) {
controller.seekTo((int) startTime);
} else {
controller.playPause();
}
adapter.notifyItemChanged(pos);
viewBinding.followAudioCheckbox.setChecked(true);
}
@Override
public void onStart() {
super.onStart();
controller = new PlaybackController(getActivity()) {
@Override
public void loadMediaInfo() {
TranscriptDialogFragment.this.loadMediaInfo(false);
}
};
controller.init();
EventBus.getDefault().register(this);
loadMediaInfo(false);
}
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEventMainThread(PlaybackPositionEvent event) {
if (transcript == null) {
return;
}
int pos = transcript.findSegmentIndexBefore(event.getPosition());
scrollToPosition(pos);
}
private void loadMediaInfo(boolean forceRefresh) {
if (disposable != null) {
disposable.dispose();
}
disposable = Maybe.create(emitter -> {
Playable media = controller.getMedia();
if (media instanceof FeedMedia) {
this.media = media;
transcript = TranscriptUtils.loadTranscript((FeedMedia) this.media, forceRefresh);
doInitialScroll = true;
((FeedMedia) this.media).setTranscript(transcript);
emitter.onSuccess(this.media);
} else {
emitter.onComplete();
}
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(media -> onMediaChanged((Playable) media),
error -> Log.e(TAG, Log.getStackTraceString(error)));
}
private void onMediaChanged(Playable media) {
if (!(media instanceof FeedMedia)) {
return;
}
this.media = media;
if (!((FeedMedia) media).hasTranscript()) {
dismiss();
Toast.makeText(getContext(), R.string.no_transcript_label, Toast.LENGTH_LONG).show();
return;
}
viewBinding.progLoading.setVisibility(View.GONE);
adapter.setMedia(media);
((AlertDialog) getDialog()).getButton(DialogInterface.BUTTON_NEGATIVE).setVisibility(View.INVISIBLE);
if (!TextUtils.isEmpty(((FeedMedia) media).getItem().getTranscriptUrl())) {
((AlertDialog) getDialog()).getButton(DialogInterface.BUTTON_NEGATIVE).setVisibility(View.VISIBLE);
((AlertDialog) getDialog()).getButton(DialogInterface.BUTTON_NEGATIVE).setEnabled(true);
((AlertDialog) getDialog()).getButton(DialogInterface.BUTTON_NEGATIVE).setClickable(true);
}
}
public void scrollToPosition(int pos) {
if (pos <= 0) {
return;
}
if (!viewBinding.followAudioCheckbox.isChecked() && !doInitialScroll) {
return;
}
doInitialScroll = false;
boolean quickScroll = Math.abs(layoutManager.findFirstVisibleItemPosition() - pos) > 5;
if (quickScroll) {
viewBinding.transcriptList.scrollToPosition(pos - 1);
// Additionally, smooth scroll, so that currently active segment is on top of screen
}
LinearSmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) {
@Override
protected int getVerticalSnapPreference() {
return LinearSmoothScroller.SNAP_TO_START;
}
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return (quickScroll ? 200 : 1000) / (float) displayMetrics.densityDpi;
}
};
smoothScroller.setTargetPosition(pos - 1);
layoutManager.startSmoothScroll(smoothScroller);
}
@Override
public void onStop() {
super.onStop();
if (disposable != null) {
disposable.dispose();
}
controller.release();
controller = null;
EventBus.getDefault().unregister(this);
}
}

View File

@ -35,6 +35,7 @@ import de.danoeh.antennapod.ui.episodes.TimeSpeedConverter;
import de.danoeh.antennapod.ui.screen.playback.MediaPlayerErrorDialog;
import de.danoeh.antennapod.ui.screen.playback.PlayButton;
import de.danoeh.antennapod.ui.screen.playback.SleepTimerDialog;
import de.danoeh.antennapod.ui.screen.playback.TranscriptDialogFragment;
import de.danoeh.antennapod.ui.screen.playback.VariableSpeedDialog;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
@ -163,7 +164,6 @@ public class AudioPlayerFragment extends Fragment implements
}
private void setChapterDividers(Playable media) {
if (media == null) {
return;
}
@ -497,6 +497,10 @@ public class AudioPlayerFragment extends Fragment implements
if (itemId == R.id.disable_sleeptimer_item || itemId == R.id.set_sleeptimer_item) {
new SleepTimerDialog().show(getChildFragmentManager(), "SleepTimerDialog");
return true;
} else if (itemId == R.id.transcript_item) {
new TranscriptDialogFragment().show(
getActivity().getSupportFragmentManager(), TranscriptDialogFragment.TAG);
return true;
} else if (itemId == R.id.open_feed_item) {
if (feedItem != null) {
openFeed(feedItem.getFeed());

View File

@ -0,0 +1,23 @@
package de.danoeh.antennapod.ui.transcript;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import de.danoeh.antennapod.databinding.TranscriptItemBinding;
public class TranscriptViewholder extends RecyclerView.ViewHolder {
public final TextView viewTimecode;
public final TextView viewContent;
public TranscriptViewholder(TranscriptItemBinding binding) {
super(binding.getRoot());
viewTimecode = binding.speaker;
viewContent = binding.content;
}
@Override
public String toString() {
return super.toString() + " '" + viewContent.getText() + "'";
}
}

View File

@ -36,8 +36,8 @@
android:orientation="vertical">
<FrameLayout
android:layout_width="128dp"
android:layout_height="128dp"
android:layout_width="112dp"
android:layout_height="112dp"
android:background="@color/image_readability_tint">
<de.danoeh.antennapod.ui.common.SquareImageView
@ -100,7 +100,7 @@
<TextView
android:id="@+id/titleLabel"
android:layout_width="128dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progLoading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateOnly="true"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/transcript_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scrollIndicators="right"
android:scrollbarStyle="outsideInset"
android:layout_marginTop="16dp"
android:scrollbars="vertical"
android:layout_weight="1"
tools:listitem="@layout/transcript_item" />
<CheckBox
android:id="@+id/followAudioCheckbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:text="@string/transcript_follow" />
</LinearLayout>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/speaker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:paddingHorizontal="24dp"
android:clickable="false"
android:defaultFocusHighlightEnabled="false"
android:longClickable="false"
android:textStyle="bold"
android:textColor="?android:attr/textColorPrimary" />
<TextView
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minLines="1"
android:maxLines="100"
android:nestedScrollingEnabled="false"
android:paddingHorizontal="24dp"
android:selectAllOnFocus="false"
android:singleLine="false"
android:textColor="?android:attr/textColorPrimary"
android:textIsSelectable="false" />
</LinearLayout>

View File

@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/transcript_item"
android:icon="@drawable/transcript"
android:title="@string/show_transcript"
custom:showAsAction="ifRoom">
</item>
<item
android:id="@+id/add_to_favorites_item"
android:icon="@drawable/ic_star_border"

View File

@ -43,6 +43,10 @@ public class FeedItem implements Serializable {
private transient Feed feed;
private long feedId;
private String podcastIndexChapterUrl;
private String podcastIndexTranscriptUrl;
private String podcastIndexTranscriptType;
private String podcastIndexTranscriptText;
private Transcript transcript;
private int state;
public static final int NEW = -1;
@ -83,7 +87,8 @@ public class FeedItem implements Serializable {
* */
public FeedItem(long id, String title, String link, Date pubDate, String paymentLink, long feedId,
boolean hasChapters, String imageUrl, int state,
String itemIdentifier, boolean autoDownloadEnabled, String podcastIndexChapterUrl) {
String itemIdentifier, boolean autoDownloadEnabled, String podcastIndexChapterUrl,
String transcriptType, String transcriptUrl) {
this.id = id;
this.title = title;
this.link = link;
@ -96,6 +101,10 @@ public class FeedItem implements Serializable {
this.itemIdentifier = itemIdentifier;
this.autoDownloadEnabled = autoDownloadEnabled;
this.podcastIndexChapterUrl = podcastIndexChapterUrl;
if (transcriptUrl != null) {
this.podcastIndexTranscriptUrl = transcriptUrl;
this.podcastIndexTranscriptType = transcriptType;
}
}
/**
@ -162,6 +171,9 @@ public class FeedItem implements Serializable {
if (other.podcastIndexChapterUrl != null) {
podcastIndexChapterUrl = other.podcastIndexChapterUrl;
}
if (other.getTranscriptUrl() != null) {
podcastIndexTranscriptUrl = other.podcastIndexTranscriptUrl;
}
}
public long getId() {
@ -413,6 +425,64 @@ public class FeedItem implements Serializable {
podcastIndexChapterUrl = url;
}
public void setTranscriptUrl(String type, String url) {
updateTranscriptPreferredFormat(type, url);
}
public String getTranscriptUrl() {
return podcastIndexTranscriptUrl;
}
public String getTranscriptType() {
return podcastIndexTranscriptType;
}
public void updateTranscriptPreferredFormat(String type, String url) {
if (StringUtils.isEmpty(type) || StringUtils.isEmpty(url)) {
return;
}
String canonicalSrr = "application/srr";
String jsonType = "application/json";
switch (type) {
case "application/json":
podcastIndexTranscriptUrl = url;
podcastIndexTranscriptType = type;
break;
case "application/srr":
case "application/srt":
case "application/x-subrip":
if (podcastIndexTranscriptUrl == null || !podcastIndexTranscriptType.equals(jsonType)) {
podcastIndexTranscriptUrl = url;
podcastIndexTranscriptType = canonicalSrr;
}
break;
default:
break;
}
}
public Transcript getTranscript() {
return transcript;
}
public void setTranscript(Transcript t) {
transcript = t;
}
public String getPodcastIndexTranscriptText() {
return podcastIndexTranscriptText;
}
public String setPodcastIndexTranscriptText(String str) {
return podcastIndexTranscriptText = str;
}
public boolean hasTranscript() {
return (podcastIndexTranscriptUrl != null);
}
@NonNull
@Override
public String toString() {

View File

@ -5,6 +5,7 @@ import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.Nullable;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaDescriptionCompat;
import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
@ -513,4 +514,32 @@ public class FeedMedia implements Playable {
}
return super.equals(o);
}
public String getTranscriptFileUrl() {
if (getLocalFileUrl() == null) {
return null;
}
return getLocalFileUrl() + ".transcript";
}
public void setTranscript(Transcript t) {
if (item == null) {
return;
}
item.setTranscript(t);
}
public Transcript getTranscript() {
if (item == null) {
return null;
}
return item.getTranscript();
}
public Boolean hasTranscript() {
if (item == null) {
return false;
}
return item.hasTranscript();
}
}

View File

@ -0,0 +1,50 @@
package de.danoeh.antennapod.model.feed;
import java.util.ArrayList;
import java.util.Set;
public class Transcript {
private Set<String> speakers;
private final ArrayList<TranscriptSegment> segments = new ArrayList<>();
public void addSegment(TranscriptSegment segment) {
if ((!segments.isEmpty() && segments.get(segments.size() - 1).getStartTime() >= segment.getStartTime())) {
throw new IllegalArgumentException("Segments must be added in sorted order");
}
segments.add(segment);
}
public int findSegmentIndexBefore(long time) {
int a = 0;
int b = segments.size() - 1;
while (a < b) {
int pivot = (a + b + 1) / 2;
if (segments.get(pivot).getStartTime() > time) {
b = pivot - 1;
} else {
a = pivot;
}
}
return a;
}
public TranscriptSegment getSegmentAt(int index) {
return segments.get(index);
}
public TranscriptSegment getSegmentAtTime(long time) {
return getSegmentAt(findSegmentIndexBefore(time));
}
public Set<String> getSpeakers() {
return speakers;
}
public void setSpeakers(Set<String> speakers) {
this.speakers = speakers;
}
public int getSegmentCount() {
return segments.size();
}
}

View File

@ -0,0 +1,31 @@
package de.danoeh.antennapod.model.feed;
public class TranscriptSegment {
private final long startTime;
private final long endTime;
private final String words;
private final String speaker;
public TranscriptSegment(long start, long end, String w, String s) {
startTime = start;
endTime = end;
words = w;
speaker = s;
}
public long getStartTime() {
return startTime;
}
public long getEndTime() {
return endTime;
}
public String getWords() {
return words;
}
public String getSpeaker() {
return speaker;
}
}

View File

@ -22,6 +22,7 @@ dependencies {
implementation project(':storage:preferences')
implementation project(':ui:app-start-intent')
implementation project(':ui:chapters')
implementation project(':ui:transcript')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.core:core:$coreVersion"

View File

@ -10,6 +10,7 @@ import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
import de.danoeh.antennapod.model.feed.Feed;
import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
import de.danoeh.antennapod.ui.chapters.ChapterUtils;
import org.apache.commons.lang3.StringUtils;
import org.greenrobot.eventbus.EventBus;
import java.io.File;
@ -25,6 +26,7 @@ import de.danoeh.antennapod.model.download.DownloadError;
import de.danoeh.antennapod.model.feed.FeedItem;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction;
import de.danoeh.antennapod.ui.transcript.TranscriptUtils;
/**
* Handles a completed media download.
@ -64,6 +66,14 @@ public class MediaDownloadedHandler implements Runnable {
if (media.getItem() != null && media.getItem().getPodcastIndexChapterUrl() != null) {
ChapterUtils.loadChaptersFromUrl(media.getItem().getPodcastIndexChapterUrl(), false);
}
FeedItem item = media.getItem();
if (item != null && item.getTranscriptUrl() != null) {
String transcript = TranscriptUtils.loadTranscriptFromUrl(item.getTranscriptUrl(), true);
if (!StringUtils.isEmpty(transcript)) {
item.setPodcastIndexTranscriptText(transcript);
TranscriptUtils.storeTranscript(media, transcript);
}
}
} catch (InterruptedIOException ignore) {
// Ignore
}

View File

@ -14,6 +14,8 @@ public class PodcastIndex extends Namespace {
private static final String URL = "url";
private static final String FUNDING = "funding";
private static final String CHAPTERS = "chapters";
private static final String TRANSCRIPT = "transcript";
private static final String TYPE = "type";
@Override
public SyndElement handleElementStart(String localName, HandlerState state,
@ -28,6 +30,12 @@ public class PodcastIndex extends Namespace {
if (!TextUtils.isEmpty(href)) {
state.getCurrentItem().setPodcastIndexChapterUrl(href);
}
} else if (TRANSCRIPT.equals(localName)) {
String href = attributes.getValue(URL);
String type = attributes.getValue(TYPE);
if (!TextUtils.isEmpty(href) && !TextUtils.isEmpty(type)) {
state.getCurrentItem().setTranscriptUrl(type, href);
}
}
return new SyndElement(localName, this);
}

View File

@ -97,6 +97,14 @@ public class RssParserTest {
assertEquals("https://example.com/funding3", feed.getPaymentLinks().get(2).url);
}
@Test
public void testPodcastIndexTranscript() throws Exception {
File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testPodcastIndexTranscript.xml");
Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
assertEquals("https://podnews.net/audio/podnews231011.mp3.json", feed.getItems().get(0).getTranscriptUrl());
assertEquals("application/json", feed.getItems().get(0).getTranscriptType());
}
@Test
public void testUnsupportedElements() throws Exception {
File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testUnsupportedElements.xml");

View File

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='UTF-8' ?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>title</title>
<item>
<title>Podcasts in YouTube make it to the UK</title>
<link>https://podnews.net/update/youtube-podcasts-uk</link>
<pubDate>Tue, 10 Oct 2023 08:46:31 +0000</pubDate>
<podcast:transcript url="https://podnews.net/audio/podnews231011.mp3.json" type="application/json" />
<podcast:transcript url="https://podnews.net/audio/podnews231011.mp3.srt" type="application/srt" />
</item>
</channel>
</rss>

View File

@ -0,0 +1,3 @@
# :parser:transcript
This module provides parsing for transcripts

View File

@ -0,0 +1,23 @@
plugins {
id("com.android.library")
}
apply from: "../../common.gradle"
android {
namespace "de.danoeh.antennapod.parser.transcript"
}
dependencies {
implementation project(':model')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.core:core:$coreVersion"
implementation "org.apache.commons:commons-lang3:$commonslangVersion"
implementation "commons-io:commons-io:$commonsioVersion"
implementation "org.jsoup:jsoup:$jsoupVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"
}

View File

@ -0,0 +1,110 @@
package de.danoeh.antennapod.parser.transcript;
import org.apache.commons.lang3.StringUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.jsoup.internal.StringUtil;
import java.util.HashSet;
import java.util.Set;
import de.danoeh.antennapod.model.feed.Transcript;
import de.danoeh.antennapod.model.feed.TranscriptSegment;
public class JsonTranscriptParser {
public static Transcript parse(String jsonStr) {
try {
Transcript transcript = new Transcript();
long startTime = -1L;
long endTime = -1L;
long segmentStartTime = -1L;
long segmentEndTime = -1L;
long duration = 0L;
String speaker = "";
String prevSpeaker = "";
String segmentBody = "";
JSONArray objSegments;
Set<String> speakers = new HashSet<>();
try {
JSONObject obj = new JSONObject(jsonStr);
objSegments = obj.getJSONArray("segments");
} catch (JSONException e) {
e.printStackTrace();
return null;
}
for (int i = 0; i < objSegments.length(); i++) {
JSONObject jsonObject = objSegments.getJSONObject(i);
segmentEndTime = endTime;
startTime = Double.valueOf(jsonObject.optDouble("startTime", -1) * 1000L).longValue();
endTime = Double.valueOf(jsonObject.optDouble("endTime", -1) * 1000L).longValue();
if (startTime < 0 || endTime < 0) {
continue;
}
if (segmentStartTime == -1L) {
segmentStartTime = startTime;
}
duration += endTime - startTime;
prevSpeaker = speaker;
speaker = jsonObject.optString("speaker");
speakers.add(speaker);
if (StringUtils.isEmpty(speaker) && StringUtils.isNotEmpty(prevSpeaker)) {
speaker = prevSpeaker;
}
String body = jsonObject.optString("body");
if (!prevSpeaker.equals(speaker)) {
if (StringUtils.isNotEmpty(segmentBody)) {
segmentBody = StringUtils.trim(segmentBody);
transcript.addSegment(new TranscriptSegment(segmentStartTime,
segmentEndTime,
segmentBody,
prevSpeaker));
segmentStartTime = startTime;
segmentBody = body.toString();
duration = 0L;
continue;
}
}
segmentBody += " " + body;
if (duration >= TranscriptParser.MIN_SPAN) {
// Look ahead and make sure the next segment does not start with an alphanumeric character
if ((i + 1) < objSegments.length()) {
String nextSegmentFirstChar = objSegments.getJSONObject(i + 1)
.optString("body")
.substring(0, 1);
if (!StringUtils.isAlphanumeric(nextSegmentFirstChar)
&& (duration < TranscriptParser.MAX_SPAN)) {
continue;
}
}
segmentBody = StringUtils.trim(segmentBody);
transcript.addSegment(new TranscriptSegment(segmentStartTime, endTime, segmentBody, speaker));
duration = 0L;
segmentBody = "";
segmentStartTime = -1L;
}
}
if (!StringUtil.isBlank(segmentBody)) {
segmentBody = StringUtils.trim(segmentBody);
transcript.addSegment(new TranscriptSegment(segmentStartTime, endTime, segmentBody, speaker));
}
if (transcript.getSegmentCount() > 0) {
transcript.setSpeakers(speakers);
return transcript;
} else {
return null;
}
} catch (JSONException e) {
e.printStackTrace();
}
return null;
}
}

View File

@ -0,0 +1,133 @@
package de.danoeh.antennapod.parser.transcript;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.internal.StringUtil;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.danoeh.antennapod.model.feed.Transcript;
import de.danoeh.antennapod.model.feed.TranscriptSegment;
public class SrtTranscriptParser {
private static final Pattern TIMECODE_PATTERN = Pattern.compile("^([0-9]{2}):([0-9]{2}):([0-9]{2}),([0-9]{3})$");
public static Transcript parse(String str) {
if (StringUtils.isBlank(str)) {
return null;
}
str = str.replaceAll("\r\n", "\n");
Transcript transcript = new Transcript();
List<String> lines = Arrays.asList(str.split("\n"));
Iterator<String> iter = lines.iterator();
String speaker = "";
String prevSpeaker = "";
StringBuilder body;
String line;
String segmentBody = "";
long startTimecode = -1L;
long spanStartTimecode = -1L;
long spanEndTimecode = -1L;
long endTimecode = -1L;
long duration = 0L;
Set<String> speakers = new HashSet<>();
while (iter.hasNext()) {
body = new StringBuilder();
line = iter.next();
if (line.isEmpty()) {
continue;
}
spanEndTimecode = endTimecode;
if (line.contains("-->")) {
String[] timecodes = line.split("-->");
if (timecodes.length < 2) {
continue;
}
startTimecode = parseTimecode(timecodes[0].trim());
endTimecode = parseTimecode(timecodes[1].trim());
if (startTimecode == -1 || endTimecode == -1) {
continue;
}
if (spanStartTimecode == -1) {
spanStartTimecode = startTimecode;
}
duration += endTimecode - startTimecode;
do {
line = iter.next();
if (StringUtil.isBlank(line)) {
break;
}
body.append(line.strip());
body.append(" ");
} while (iter.hasNext());
}
if (body.indexOf(": ") != -1) {
String[] parts = body.toString().trim().split(":");
if (parts.length < 2) {
continue;
}
prevSpeaker = speaker;
speaker = parts[0];
speakers.add(speaker);
body = new StringBuilder(parts[1].strip());
if (StringUtils.isNotEmpty(prevSpeaker) && !StringUtils.equals(speaker, prevSpeaker)) {
if (StringUtils.isNotEmpty(segmentBody)) {
transcript.addSegment(new TranscriptSegment(spanStartTimecode,
spanEndTimecode, segmentBody, prevSpeaker));
duration = 0L;
spanStartTimecode = startTimecode;
segmentBody = body.toString();
continue;
}
}
} else {
if (StringUtils.isNotEmpty(prevSpeaker) && StringUtils.isEmpty(speaker)) {
speaker = prevSpeaker;
}
}
segmentBody += " " + body;
segmentBody = StringUtils.trim(segmentBody);
if (duration >= TranscriptParser.MIN_SPAN && endTimecode > spanStartTimecode) {
transcript.addSegment(new TranscriptSegment(spanStartTimecode, endTimecode, segmentBody, speaker));
duration = 0L;
spanStartTimecode = -1L;
segmentBody = "";
}
}
if (!StringUtil.isBlank(segmentBody) && endTimecode > spanStartTimecode) {
segmentBody = StringUtils.trim(segmentBody);
transcript.addSegment(new TranscriptSegment(spanStartTimecode, endTimecode, segmentBody, speaker));
}
if (transcript.getSegmentCount() > 0) {
transcript.setSpeakers(speakers);
return transcript;
} else {
return null;
}
}
static long parseTimecode(String timecode) {
Matcher matcher = TIMECODE_PATTERN.matcher(timecode);
if (!matcher.matches()) {
return -1;
}
long hours = Integer.parseInt(matcher.group(1));
long minutes = Integer.parseInt(matcher.group(2));
long seconds = Integer.parseInt(matcher.group(3));
long milliseconds = Integer.parseInt(matcher.group(4));
return (hours * 60 * 60 * 1000) + (minutes * 60 * 1000) + (seconds * 1000) + milliseconds;
}
}

View File

@ -0,0 +1,25 @@
package de.danoeh.antennapod.parser.transcript;
import org.apache.commons.lang3.StringUtils;
import de.danoeh.antennapod.model.feed.Transcript;
public class TranscriptParser {
static final long MIN_SPAN = 5000L; // Merge short segments together to form a span of 5 seconds
static final long MAX_SPAN = 8000L; // Don't go beyond 10 seconds when merging
public static Transcript parse(String str, String type) {
if (str == null || StringUtils.isBlank(str)) {
return null;
}
if ("application/json".equals(type)) {
return JsonTranscriptParser.parse(str);
}
if ("application/srt".equals(type) || "application/srr".equals(type) || "application/x-subrip".equals(type)) {
return SrtTranscriptParser.parse(str);
}
return null;
}
}

View File

@ -0,0 +1,84 @@
package de.danoeh.antennapod.parser.transcript;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import de.danoeh.antennapod.model.feed.Transcript;
@RunWith(RobolectricTestRunner.class)
public class JsonTranscriptParserTest {
private static String jsonStr = "{'version': '1.0.0', "
+ "'segments': [ "
+ "{ 'speaker' : 'John Doe', 'startTime': 0.8, 'endTime': 1.9, 'body': 'And' },"
+ "{ 'speaker' : 'Sally Green', 'startTime': 1.91, 'endTime': 2.8, 'body': 'this merges' },"
+ "{ 'startTime': 2.9, 'endTime': 3.4, 'body': ' the' },"
+ "{ 'startTime': 3.5, 'endTime': 3.6, 'body': ' person' }]}";
@Test
public void testParseJson() {
Transcript result = JsonTranscriptParser.parse(jsonStr);
// TODO: for gaps in the transcript (ads, music) should we return null?
assertEquals(result.getSegmentAtTime(0L).getStartTime(), 800L);
assertEquals(result.getSegmentAtTime(800L).getSpeaker(), "John Doe");
assertEquals(result.getSegmentAtTime(800L).getStartTime(), 800L);
assertEquals(result.getSegmentAtTime(800L).getEndTime(), 1900L);
assertEquals(result.getSegmentAtTime(1800L).getStartTime(), 800L);
// 2 segments get merged into at least 5 second
assertEquals(result.getSegmentAtTime(1800L).getWords(), "And");
}
@Test
public void testParse() {
String type = "application/json";
Transcript result = TranscriptParser.parse(jsonStr, type);
// There isn't a segment at 900L, so go backwards and get the segment at 800L
assertEquals(result.getSegmentAtTime(900L).getSpeaker(), "John Doe");
assertEquals(result.getSegmentAtTime(930L).getWords(), "And");
// blank string
String blankStr = "";
result = TranscriptParser.parse(blankStr, type);
assertEquals(result, null);
result = TranscriptParser.parse(null, type);
assertEquals(result, null);
// All blank lines
String allNewlinesStr = "\r\n\r\n\r\n\r\n";
result = TranscriptParser.parse(allNewlinesStr, type);
assertEquals(result, null);
// segments is missing
String jsonStrBad1 = "{'version': '1.0.0', "
+ "'segmentsX': [ "
+ "{ 'speaker' : 'John Doe', 'startTime': 0.8, 'endTime': 1.9, 'body': 'And' },"
+ "{ 'startTime': 2.9, 'endTime': 3.4, 'body': 'the' },"
+ "{ 'startTime': 3.5, 'endTime': 3.6, 'body': 'person' }]}";
result = TranscriptParser.parse(jsonStrBad1, type);
assertEquals(result, null);
// invalid time formatting
String jsonStrBad2 = "{'version': '1.0.0', "
+ "'segments': [ "
+ "{ 'speaker' : 'XJohn Doe', 'startTime': stringTime, 'endTime': stringTime, 'body': 'And' },"
+ "{ 'XstartTime': 2.9, 'XendTime': 3.4, 'body': 'the' },"
+ "{ 'startTime': '-2.9', 'endTime': '-3.4', 'body': 'the' },"
+ "{ 'startTime': 'bad_time', 'endTime': '-3.4', 'body': 'the' }]}";
result = TranscriptParser.parse(jsonStrBad2, type);
assertNull(result);
// Just plain text
String strBad3 = "John Doe: Promoting your podcast in a new\n\n"
+ "way. The latest from PogNews.";
result = TranscriptParser.parse(strBad3, type);
assertNull(result);
// passing the wrong type
type = "application/srt";
result = TranscriptParser.parse(jsonStr, type);
assertEquals(result, null);
}
}

View File

@ -0,0 +1,94 @@
package de.danoeh.antennapod.parser.transcript;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import de.danoeh.antennapod.model.feed.Transcript;
@RunWith(RobolectricTestRunner.class)
public class SrtTranscriptParserTest {
private static String srtStr = "1\n"
+ "00:00:00,000 --> 00:00:50,730\n"
+ "John Doe: Promoting your podcast in a new\n\n"
+ "2\n"
+ "00:00:90,740 --> 00:00:91,600\n"
+ "way. The latest from PogNews.\n\n"
+ "00:00:91,730 --> 00:00:93,600\n"
+ "We bring your favorite podcast.";
@Test
public void testParseSrt() {
Transcript result = SrtTranscriptParser.parse(srtStr);
assertEquals(result.getSegmentAtTime(0L).getWords(), "Promoting your podcast in a new");
assertEquals(result.getSegmentAtTime(0L).getSpeaker(), "John Doe");
assertEquals(result.getSegmentAtTime(0L).getStartTime(), 0L);
assertEquals(result.getSegmentAtTime(0L).getEndTime(), 50730L);
assertEquals(result.getSegmentAtTime(90740).getStartTime(), 90740);
assertEquals("way. The latest from PogNews. We bring your favorite podcast.",
result.getSegmentAtTime(90740).getWords());
}
@Test
public void testParse() {
String type = "application/srr";
Transcript result;
result = TranscriptParser.parse(srtStr, type);
// There isn't a segment at 800L, so go backwards and get the segment at 0L
assertEquals(result.getSegmentAtTime(800L).getWords(), "Promoting your podcast in a new");
result = TranscriptParser.parse(null, type);
assertEquals(result, null);
// blank string
String blankStr = "";
result = TranscriptParser.parse(blankStr, type);
assertNull(result);
// All empty lines
String allNewlinesStr = "\r\n\r\n\r\n\r\n";
result = TranscriptParser.parse(allNewlinesStr, type);
assertEquals(result, null);
// first segment has invalid time formatting, so the entire segment will be thrown out
String srtStrBad1 = "00:0000,000 --> 00:00:02,730\n"
+ "John Doe: Promoting your podcast in a new\n\n"
+ "2\n"
+ "00:00:02,730 --> 00:00:04,600\n"
+ "way. The latest from PogNews.";
result = TranscriptParser.parse(srtStrBad1, type);
assertEquals(result.getSegmentAtTime(2730L).getWords(), "way. The latest from PogNews.");
// first segment has invalid time in end time, 2nd segment has invalid time in both start time and end time
String srtStrBad2 = "00:00:00,000 --> 00:0002,730\n"
+ "Jane Doe: Promoting your podcast in a new\n\n"
+ "2\n"
+ "badstarttime --> badendtime\n"
+ "way. The latest from PogNews.\n"
+ "badstarttime -->\n"
+ "Jane Doe says something\n"
+ "00:00:00,000 --> 00:00:02,730\n"
+ "Jane Doe:";
result = TranscriptParser.parse(srtStrBad2, type);
assertNull(result);
// Just plain text
String strBad3 = "John Doe: Promoting your podcast in a new\n\n"
+ "way. The latest from PogNews.";
result = TranscriptParser.parse(strBad3, type);
assertNull(result);
// passing the wrong type
type = "application/json";
result = TranscriptParser.parse(srtStr, type);
assertEquals(result, null);
type = "unknown";
result = TranscriptParser.parse(srtStr, type);
assertEquals(result, null);
}
}

View File

@ -238,8 +238,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
};
androidAutoConnectionState.observeForever(androidAutoConnectionObserver);
ContextCompat.registerReceiver(this, autoStateUpdated,
new IntentFilter("com.google.android.gms.car.media.STATUS"), ContextCompat.RECEIVER_EXPORTED);
ContextCompat.registerReceiver(this, shutdownReceiver,
new IntentFilter(PlaybackServiceInterface.ACTION_SHUTDOWN_PLAYBACK_SERVICE),
ContextCompat.RECEIVER_NOT_EXPORTED);
@ -274,11 +272,11 @@ public class PlaybackService extends MediaBrowserServiceCompat {
PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0));
mediaSession = new MediaSessionCompat(getApplicationContext(), TAG, eventReceiver, buttonReceiverIntent);
setSessionToken(mediaSession.getSessionToken());
mediaSession.setCallback(sessionCallback);
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
recreateMediaPlayer();
mediaSession.setActive(true);
setSessionToken(mediaSession.getSessionToken());
}
void recreateMediaPlayer() {
@ -298,6 +296,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
mediaPlayer.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true);
}
isCasting = mediaPlayer.isCasting();
updateMediaSession(mediaPlayer.getPlayerStatus());
}
@Override
@ -324,7 +323,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
mediaSession.release();
mediaSession = null;
}
unregisterReceiver(autoStateUpdated);
unregisterReceiver(headsetDisconnected);
unregisterReceiver(shutdownReceiver);
unregisterReceiver(bluetoothStateUpdated);
@ -1491,28 +1489,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
}
private final BroadcastReceiver autoStateUpdated = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String status = intent.getStringExtra("media_connection_status");
boolean isConnectedToCar = "media_connected".equals(status);
Log.d(TAG, "Received Auto Connection update: " + status);
if (!isConnectedToCar) {
Log.d(TAG, "Car was unplugged during playback.");
} else {
PlayerStatus playerStatus = mediaPlayer.getPlayerStatus();
if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) {
mediaPlayer.resume();
} else if (playerStatus == PlayerStatus.PREPARING) {
mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared());
} else if (playerStatus == PlayerStatus.INITIALIZED) {
mediaPlayer.setStartWhenPrepared(true);
mediaPlayer.prepare();
}
}
}
};
/**
* Pauses playback when the headset is disconnected and the preference is
* set

View File

@ -30,6 +30,7 @@ include ':net:sync:service'
include ':parser:feed'
include ':parser:media'
include ':parser:transcript'
include ':playback:base'
include ':playback:cast'
@ -51,3 +52,4 @@ include ':ui:notifications'
include ':ui:preferences'
include ':ui:statistics'
include ':ui:widget'
include ':ui:transcript'

View File

@ -342,6 +342,10 @@ class DBUpgrader {
if (oldVersion < 3050000) {
db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS
+ " ADD COLUMN " + PodDBAdapter.KEY_STATE + " INTEGER DEFAULT " + Feed.STATE_SUBSCRIBED);
db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS
+ " ADD COLUMN " + PodDBAdapter.KEY_PODCASTINDEX_TRANSCRIPT_URL + " TEXT");
db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS
+ " ADD COLUMN " + PodDBAdapter.KEY_PODCASTINDEX_TRANSCRIPT_TYPE + " TEXT");
}
}

View File

@ -119,6 +119,14 @@ public class DBWriter {
media.setLocalFileUrl(null);
localDelete = true;
} else if (media.getLocalFileUrl() != null) {
// delete transcript file before the media file because the fileurl is needed
if (media.getTranscriptFileUrl() != null) {
File transcriptFile = new File(media.getTranscriptFileUrl());
if (transcriptFile.exists() && !transcriptFile.delete()) {
Log.d(TAG, "Deletion of transcript file failed.");
}
}
// delete downloaded media file
File mediaFile = new File(media.getLocalFileUrl());
if (mediaFile.exists() && !mediaFile.delete()) {

View File

@ -122,6 +122,8 @@ public class PodDBAdapter {
public static final String KEY_NEW_EPISODES_ACTION = "new_episodes_action";
public static final String KEY_PODCASTINDEX_CHAPTER_URL = "podcastindex_chapter_url";
public static final String KEY_STATE = "state";
public static final String KEY_PODCASTINDEX_TRANSCRIPT_URL = "podcastindex_transcript_url";
public static final String KEY_PODCASTINDEX_TRANSCRIPT_TYPE = "podcastindex_transcript_type";
// Table names
public static final String TABLE_NAME_FEEDS = "Feeds";
@ -184,7 +186,9 @@ public class PodDBAdapter {
+ KEY_HAS_CHAPTERS + " INTEGER," + KEY_ITEM_IDENTIFIER + " TEXT,"
+ KEY_IMAGE_URL + " TEXT,"
+ KEY_AUTO_DOWNLOAD_ENABLED + " INTEGER,"
+ KEY_PODCASTINDEX_CHAPTER_URL + " TEXT)";
+ KEY_PODCASTINDEX_CHAPTER_URL + " TEXT,"
+ KEY_PODCASTINDEX_TRANSCRIPT_TYPE + " TEXT,"
+ KEY_PODCASTINDEX_TRANSCRIPT_URL + " TEXT" + ")";
private static final String CREATE_TABLE_FEED_MEDIA = "CREATE TABLE "
+ TABLE_NAME_FEED_MEDIA + " (" + TABLE_PRIMARY_KEY + KEY_DURATION
@ -272,7 +276,9 @@ public class PodDBAdapter {
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ITEM_IDENTIFIER + ", "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_IMAGE_URL + ", "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_AUTO_DOWNLOAD_ENABLED + ", "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PODCASTINDEX_CHAPTER_URL;
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PODCASTINDEX_CHAPTER_URL + ", "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PODCASTINDEX_TRANSCRIPT_TYPE + ", "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_PODCASTINDEX_TRANSCRIPT_URL;
private static final String KEYS_FEED_MEDIA =
TABLE_NAME_FEED_MEDIA + "." + KEY_ID + " AS " + SELECT_KEY_MEDIA_ID + ", "
@ -675,6 +681,14 @@ public class PodDBAdapter {
values.put(KEY_IMAGE_URL, item.getImageUrl());
values.put(KEY_PODCASTINDEX_CHAPTER_URL, item.getPodcastIndexChapterUrl());
// We only store one transcript url, we prefer JSON if it exists
String type = item.getTranscriptType();
String url = item.getTranscriptUrl();
if (url != null) {
values.put(KEY_PODCASTINDEX_TRANSCRIPT_TYPE, type);
values.put(KEY_PODCASTINDEX_TRANSCRIPT_URL, url);
}
if (item.getId() == 0) {
item.setId(db.insert(TABLE_NAME_FEED_ITEMS, null, values));
} else {

View File

@ -26,6 +26,8 @@ public class FeedItemCursor extends CursorWrapper {
private final int indexImageUrl;
private final int indexPodcastIndexChapterUrl;
private final int indexMediaId;
private final int indexPodcastIndexTranscriptType;
private final int indexPodcastIndexTranscriptUrl;
public FeedItemCursor(Cursor cursor) {
super(new FeedMediaCursor(cursor));
@ -43,6 +45,8 @@ public class FeedItemCursor extends CursorWrapper {
indexImageUrl = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_IMAGE_URL);
indexPodcastIndexChapterUrl = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_PODCASTINDEX_CHAPTER_URL);
indexMediaId = cursor.getColumnIndexOrThrow(PodDBAdapter.SELECT_KEY_MEDIA_ID);
indexPodcastIndexTranscriptType = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_PODCASTINDEX_TRANSCRIPT_TYPE);
indexPodcastIndexTranscriptUrl = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_PODCASTINDEX_TRANSCRIPT_URL);
}
/**
@ -62,7 +66,9 @@ public class FeedItemCursor extends CursorWrapper {
getInt(indexRead),
getString(indexItemIdentifier),
getLong(indexAutoDownload) > 0,
getString(indexPodcastIndexChapterUrl));
getString(indexPodcastIndexChapterUrl),
getString(indexPodcastIndexTranscriptType),
getString(indexPodcastIndexTranscriptUrl));
if (!isNull(indexMediaId)) {
item.setMedia(feedMediaCursor.getFeedMedia());
}

View File

@ -13,9 +13,11 @@ dependencies {
implementation project(':net:common')
implementation project(':parser:media')
implementation project(':parser:feed')
implementation project(':parser:transcript')
implementation project(':storage:database')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "commons-io:commons-io:$commonsioVersion"
implementation "org.apache.commons:commons-lang3:$commonslangVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
}

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="?attr/action_icon_color"
android:pathData="M240,640L560,640L560,560L240,560L240,640ZM640,640L720,640L720,560L640,560L640,640ZM240,480L320,480L320,400L240,400L240,480ZM400,480L720,480L720,400L400,400L400,480ZM160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,240Q800,240 800,240Q800,240 800,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720L160,720Z"/>
</vector>

View File

@ -258,6 +258,10 @@
<item quantity="other">%d episodes removed from inbox.</item>
</plurals>
<string name="add_to_favorite_label">Add to favorites</string>
<string name="show_transcript">Show transcript</string>
<string name="transcript">Transcript</string>
<string name="transcript_follow">Follow audio</string>
<string name="no_transcript_label">No transcript</string>
<string name="remove_from_favorite_label">Remove from favorites</string>
<string name="visit_website_label">Visit website</string>
<string name="skip_episode_label">Skip episode</string>

View File

@ -0,0 +1,20 @@
plugins {
id("com.android.library")
}
apply from: "../../common.gradle"
apply from: "../../playFlavor.gradle"
android {
namespace "de.danoeh.antennapod.ui.transcript"
}
dependencies {
implementation project(':model')
implementation project(':net:common')
implementation project(':parser:media')
implementation project(':parser:transcript')
implementation "commons-io:commons-io:$commonsioVersion"
implementation "org.apache.commons:commons-lang3:$commonslangVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
}

View File

@ -0,0 +1,111 @@
package de.danoeh.antennapod.ui.transcript;
import android.util.Log;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.nio.charset.Charset;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.net.common.AntennapodHttpClient;
import de.danoeh.antennapod.model.feed.Transcript;
import de.danoeh.antennapod.parser.transcript.TranscriptParser;
import okhttp3.CacheControl;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
public class TranscriptUtils {
private static final String TAG = "Transcript";
public static String loadTranscriptFromUrl(String url, boolean forceRefresh) throws InterruptedIOException {
if (forceRefresh) {
return loadTranscriptFromUrl(url, CacheControl.FORCE_NETWORK);
}
String str = loadTranscriptFromUrl(url, CacheControl.FORCE_CACHE);
if (str == null || str.length() <= 1) {
// Some publishers use one dummy transcript before actual transcript are available
return loadTranscriptFromUrl(url, CacheControl.FORCE_NETWORK);
}
return str;
}
private static String loadTranscriptFromUrl(String url, CacheControl cacheControl) throws InterruptedIOException {
StringBuilder str = new StringBuilder();
Response response = null;
try {
Log.d(TAG, "Downloading transcript URL " + url);
Request request = new Request.Builder().url(url).cacheControl(cacheControl).build();
response = AntennapodHttpClient.getHttpClient().newCall(request).execute();
if (response.isSuccessful() && response.body() != null) {
Log.d(TAG, "Done Downloading transcript URL " + url);
str.append(response.body().string());
} else {
Log.d(TAG, "Error Downloading transcript URL " + url + ": " + response.message());
}
} catch (InterruptedIOException e) {
Log.d(TAG, "InterruptedIOException while downloading transcript URL " + url);
throw e;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
if (response != null) {
response.close();
}
}
return str.toString();
}
public static Transcript loadTranscript(FeedMedia media, Boolean forceRefresh) throws InterruptedIOException {
String transcriptType = media.getItem().getTranscriptType();
if (!forceRefresh && media.getItem().getTranscript() != null) {
return media.getTranscript();
}
if (!forceRefresh && media.getTranscriptFileUrl() != null) {
File transcriptFile = new File(media.getTranscriptFileUrl());
try {
if (transcriptFile.exists()) {
String t = FileUtils.readFileToString(transcriptFile, (String) null);
if (StringUtils.isNotEmpty(t)) {
media.setTranscript(TranscriptParser.parse(t, transcriptType));
return media.getTranscript();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
String transcriptUrl = media.getItem().getTranscriptUrl();
String t = TranscriptUtils.loadTranscriptFromUrl(transcriptUrl, forceRefresh);
if (StringUtils.isNotEmpty(t)) {
return TranscriptParser.parse(t, transcriptType);
}
return null;
}
public static void storeTranscript(FeedMedia media, String transcript) {
File transcriptFile = new File(media.getTranscriptFileUrl());
FileOutputStream ostream = null;
try {
if (transcriptFile.exists() && !transcriptFile.delete()) {
Log.e(TAG, "Failed to delete existing transcript file " + transcriptFile.getAbsolutePath());
}
if (transcriptFile.createNewFile()) {
ostream = new FileOutputStream(transcriptFile);
ostream.write(transcript.getBytes(Charset.forName("UTF-8")));
ostream.close();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
IOUtils.closeQuietly(ostream);
}
}
}