Feature copy transcript (#7914)

This commit is contained in:
Mino 2025-08-12 07:59:28 +01:00 committed by GitHub
parent ed3efd0459
commit 4b101583c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 216 additions and 38 deletions

View File

@ -1,6 +1,7 @@
package de.danoeh.antennapod.ui.screen.playback;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -11,11 +12,14 @@ 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.Transcript;
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.HashSet;
import java.util.Set;
import org.apache.commons.lang3.ObjectUtils;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@ -29,6 +33,8 @@ public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder
private FeedMedia media;
private int prevHighlightPosition = -1;
private int highlightPosition = -1;
private boolean inMultiselectMode = false;
private final HashSet<Integer> selectedPositions = new HashSet<>();
public TranscriptAdapter(Context context, SegmentClickListener segmentClickListener) {
this.context = context;
@ -49,6 +55,21 @@ public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder
notifyDataSetChanged();
}
public void setMultiselectMode(boolean multiselectMode) {
if (this.inMultiselectMode == multiselectMode) {
return;
}
this.inMultiselectMode = multiselectMode;
if (!multiselectMode) {
selectedPositions.clear();
}
notifyDataSetChanged();
}
public boolean isMultiselectMode() {
return inMultiselectMode;
}
@Override
public void onBindViewHolder(@NonNull TranscriptViewholder holder, int position) {
if (media == null || media.getTranscript() == null) {
@ -62,6 +83,13 @@ public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder
}
});
holder.viewContent.setOnLongClickListener(v -> {
if (segmentClickListener != null) {
segmentClickListener.onTranscriptLongClicked(position, seg);
}
return true;
});
String timecode = Converter.getDurationStringLong((int) seg.getStartTime());
if (!StringUtil.isBlank(seg.getSpeaker())) {
if (position > 0 && media.getTranscript()
@ -84,7 +112,15 @@ public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder
holder.viewContent.setText(seg.getWords());
}
if (position == highlightPosition) {
if (inMultiselectMode) {
highlightViewHolder(holder, selectedPositions.contains(position));
} else {
highlightViewHolder(holder, position == highlightPosition);
}
}
private void highlightViewHolder(TranscriptViewholder holder, boolean highlight) {
if (highlight) {
float density = context.getResources().getDisplayMetrics().density;
holder.viewContent.setBackgroundColor(SurfaceColors.getColorForElevation(context, 32 * density));
holder.viewContent.setAlpha(1.0f);
@ -140,8 +176,66 @@ public class TranscriptAdapter extends RecyclerView.Adapter<TranscriptViewholder
EventBus.getDefault().unregister(this);
}
public void toggleSelection(int pos) {
if (selectedPositions.contains(pos)) {
selectedPositions.remove(pos);
} else {
selectedPositions.add(pos);
}
notifyItemChanged(pos);
}
public void selectAll() {
if (media == null || media.getTranscript() == null) {
return;
}
selectedPositions.clear();
int count = getItemCount();
for (int i = 0; i < count; i++) {
selectedPositions.add(i);
}
notifyDataSetChanged();
}
public String getSelectedText() {
if (!inMultiselectMode) {
return null;
}
Transcript transcript = media.getTranscript();
StringBuilder ss = new StringBuilder();
String lastSpeaker = null;
if (selectedPositions.isEmpty()) {
return "";
}
java.util.List<Integer> sortedPositions = new java.util.ArrayList<>(selectedPositions);
java.util.Collections.sort(sortedPositions);
int prevIndex = -2;
for (int index : sortedPositions) {
if (prevIndex != -2 && index != prevIndex + 1) {
ss.append("\n[...]\n");
}
TranscriptSegment seg = transcript.getSegmentAt(index);
if (!StringUtil.isBlank(seg.getSpeaker())) {
if (ObjectUtils.notEqual(lastSpeaker, seg.getSpeaker())) {
ss.append("\n").append(seg.getSpeaker()).append(" : ");
lastSpeaker = seg.getSpeaker();
}
} else {
lastSpeaker = null;
}
if (!TextUtils.isEmpty(ss) && ss.charAt(ss.length() - 1) != ' ') {
ss.append(' ');
}
ss.append(seg.getWords());
prevIndex = index;
}
return ss.toString().strip();
}
public interface SegmentClickListener {
void onTranscriptClicked(int position, TranscriptSegment seg);
void onTranscriptLongClicked(int position, TranscriptSegment seg);
}
}

View File

@ -1,11 +1,13 @@
package de.danoeh.antennapod.ui.screen.playback;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
@ -13,6 +15,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
@ -20,6 +23,7 @@ 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.MessageEvent;
import de.danoeh.antennapod.event.playback.PlaybackPositionEvent;
import de.danoeh.antennapod.model.feed.FeedMedia;
import de.danoeh.antennapod.model.feed.Transcript;
@ -35,7 +39,8 @@ import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
public class TranscriptDialogFragment extends DialogFragment {
public class TranscriptDialogFragment extends DialogFragment
implements TranscriptAdapter.SegmentClickListener {
public static final String TAG = "TranscriptFragment";
private TranscriptDialogBinding viewBinding;
private PlaybackController controller;
@ -62,7 +67,7 @@ public class TranscriptDialogFragment extends DialogFragment {
layoutManager = new LinearLayoutManager(getContext());
viewBinding.transcriptList.setLayoutManager(layoutManager);
adapter = new TranscriptAdapter(getContext(), this::transcriptClicked);
adapter = new TranscriptAdapter(getContext(), this);
viewBinding.transcriptList.setAdapter(adapter);
viewBinding.transcriptList.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
@ -74,40 +79,66 @@ public class TranscriptDialogFragment extends DialogFragment {
}
});
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.toolbar.inflateMenu(R.menu.transcript);
viewBinding.toolbar.setOnMenuItemClickListener(this::onMenuItemClick);
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;
AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext())
.setView(viewBinding.getRoot())
.setNegativeButton(R.string.close_label, null)
.create();
setMultiselectMode(false);
return dialog;
}
private void transcriptClicked(int pos, TranscriptSegment segment) {
long startTime = segment.getStartTime();
long endTime = segment.getEndTime();
private void setMultiselectMode(boolean multiselectMode) {
adapter.setMultiselectMode(multiselectMode);
viewBinding.toolbar.getMenu().findItem(R.id.action_copy).setVisible(multiselectMode);
viewBinding.toolbar.getMenu().findItem(R.id.action_cancel_copy).setVisible(multiselectMode);
viewBinding.toolbar.getMenu().findItem(R.id.action_select_all).setVisible(multiselectMode);
viewBinding.toolbar.getMenu().findItem(R.id.action_refresh).setVisible(!multiselectMode);
viewBinding.followAudioCheckbox.setChecked(!multiselectMode);
}
scrollToPosition(pos);
if (!(controller.getPosition() >= startTime && controller.getPosition() <= endTime)) {
controller.seekTo((int) startTime);
} else {
controller.playPause();
private void copySelectedText() {
String selectedText = adapter.getSelectedText();
ClipboardManager clipboardManager = ContextCompat.getSystemService(requireContext(), ClipboardManager.class);
if (clipboardManager != null) {
clipboardManager.setPrimaryClip(ClipData.newPlainText(getString(R.string.transcript), selectedText));
}
if (Build.VERSION.SDK_INT <= 32) {
EventBus.getDefault().post(new MessageEvent(getString(R.string.copied_to_clipboard)));
}
}
@Override
public void onTranscriptClicked(int pos, TranscriptSegment segment) {
if (adapter.isMultiselectMode()) {
adapter.toggleSelection(pos);
} else {
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 onTranscriptLongClicked(int position, TranscriptSegment seg) {
if (!adapter.isMultiselectMode()) {
setMultiselectMode(true);
adapter.toggleSelection(position);
}
adapter.notifyItemChanged(pos);
viewBinding.followAudioCheckbox.setChecked(true);
}
@Override
@ -170,12 +201,6 @@ public class TranscriptDialogFragment extends DialogFragment {
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) {
@ -217,4 +242,23 @@ public class TranscriptDialogFragment extends DialogFragment {
EventBus.getDefault().unregister(this);
}
}
private boolean onMenuItemClick(MenuItem item) {
int id = item.getItemId();
if (id == R.id.action_refresh) {
viewBinding.progLoading.setVisibility(View.VISIBLE);
loadMediaInfo(true);
return true;
} else if (id == R.id.action_copy) {
copySelectedText();
setMultiselectMode(false);
return true;
} else if (id == R.id.action_cancel_copy) {
setMultiselectMode(false);
return true;
} else if (id == R.id.action_select_all) {
adapter.selectAll();
return true;
}
return false;
}
}

View File

@ -2,10 +2,17 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/transcript" />
<ProgressBar
android:id="@+id/progLoading"
android:layout_width="match_parent"
@ -20,7 +27,6 @@
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" />

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/refresh_label"
android:icon="@drawable/ic_refresh"
app:showAsAction="always" />
<item
android:id="@+id/action_copy"
android:title="@string/copy_to_clipboard"
android:icon="@drawable/ic_copy"
app:showAsAction="always" />
<item
android:id="@+id/action_select_all"
android:title="@string/select_all_label"
android:icon="@drawable/ic_select_all"
app:showAsAction="always" />
<item
android:id="@+id/action_cancel_copy"
android:title="@string/cancel_label"
android:icon="@drawable/ic_close"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/action_icon_color"
android:pathData="M16,1H4c-1.1,0 -2,0.9 -2,2v14h2V3h12V1zm3,4H8c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2zm0,16H8V7h11v14z"/>
</vector>