Compare commits

...

6 Commits

Author SHA1 Message Date
flofriday ba7ef9c90a
Merge 1f03e1124c into a61f548792 2024-05-08 07:47:57 +02:00
ByteHamster a61f548792
Fix settings toolbar having color (#7169) 2024-05-08 07:46:25 +02:00
flofriday 2827f41430
Improve layout for missing chapter images (#7164)
If only some chapters have images the other chapters don't display
anything but reserve space for the image.

Now those chapters display the image of the episode. If no chapters have
images no images will be displayed (just like before).
2024-05-06 22:14:26 +02:00
flofriday 6f572faa77
Fix inconsistent icons in the app toolbar. (#7163) 2024-05-06 22:04:24 +02:00
Simon Conrad ba14510b80
Add support for parsing Nero M4A chapters (#7159) 2024-05-05 10:05:26 +02:00
ByteHamster cb1a03cd8d
Show statistics above description on feed info page (#7161) 2024-05-03 21:42:14 +02:00
18 changed files with 375 additions and 151 deletions

View File

@ -19,6 +19,7 @@ import de.danoeh.antennapod.R;
import de.danoeh.antennapod.model.feed.Chapter;
import de.danoeh.antennapod.ui.common.Converter;
import de.danoeh.antennapod.model.feed.EmbeddedChapterImage;
import de.danoeh.antennapod.ui.common.ImagePlaceholder;
import de.danoeh.antennapod.ui.common.IntentUtils;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.ui.common.CircularProgressBar;
@ -99,15 +100,27 @@ public class ChaptersListAdapter extends RecyclerView.Adapter<ChaptersListAdapte
if (hasImages) {
holder.image.setVisibility(View.VISIBLE);
float radius = 4 * context.getResources().getDisplayMetrics().density;
RequestOptions options = new RequestOptions()
.placeholder(ImagePlaceholder.getDrawable(context, radius))
.dontAnimate()
.transform(new FitCenter(), new RoundedCorners((int) radius));
if (TextUtils.isEmpty(sc.getImageUrl())) {
Glide.with(context).clear(holder.image);
if (media.getImageLocation() == null) {
Glide.with(context).clear(holder.image);
holder.image.setVisibility(View.GONE);
} else {
Glide.with(context)
.load(media.getImageLocation())
.apply(options)
.into(holder.image);
}
} else {
Glide.with(context)
.load(EmbeddedChapterImage.getModelFor(media, position))
.apply(new RequestOptions()
.dontAnimate()
.transform(new FitCenter(), new RoundedCorners((int)
(4 * context.getResources().getDisplayMetrics().density))))
.apply(options)
.into(holder.image);
}
} else {

View File

@ -15,8 +15,6 @@ import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@ -27,13 +25,12 @@ import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.Fragment;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.appbar.CollapsingToolbarLayout;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.databinding.FeedinfoBinding;
import de.danoeh.antennapod.ui.TransitionEffect;
import de.danoeh.antennapod.storage.database.DBReader;
import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
@ -69,17 +66,7 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
private Feed feed;
private Disposable disposable;
private ImageView imgvCover;
private TextView txtvTitle;
private TextView txtvDescription;
private TextView txtvFundingUrl;
private TextView lblSupport;
private TextView txtvUrl;
private TextView txtvAuthorHeader;
private ImageView imgvBackground;
private View infoContainer;
private View header;
private MaterialToolbar toolbar;
private FeedinfoBinding viewBinding;
public static FeedInfoFragment newInstance(Feed feed) {
FeedInfoFragment fragment = new FeedInfoFragment();
@ -110,58 +97,45 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View root = inflater.inflate(R.layout.feedinfo, null);
toolbar = root.findViewById(R.id.toolbar);
toolbar.setTitle("");
toolbar.inflateMenu(R.menu.feedinfo);
toolbar.setNavigationOnClickListener(v -> getParentFragmentManager().popBackStack());
toolbar.setOnMenuItemClickListener(this);
viewBinding = FeedinfoBinding.inflate(inflater);
viewBinding.toolbar.setTitle("");
viewBinding.toolbar.inflateMenu(R.menu.feedinfo);
viewBinding.toolbar.setNavigationOnClickListener(v -> getParentFragmentManager().popBackStack());
viewBinding.toolbar.setOnMenuItemClickListener(this);
refreshToolbarState();
AppBarLayout appBar = root.findViewById(R.id.appBar);
CollapsingToolbarLayout collapsingToolbar = root.findViewById(R.id.collapsing_toolbar);
ToolbarIconTintManager iconTintManager = new ToolbarIconTintManager(getContext(), toolbar, collapsingToolbar) {
ToolbarIconTintManager iconTintManager = new ToolbarIconTintManager(getContext(),
viewBinding.toolbar, viewBinding.collapsingToolbar) {
@Override
protected void doTint(Context themedContext) {
toolbar.getMenu().findItem(R.id.visit_website_item)
viewBinding.toolbar.getMenu().findItem(R.id.visit_website_item)
.setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_web));
toolbar.getMenu().findItem(R.id.share_item)
viewBinding.toolbar.getMenu().findItem(R.id.share_item)
.setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_share));
}
};
iconTintManager.updateTint();
appBar.addOnOffsetChangedListener(iconTintManager);
viewBinding.appBar.addOnOffsetChangedListener(iconTintManager);
imgvCover = root.findViewById(R.id.imgvCover);
txtvTitle = root.findViewById(R.id.txtvTitle);
txtvAuthorHeader = root.findViewById(R.id.txtvAuthor);
imgvBackground = root.findViewById(R.id.imgvBackground);
header = root.findViewById(R.id.headerContainer);
infoContainer = root.findViewById(R.id.infoContainer);
root.findViewById(R.id.butShowInfo).setVisibility(View.INVISIBLE);
root.findViewById(R.id.butShowSettings).setVisibility(View.INVISIBLE);
root.findViewById(R.id.butFilter).setVisibility(View.INVISIBLE);
viewBinding.header.butShowInfo.setVisibility(View.INVISIBLE);
viewBinding.header.butShowSettings.setVisibility(View.INVISIBLE);
viewBinding.header.butFilter.setVisibility(View.INVISIBLE);
// https://github.com/bumptech/glide/issues/529
imgvBackground.setColorFilter(new LightingColorFilter(0xff828282, 0x000000));
viewBinding.imgvBackground.setColorFilter(new LightingColorFilter(0xff828282, 0x000000));
txtvDescription = root.findViewById(R.id.txtvDescription);
txtvUrl = root.findViewById(R.id.txtvUrl);
lblSupport = root.findViewById(R.id.lblSupport);
txtvFundingUrl = root.findViewById(R.id.txtvFundingUrl);
txtvUrl.setOnClickListener(copyUrlToClipboard);
viewBinding.urlLabel.setOnClickListener(copyUrlToClipboard);
long feedId = getArguments().getLong(EXTRA_FEED_ID);
getParentFragmentManager().beginTransaction().replace(R.id.statisticsFragmentContainer,
FeedStatisticsFragment.newInstance(feedId, false), "feed_statistics_fragment")
.commitAllowingStateLoss();
root.findViewById(R.id.btnvOpenStatistics).setOnClickListener(view -> {
viewBinding.statisticsButton.setOnClickListener(view -> {
StatisticsFragment fragment = new StatisticsFragment();
((MainActivity) getActivity()).loadChildFragment(fragment, TransitionEffect.SLIDE);
});
return root;
return viewBinding.getRoot();
}
@Override
@ -186,13 +160,14 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (header == null || infoContainer == null) {
if (viewBinding == null) {
return;
}
int horizontalSpacing = (int) getResources().getDimension(R.dimen.additional_horizontal_spacing);
header.setPadding(horizontalSpacing, header.getPaddingTop(), horizontalSpacing, header.getPaddingBottom());
infoContainer.setPadding(horizontalSpacing, infoContainer.getPaddingTop(),
horizontalSpacing, infoContainer.getPaddingBottom());
viewBinding.header.getRoot().setPadding(horizontalSpacing, viewBinding.header.getRoot().getPaddingTop(),
horizontalSpacing, viewBinding.header.getRoot().getPaddingBottom());
viewBinding.infoContainer.setPadding(horizontalSpacing, viewBinding.infoContainer.getPaddingTop(),
horizontalSpacing, viewBinding.infoContainer.getPaddingBottom());
}
private void showFeed() {
@ -206,7 +181,7 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
.error(R.color.light_gray)
.fitCenter()
.dontAnimate())
.into(imgvCover);
.into(viewBinding.header.imgvCover);
Glide.with(this)
.load(feed.getImageUrl())
.apply(new RequestOptions()
@ -214,27 +189,26 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
.error(R.color.image_readability_tint)
.transform(new FastBlurTransformation())
.dontAnimate())
.into(imgvBackground);
.into(viewBinding.imgvBackground);
txtvTitle.setText(feed.getTitle());
txtvTitle.setMaxLines(6);
viewBinding.header.txtvTitle.setText(feed.getTitle());
viewBinding.header.txtvTitle.setMaxLines(6);
String description = HtmlToPlainText.getPlainText(feed.getDescription());
txtvDescription.setText(description);
viewBinding.descriptionLabel.setText(description);
if (!TextUtils.isEmpty(feed.getAuthor())) {
txtvAuthorHeader.setText(feed.getAuthor());
viewBinding.header.txtvAuthor.setText(feed.getAuthor());
}
txtvUrl.setText(feed.getDownloadUrl());
txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0);
viewBinding.urlLabel.setText(feed.getDownloadUrl());
viewBinding.urlLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0);
if (feed.getPaymentLinks() == null || feed.getPaymentLinks().size() == 0) {
lblSupport.setVisibility(View.GONE);
txtvFundingUrl.setVisibility(View.GONE);
viewBinding.supportHeadingLabel.setVisibility(View.GONE);
viewBinding.supportUrl.setVisibility(View.GONE);
} else {
lblSupport.setVisibility(View.VISIBLE);
ArrayList<FeedFunding> fundingList = feed.getPaymentLinks();
// Filter for duplicates, but keep items in the order that they have in the feed.
@ -260,7 +234,7 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
str.append("\n");
}
str = new StringBuilder(StringUtils.trim(str.toString()));
txtvFundingUrl.setText(str.toString());
viewBinding.supportUrl.setText(str.toString());
}
refreshToolbarState();
@ -275,11 +249,13 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
}
private void refreshToolbarState() {
toolbar.getMenu().findItem(R.id.reconnect_local_folder).setVisible(feed != null && feed.isLocalFeed());
toolbar.getMenu().findItem(R.id.share_item).setVisible(feed != null && !feed.isLocalFeed());
toolbar.getMenu().findItem(R.id.visit_website_item).setVisible(feed != null && feed.getLink() != null
viewBinding.toolbar.getMenu().findItem(R.id.reconnect_local_folder).setVisible(
feed != null && feed.isLocalFeed());
viewBinding.toolbar.getMenu().findItem(R.id.share_item).setVisible(feed != null && !feed.isLocalFeed());
viewBinding.toolbar.getMenu().findItem(R.id.visit_website_item).setVisible(feed != null
&& feed.getLink() != null
&& IntentUtils.isCallable(getContext(), new Intent(Intent.ACTION_VIEW, Uri.parse(feed.getLink()))));
toolbar.getMenu().findItem(R.id.edit_feed_url_item).setVisible(feed != null && !feed.isLocalFeed());
viewBinding.toolbar.getMenu().findItem(R.id.edit_feed_url_item).setVisible(feed != null && !feed.isLocalFeed());
}
@Override
@ -310,8 +286,9 @@ public class FeedInfoFragment extends Fragment implements MaterialToolbar.OnMenu
@Override
protected void setUrl(String url) {
feed.setDownloadUrl(url);
txtvUrl.setText(feed.getDownloadUrl());
txtvUrl.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_paperclip, 0);
viewBinding.urlLabel.setText(feed.getDownloadUrl());
viewBinding.urlLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(
0, 0, R.drawable.ic_paperclip, 0);
}
}.show();
} else {

View File

@ -157,7 +157,7 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem
viewBinding.progressBar.setVisibility(View.VISIBLE);
ToolbarIconTintManager iconTintManager = new ToolbarIconTintManager(
getContext(), viewBinding.toolbar, viewBinding.collapsingToolbar) {
viewBinding.toolbar.getContext(), viewBinding.toolbar, viewBinding.collapsingToolbar) {
@Override
protected void doTint(Context themedContext) {
viewBinding.toolbar.getMenu().findItem(R.id.refresh_item)

View File

@ -18,7 +18,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/add_feed_label"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />

View File

@ -26,7 +26,6 @@
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="@drawable/ic_arrow_down" />

View File

@ -11,7 +11,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
android:layout_alignParentTop="true"
app:title="@string/downloads_log_label" />

View File

@ -41,7 +41,6 @@
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:layout_collapseMode="pin"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />
@ -69,70 +68,7 @@
android:paddingHorizontal="@dimen/additional_horizontal_spacing">
<TextView
android:id="@+id/lblUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/url_label"
android:textColor="?android:attr/textColorPrimary"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/txtvUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:maxLines="4"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:drawablePadding="4dp"
tools:background="@android:color/holo_green_dark"
tools:text="http://www.example.com/feed" />
<TextView
android:id="@+id/lblSupport"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/support_funding_label"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/txtvFundingUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="8"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:linksClickable="true"
android:autoLink="web"
tools:background="@android:color/holo_green_dark" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/description_label"
android:textColor="?android:attr/textColorPrimary"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/txtvDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/design_time_lorem_ipsum"
android:textIsSelectable="true"
tools:background="@android:color/holo_green_dark" />
<TextView
android:id="@+id/lblStatistics"
android:id="@+id/statisticsHeadingLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
@ -148,7 +84,7 @@
android:layout_height="wrap_content" />
<Button
android:id="@+id/btnvOpenStatistics"
android:id="@+id/statisticsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
@ -156,6 +92,70 @@
android:text="@string/statistics_view_all"
style="@style/Widget.MaterialComponents.Button.TextButton" />
<TextView
android:id="@+id/supportHeadingLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/support_funding_label"
android:textColor="?android:attr/textColorPrimary"
android:textSize="18sp"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/supportUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="8"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:linksClickable="true"
android:autoLink="web"
tools:background="@android:color/holo_green_dark" />
<TextView
android:id="@+id/descriptionHeadingLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/description_label"
android:textColor="?android:attr/textColorPrimary"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/descriptionLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/design_time_lorem_ipsum"
android:textIsSelectable="true"
tools:background="@android:color/holo_green_dark" />
<TextView
android:id="@+id/urlHeadingLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:layout_marginTop="16dp"
android:layout_marginBottom="4dp"
android:text="@string/url_label"
android:textColor="?android:attr/textColorPrimary"
tools:background="@android:color/holo_red_light" />
<TextView
android:id="@+id/urlLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:maxLines="4"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:drawablePadding="4dp"
tools:background="@android:color/holo_green_dark"
tools:text="http://www.example.com/feed" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -12,7 +12,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />

View File

@ -12,8 +12,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
android:elevation="4dp"
app:title="@string/feed_settings_label"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />

View File

@ -17,7 +17,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/search_label"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />

View File

@ -25,7 +25,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
android:layout_alignParentTop="true" />
<View

View File

@ -4,7 +4,7 @@ import java.util.List;
public class Chapter {
private long id;
/** Defines starting point in milliseconds. */
/** The start time of the chapter in milliseconds */
private long start;
private String title;
private String link;
@ -66,7 +66,7 @@ public class Chapter {
@Override
public String toString() {
return "ID3Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]";
return "Chapter [title=" + getTitle() + ", start=" + getStart() + ", url=" + getLink() + "]";
}
public long getId() {

View File

@ -0,0 +1,161 @@
package de.danoeh.antennapod.parser.media.m4a;
import android.util.Log;
import org.apache.commons.io.IOUtils;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import de.danoeh.antennapod.model.feed.Chapter;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class M4AChapterReader {
private static final String TAG = "M4AChapterReader";
private final List<Chapter> chapters = new ArrayList<>();
private final InputStream inputStream;
private static final int FTYP_CODE = 0x66747970; // "ftyp"
public M4AChapterReader(InputStream input) {
inputStream = input;
}
/**
* Read the input stream populating the chapters list
*/
public void readInputStream() {
try {
isM4A(inputStream);
int dataSize = this.findAtom("moov.udta.chpl");
if (dataSize == -1) {
Log.d(TAG, "Nero Chapter Atom not found");
} else {
Log.d(TAG, "Nero Chapter Atom found. Data Size: " + dataSize);
this.parseNeroChapterAtom(dataSize);
}
} catch (Exception e) {
Log.d(TAG, "ERROR: " + e.getMessage());
}
}
/**
* Find the atom with the given name in the M4A file
*
* @param name the name of the atom to find, separated by dots
* @return the size of the atom (minus the 8-byte header) if found
* @throws IOException if an I/O error occurs or the atom is not found
*/
public int findAtom(String name) throws IOException {
// Split the name into parts encoded as UTF-8
String[] parts = name.split("\\.");
int partIndex = 0;
// Initialize remaining size to track the current part's size and check if it is exceeded
int remainingSize = -1;
// Read the M4A file atom by atom
ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
while (true) {
// Read the atom header
IOUtils.readFully(inputStream, buffer.array());
// Get the size of the current atom
int chunkSize = buffer.getInt();
int dataSize = chunkSize - 8;
// Get the atom type
String atomType = StandardCharsets.UTF_8.decode(buffer).toString();
// Reset the buffer for reading the atom data
buffer.clear();
// Check if the current atom matches the current part of the name
if (atomType.equals(parts[partIndex])) {
if (partIndex == parts.length - 1) {
// If the current atom is the last part of the name return its size
return dataSize;
} else {
// Else move to the next part of the name
partIndex++;
// Update the remaining size
remainingSize = dataSize;
}
} else {
// Do not check the remaining size of top-level atoms
if (partIndex > 0) {
// Update the remaining size
remainingSize -= dataSize;
// If the remaining size is exhausted, throw an exception
if (remainingSize <= 0) {
throw new IOException("Part size exceeded for part \"" + parts[partIndex - 1]
+ "\" while searching atom. Remaining Size: " + remainingSize);
}
}
// Skip the rest of the atom
IOUtils.skipFully(inputStream, dataSize);
}
}
}
/**
* Parse the Nero Chapter Atom in the M4A file
* Assumes that the current position is at the start of the Nero Chapter Atom
*
* @param chunkSize the size of the Nero Chapter Atom
* @throws IOException if an I/O error occurs
* @see <a href="https://github.com/Zeugma440/atldotnet/wiki/Focus-on-Chapter-metadata#nero-chapters">Nero Chapter</a>
*/
private void parseNeroChapterAtom(long chunkSize) throws IOException {
// Read the Nero Chapter Atom data into a buffer
ByteBuffer byteBuffer = ByteBuffer.allocate((int) chunkSize).order(ByteOrder.BIG_ENDIAN);
IOUtils.readFully(inputStream, byteBuffer.array());
// Skip the 5-byte header
// Nero Chapter Atom consists of a 5-byte header followed by chapter data
// The first 4 bytes are the version and flags, the 5th byte is reserved
byteBuffer.position(5);
// Get the chapter count
int chapterCount = byteBuffer.getInt();
Log.d(TAG, "Nero Chapter Count: " + chapterCount);
// Parse each chapter
for (int i = 0; i < chapterCount; i++) {
long startTime = byteBuffer.getLong();
int chapterNameSize = byteBuffer.get();
byte[] chapterNameBytes = new byte[chapterNameSize];
byteBuffer.get(chapterNameBytes, 0, chapterNameSize);
String chapterName = new String(chapterNameBytes, StandardCharsets.UTF_8);
Chapter chapter = new Chapter();
chapter.setStart(startTime / 10000);
chapter.setTitle(chapterName);
chapter.setChapterId(String.valueOf(i + 1));
chapters.add(chapter);
Log.d(TAG, "Nero Chapter " + (i + 1) + ": " + chapter);
}
}
public List<Chapter> getChapters() {
return chapters;
}
/**
* Assert that the input stream is an M4A file by checking the signature
*
* @param inputStream the input stream to check
* @throws IOException if an I/O error occurs
*/
public static void isM4A(InputStream inputStream) throws IOException {
ByteBuffer byteBuffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN);
IOUtils.readFully(inputStream, byteBuffer.array());
int ftypSize = byteBuffer.getInt();
if (byteBuffer.getInt() != FTYP_CODE) {
throw new IOException("Not an M4A file");
}
IOUtils.skipFully(inputStream, ftypSize - 8);
}
}

View File

@ -0,0 +1,41 @@
package de.danoeh.antennapod.parser.media.m4a;
import de.danoeh.antennapod.model.feed.Chapter;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import static org.junit.Assert.assertEquals;
@RunWith(RobolectricTestRunner.class)
public class M4AChapterReaderTest {
@Test
public void testFiles() throws IOException {
testFile();
}
public void testFile() throws IOException {
InputStream inputStream = getClass().getClassLoader()
.getResource("nero-chapters.m4a").openStream();
M4AChapterReader reader = new M4AChapterReader(inputStream);
reader.readInputStream();
List<Chapter> chapters = reader.getChapters();
assertEquals(4, chapters.size());
assertEquals(0, chapters.get(0).getStart());
assertEquals(3000, chapters.get(1).getStart());
assertEquals(6000, chapters.get(2).getStart());
assertEquals(9000, chapters.get(3).getStart());
assertEquals("Chapter 1 - ❤️😊", chapters.get(0).getTitle());
assertEquals("Chapter 2 - ßöÄ", chapters.get(1).getTitle());
assertEquals("Chapter 3 - 爱", chapters.get(2).getTitle());
assertEquals("Chapter 4", chapters.get(3).getTitle());
}
}

Binary file not shown.

View File

@ -16,6 +16,7 @@ import de.danoeh.antennapod.parser.media.id3.ID3ReaderException;
import de.danoeh.antennapod.model.playback.Playable;
import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentChapterReader;
import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException;
import de.danoeh.antennapod.parser.media.m4a.M4AChapterReader;
import okhttp3.CacheControl;
import okhttp3.Request;
import okhttp3.Response;
@ -106,6 +107,19 @@ public class ChapterUtils {
} catch (IOException | VorbisCommentReaderException e) {
Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage());
}
try (CountingInputStream in = openStream(playable, context)) {
List<Chapter> chapters = readM4AChaptersFromInputStream(in);
if (!chapters.isEmpty()) {
Log.i(TAG, "Chapters loaded");
return chapters;
}
} catch (InterruptedIOException e) {
throw e;
} catch (IOException e) {
Log.e(TAG, "Unable to open stream " + e.getMessage());
}
return null;
}
@ -195,6 +209,22 @@ public class ChapterUtils {
return Collections.emptyList();
}
@NonNull
private static List<Chapter> readM4AChaptersFromInputStream(InputStream input) {
M4AChapterReader reader = new M4AChapterReader(new BufferedInputStream(input));
reader.readInputStream();
List<Chapter> chapters = reader.getChapters();
if (chapters == null) {
return Collections.emptyList();
}
Collections.sort(chapters, new ChapterStartTimeComparator());
enumerateEmptyChapterTitles(chapters);
if (chaptersValid(chapters)) {
return chapters;
}
return Collections.emptyList();
}
/**
* Makes sure that chapter does a title and an item attribute.
*/

View File

@ -27,6 +27,7 @@
<item name="android:splitMotionEvents">false</item>
<item name="android:fitsSystemWindows">false</item>
<item name="android:windowContentTransitions">true</item>
<item name="toolbarStyle">@style/Style.AntennaPod.Toolbar</item>
<item name="preferenceTheme">@style/AppPreferenceThemeOverlay</item>
</style>
@ -52,9 +53,9 @@
<style name="Theme.Base.AntennaPod.Dynamic.Dark" parent="Theme.Material3.DynamicColors.Dark">
<item name="progressBarTheme">@style/ProgressBarDark</item>
<item name="background_color">@color/background_darktheme</item>
<item name="actionBarStyle">@style/Widget.AntennaPod.ActionBar</item>
<item name="background_elevated">@color/background_elevated_darktheme</item>
<item name="action_icon_color">@color/white</item>
<item name="actionBarStyle">@style/Widget.AntennaPod.ActionBar</item>
<item name="android:textAllCaps">false</item>
<item name="seek_background">@color/seek_background_dark</item>
<item name="dragview_background">@drawable/ic_drag_darktheme</item>
@ -70,6 +71,7 @@
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<item name="android:windowContentTransitions">true</item>
<item name="android:navigationBarColor">@color/background_darktheme</item>
<item name="toolbarStyle">@style/Style.AntennaPod.Toolbar</item>
<item name="preferenceTheme">@style/AppPreferenceThemeOverlay</item>
</style>
@ -227,6 +229,15 @@
<item name="windowSplashScreenAnimatedIcon">@drawable/launcher_animate</item>
</style>
<style name="Style.AntennaPod.Toolbar" parent="Widget.Material3.Toolbar">
<item name="materialThemeOverlay">@style/Theme.AntennaPod.Toolbar</item>
</style>
<style name="Theme.AntennaPod.Toolbar" parent="ThemeOverlay.MaterialComponents.Toolbar.Surface">
<item name="action_icon_color">?attr/colorOnSurface</item>
<item name="colorControlNormal">?attr/colorOnSurface</item>
</style>
<style name="Theme.AntennaPod.VideoPlayer" parent="@style/Theme.AntennaPod.Dark">
<item name="windowActionBarOverlay">true</item>
</style>

View File

@ -19,7 +19,6 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?attr/actionBarSize"
android:theme="?attr/actionBarTheme"
app:title="@string/discover"
app:navigationContentDescription="@string/toolbar_back_button_content_description"
app:navigationIcon="?homeAsUpIndicator" />