Merge branch 'dev' into refactor

This commit is contained in:
tobigr 2026-01-09 14:09:14 +01:00
commit f5245eac91
68 changed files with 1177 additions and 1508 deletions

46
.github/workflows/backport-pr.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Backport merged pull request
on:
issue_comment:
types: [created]
permissions:
contents: write # for comment creation on original PR
pull-requests: write
jobs:
backport:
name: Backport pull request
runs-on: ubuntu-latest
# Only run when the comment starts with the `/backport` command on a PR and
# the commenter has write access to the repository. We do not want to allow
# everybody to trigger backports and create branches in our repository.
if: >
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/backport ') &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'MEMBER'
)
steps:
- uses: actions/checkout@v4
- name: Get backport metadata
# the target branch is the first argument after `/backport`
run: |
set -euo pipefail
body="${{ github.event.comment.body }}"
line=${body%%$'\n'*} # Get the first line
if [[ $line =~ ^/backport[[:space:]]+([^[:space:]]+) ]]; then
echo "BACKPORT_TARGET=${BASH_REMATCH[1]}" >> "$GITHUB_ENV"
else
echo "Usage: /backport <target-branch>" >&2
exit 1
fi
- name: Create backport pull request
uses: korthout/backport-action@v4
with:
add_labels: 'backport'
copy_labels_pattern: '.*'
label_pattern: ''
target_branches: ${{ env.BACKPORT_TARGET }}

View File

@ -7,11 +7,11 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
ref: 'master'
- uses: actions/setup-java@v4
- uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
@ -32,7 +32,7 @@ jobs:
mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk"
- name: "Upload APK"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/release/*.apk

View File

@ -37,8 +37,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- uses: actions/checkout@v6
- uses: gradle/actions/wrapper-validation@v4
- name: create and checkout branch
# push events already checked out the branch
@ -48,7 +48,7 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@ -58,7 +58,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/debug/*.apk
@ -80,7 +80,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Enable KVM
run: |
@ -89,7 +89,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@ -104,7 +104,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
if: failure()
with:
name: android-test-report-api${{ matrix.api-level }}
@ -118,19 +118,19 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
cache: 'gradle'
- name: Cache SonarCloud packages
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar

View File

@ -17,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 16
@ -27,7 +27,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images
uses: actions/github-script@v7
uses: actions/github-script@v8
timeout-minutes: 3
with:
script: |

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@ interface PlaylistRemoteDAO : BasicDAO<PlaylistRemoteEntity> {
@Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity>
@Query("SELECT * FROM remote_playlists WHERE url = :url AND uid = :serviceId")
@Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
fun getPlaylist(serviceId: Long, url: String?): Flowable<MutableList<PlaylistRemoteEntity>>
@get:Query("SELECT * FROM remote_playlists ORDER BY display_index")

View File

@ -1,9 +1,14 @@
package org.schabi.newpipe.error;
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.error
/**
* The user actions that can cause an error.
*/
public enum UserAction {
enum class UserAction(val message: String) {
USER_REPORT("user report"),
UI_ERROR("ui error"),
DATABASE_IMPORT_EXPORT("database import or export"),
@ -36,14 +41,4 @@ public enum UserAction {
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
PLAY_ON_POPUP("play on popup"),
SUBSCRIPTIONS("loading subscriptions");
private final String message;
UserAction(final String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}

View File

@ -1,32 +0,0 @@
package org.schabi.newpipe.fragments.list.search;
import androidx.annotation.NonNull;
public class SuggestionItem {
final boolean fromHistory;
public final String query;
public SuggestionItem(final boolean fromHistory, final String query) {
this.fromHistory = fromHistory;
this.query = query;
}
@Override
public boolean equals(final Object o) {
if (o instanceof SuggestionItem) {
return query.equals(((SuggestionItem) o).query);
}
return false;
}
@Override
public int hashCode() {
return query.hashCode();
}
@NonNull
@Override
public String toString() {
return "[" + fromHistory + "" + query + "]";
}
}

View File

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.fragments.list.search
class SuggestionItem(@JvmField val fromHistory: Boolean, @JvmField val query: String) {
override fun equals(other: Any?): Boolean {
if (other is SuggestionItem) {
return query == other.query
}
return false
}
override fun hashCode() = query.hashCode()
override fun toString() = "[$fromHistory$query]"
}

View File

@ -1,94 +0,0 @@
package org.schabi.newpipe.fragments.list.search;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding;
public class SuggestionListAdapter
extends ListAdapter<SuggestionItem, SuggestionListAdapter.SuggestionItemHolder> {
private OnSuggestionItemSelected listener;
public SuggestionListAdapter() {
super(new SuggestionItemCallback());
}
public void setListener(final OnSuggestionItemSelected listener) {
this.listener = listener;
}
@NonNull
@Override
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int viewType) {
return new SuggestionItemHolder(ItemSearchSuggestionBinding
.inflate(LayoutInflater.from(parent.getContext()), parent, false));
}
@Override
public void onBindViewHolder(final SuggestionItemHolder holder, final int position) {
final SuggestionItem currentItem = getItem(position);
holder.updateFrom(currentItem);
holder.itemBinding.suggestionSearch.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemSelected(currentItem);
}
});
holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemLongClick(currentItem);
}
return true;
});
holder.itemBinding.suggestionInsert.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemInserted(currentItem);
}
});
}
public interface OnSuggestionItemSelected {
void onSuggestionItemSelected(SuggestionItem item);
void onSuggestionItemInserted(SuggestionItem item);
void onSuggestionItemLongClick(SuggestionItem item);
}
public static final class SuggestionItemHolder extends RecyclerView.ViewHolder {
private final ItemSearchSuggestionBinding itemBinding;
private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) {
super(binding.getRoot());
this.itemBinding = binding;
}
private void updateFrom(final SuggestionItem item) {
itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history
: R.drawable.ic_search);
itemBinding.itemSuggestionQuery.setText(item.query);
}
}
private static final class SuggestionItemCallback
extends DiffUtil.ItemCallback<SuggestionItem> {
@Override
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return oldItem.fromHistory == newItem.fromHistory
&& oldItem.query.equals(newItem.query);
}
@Override
public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return true; // items' contents never change; the list of items themselves does
}
}
}

View File

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.fragments.list.search
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding
import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder
class SuggestionListAdapter :
ListAdapter<SuggestionItem, SuggestionItemHolder>(SuggestionItemCallback()) {
var listener: OnSuggestionItemSelected? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionItemHolder {
return SuggestionItemHolder(
ItemSearchSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) {
val currentItem = getItem(position)
holder.updateFrom(currentItem)
holder.binding.suggestionSearch.setOnClickListener {
listener?.onSuggestionItemSelected(currentItem)
}
holder.binding.suggestionSearch.setOnLongClickListener {
listener?.onSuggestionItemLongClick(currentItem)
true
}
holder.binding.suggestionInsert.setOnClickListener {
listener?.onSuggestionItemInserted(currentItem)
}
}
interface OnSuggestionItemSelected {
fun onSuggestionItemSelected(item: SuggestionItem)
fun onSuggestionItemInserted(item: SuggestionItem)
fun onSuggestionItemLongClick(item: SuggestionItem)
}
class SuggestionItemHolder(val binding: ItemSearchSuggestionBinding) :
RecyclerView.ViewHolder(binding.getRoot()) {
fun updateFrom(item: SuggestionItem) {
binding.itemSuggestionIcon.setImageResource(
if (item.fromHistory) {
R.drawable.ic_history
} else {
R.drawable.ic_search
}
)
binding.itemSuggestionQuery.text = item.query
}
}
private class SuggestionItemCallback : DiffUtil.ItemCallback<SuggestionItem>() {
override fun areItemsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
return oldItem.fromHistory == newItem.fromHistory && oldItem.query == newItem.query
}
override fun areContentsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
return true // items' contents never change; the list of items themselves does
}
}
}

View File

@ -1,9 +1,14 @@
package org.schabi.newpipe.info_list;
/*
* SPDX-FileCopyrightText: 2023-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.info_list
/**
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
enum class ItemViewMode {
/**
* Default mode.
*/

View File

@ -1,108 +0,0 @@
package org.schabi.newpipe.local.history;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.util.Localization;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
/**
* This is an adapter for history entries.
*
* @param <E> the type of the entries
* @param <VH> the type of the view holder
*/
public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
extends RecyclerView.Adapter<VH> {
private final ArrayList<E> mEntries;
private final DateFormat mDateFormat;
private final Context mContext;
private OnHistoryItemClickListener<E> onHistoryItemClickListener = null;
public HistoryEntryAdapter(final Context context) {
super();
mContext = context;
mEntries = new ArrayList<>();
mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM,
Localization.getPreferredLocale(context));
}
public void setEntries(@NonNull final Collection<E> historyEntries) {
mEntries.clear();
mEntries.addAll(historyEntries);
notifyDataSetChanged();
}
public Collection<E> getItems() {
return mEntries;
}
public void clear() {
mEntries.clear();
notifyDataSetChanged();
}
protected String getFormattedDate(final Date date) {
return mDateFormat.format(date);
}
protected String getFormattedViewString(final long viewCount) {
return Localization.shortViewCount(mContext, viewCount);
}
@Override
public int getItemCount() {
return mEntries.size();
}
@Override
public void onBindViewHolder(final VH holder, final int position) {
final E entry = mEntries.get(position);
holder.itemView.setOnClickListener(v -> {
if (onHistoryItemClickListener != null) {
onHistoryItemClickListener.onHistoryItemClick(entry);
}
});
holder.itemView.setOnLongClickListener(view -> {
if (onHistoryItemClickListener != null) {
onHistoryItemClickListener.onHistoryItemLongClick(entry);
return true;
}
return false;
});
onBindViewHolder(holder, entry, position);
}
@Override
public void onViewRecycled(@NonNull final VH holder) {
super.onViewRecycled(holder);
holder.itemView.setOnClickListener(null);
}
abstract void onBindViewHolder(VH holder, E entry, int position);
public void setOnHistoryItemClickListener(
@Nullable final OnHistoryItemClickListener<E> onHistoryItemClickListener) {
this.onHistoryItemClickListener = onHistoryItemClickListener;
}
public boolean isEmpty() {
return mEntries.isEmpty();
}
public interface OnHistoryItemClickListener<E> {
void onHistoryItemClick(E item);
void onHistoryItemLongClick(E item);
}
}

View File

@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import android.content.Context
@ -21,11 +26,7 @@ fun export(
}
}
fun exportWithTitles(
playlist: List<PlaylistStreamEntry>,
context: Context
): String {
private fun exportWithTitles(playlist: List<PlaylistStreamEntry>, context: Context): String {
return playlist.asSequence()
.map { it.streamEntity }
.map { entity ->
@ -38,18 +39,14 @@ fun exportWithTitles(
.joinToString(separator = "\n")
}
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
return playlist.asSequence()
.map { it.streamEntity.url }
.joinToString(separator = "\n")
private fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
return playlist.joinToString(separator = "\n") { it.streamEntity.url }
}
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
private fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
val videoIDs = playlist.asReversed().asSequence()
.map { it.streamEntity.url }
.mapNotNull(::getYouTubeId)
.mapNotNull { getYouTubeId(it.streamEntity.url) }
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
.toList()
.asReversed()
@ -58,7 +55,7 @@ fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
}
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
private val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
/**
* Gets the video id from a YouTube URL.
@ -66,7 +63,7 @@ val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFacto
* @param url YouTube URL
* @return the video id
*/
fun getYouTubeId(url: String): String? {
private fun getYouTubeId(url: String): String? {
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
}

View File

@ -111,7 +111,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null;
public static LocalPlaylistFragment getInstance(final long playlistId, final String name) {
final LocalPlaylistFragment instance = new LocalPlaylistFragment();
final var instance = new LocalPlaylistFragment();
instance.setInitialData(playlistId, name);
return instance;
}
@ -180,9 +180,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
final StreamEntity item =
((PlaylistStreamEntry) selectedItem).getStreamEntity();
if (selectedItem instanceof PlaylistStreamEntry entry) {
final StreamEntity item = entry.getStreamEntity();
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
}
@ -496,6 +495,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
debounceSaver.setHasChangesToSave();
saveImmediate();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
@ -560,8 +560,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return;
}
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length());
@ -667,6 +666,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.addItems(itemsToKeep);
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
saveImmediate();
hideLoading();
isRewritingPlaylist = false;
@ -686,6 +686,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
saveImmediate();
}
/**
@ -708,8 +709,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final List<LocalItem> items = itemListAdapter.getItemsList();
final List<Long> streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
if (item instanceof PlaylistStreamEntry) {
streamIds.add(((PlaylistStreamEntry) item).getStreamId());
if (item instanceof PlaylistStreamEntry entry) {
streamIds.add(entry.getStreamId());
}
}
@ -767,6 +768,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
debounceSaver.setHasChangesToSave();
saveImmediate();
}
return isSwapped;
}

View File

@ -1,8 +0,0 @@
package org.schabi.newpipe.local.playlist;
public enum PlayListShareMode {
JUST_URLS,
WITH_TITLES,
YOUTUBE_TEMP_PLAYLIST
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
enum class PlayListShareMode {
JUST_URLS,
WITH_TITLES,
YOUTUBE_TEMP_PLAYLIST
}

View File

@ -1,69 +0,0 @@
package org.schabi.newpipe.local.playlist;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class RemotePlaylistManager {
private final AppDatabase database;
private final PlaylistRemoteDAO playlistRemoteTable;
public RemotePlaylistManager(final AppDatabase db) {
database = db;
playlistRemoteTable = db.playlistRemoteDAO();
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylists() {
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
.subscribeOn(Schedulers.io());
}
public Single<Integer> deletePlaylist(final long playlistId) {
return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId))
.subscribeOn(Schedulers.io());
}
public Completable updatePlaylists(final List<PlaylistRemoteEntity> updateItems,
final List<Long> deletedItems) {
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
for (final Long uid: deletedItems) {
playlistRemoteTable.deletePlaylist(uid);
}
for (final PlaylistRemoteEntity item: updateItems) {
playlistRemoteTable.upsert(item);
}
})).subscribeOn(Schedulers.io());
}
public Single<Long> onBookmark(final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
return playlistRemoteTable.upsert(playlist);
}).subscribeOn(Schedulers.io());
}
public Single<Integer> onUpdate(final long playlistId, final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
playlist.setUid(playlistId);
return playlistRemoteTable.update(playlist);
}).subscribeOn(Schedulers.io());
}
}

View File

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
class RemotePlaylistManager(private val database: AppDatabase) {
private val playlistRemoteTable = database.playlistRemoteDAO()
val playlists: Flowable<MutableList<PlaylistRemoteEntity>>
get() = playlistRemoteTable.playlists.subscribeOn(Schedulers.io())
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity> {
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io())
}
fun getPlaylist(info: PlaylistInfo): Flowable<MutableList<PlaylistRemoteEntity>> {
return playlistRemoteTable.getPlaylist(info.serviceId.toLong(), info.url)
.subscribeOn(Schedulers.io())
}
fun deletePlaylist(playlistId: Long): Single<Int> {
return Single.fromCallable { playlistRemoteTable.deletePlaylist(playlistId) }
.subscribeOn(Schedulers.io())
}
fun updatePlaylists(
updateItems: List<PlaylistRemoteEntity>,
deletedItems: List<Long>
): Completable {
return Completable.fromRunnable {
database.runInTransaction {
deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) }
updateItems.forEach { playlistRemoteTable.upsert(it) }
}
}.subscribeOn(Schedulers.io())
}
fun onBookmark(playlistInfo: PlaylistInfo): Single<Long> {
return Single.fromCallable {
val playlist = PlaylistRemoteEntity(playlistInfo)
playlistRemoteTable.upsert(playlist)
}.subscribeOn(Schedulers.io())
}
fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single<Int> {
return Single.fromCallable {
val playlist = PlaylistRemoteEntity(playlistInfo).apply { uid = playlistId }
playlistRemoteTable.update(playlist)
}.subscribeOn(Schedulers.io())
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.player;
public enum PlayerType {
MAIN,
AUDIO,
POPUP;
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2022-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.player
enum class PlayerType {
MAIN,
AUDIO,
POPUP
}

View File

@ -17,10 +17,10 @@ import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
import java.util.Collection;
import java.util.Collections;

View File

@ -4,15 +4,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.schabi.newpipe.player.playqueue.events.AppendEvent
import org.schabi.newpipe.player.playqueue.events.ErrorEvent
import org.schabi.newpipe.player.playqueue.events.InitEvent
import org.schabi.newpipe.player.playqueue.events.MoveEvent
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent
import org.schabi.newpipe.player.playqueue.events.RecoveryEvent
import org.schabi.newpipe.player.playqueue.events.RemoveEvent
import org.schabi.newpipe.player.playqueue.events.ReorderEvent
import org.schabi.newpipe.player.playqueue.events.SelectEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent
import java.io.Serializable
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger

View File

@ -10,12 +10,11 @@ import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.playqueue.events.AppendEvent;
import org.schabi.newpipe.player.playqueue.events.ErrorEvent;
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
import org.schabi.newpipe.player.playqueue.events.SelectEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent;
import org.schabi.newpipe.util.FallbackViewHolder;
import java.util.List;

View File

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2017-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.player.playqueue
import java.io.Serializable
sealed interface PlayQueueEvent : Serializable {
fun type(): Type
class InitEvent : PlayQueueEvent {
override fun type() = Type.INIT
}
// sent when the index is changed
class SelectEvent(val oldIndex: Int, val newIndex: Int) : PlayQueueEvent {
override fun type() = Type.SELECT
}
// sent when more streams are added to the play queue
class AppendEvent(val amount: Int) : PlayQueueEvent {
override fun type() = Type.APPEND
}
// sent when a pending stream is removed from the play queue
class RemoveEvent(val removeIndex: Int, val queueIndex: Int) : PlayQueueEvent {
override fun type() = Type.REMOVE
}
// sent when two streams swap place in the play queue
class MoveEvent(val fromIndex: Int, val toIndex: Int) : PlayQueueEvent {
override fun type() = Type.MOVE
}
// sent when queue is shuffled
class ReorderEvent(val fromSelectedIndex: Int, val toSelectedIndex: Int) : PlayQueueEvent {
override fun type() = Type.REORDER
}
// sent when recovery record is set on a stream
class RecoveryEvent(val index: Int, val position: Long) : PlayQueueEvent {
override fun type() = Type.RECOVERY
}
// sent when the item at index has caused an exception
class ErrorEvent(val errorIndex: Int, val queueIndex: Int) : PlayQueueEvent {
override fun type() = Type.ERROR
}
// It is necessary only for use in java code. Remove it and use kotlin pattern
// matching when all users of this enum are converted to kotlin
enum class Type { INIT, SELECT, APPEND, REMOVE, MOVE, REORDER, RECOVERY, ERROR }
}

View File

@ -1,18 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class AppendEvent implements PlayQueueEvent {
private final int amount;
public AppendEvent(final int amount) {
this.amount = amount;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.APPEND;
}
public int getAmount() {
return amount;
}
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class ErrorEvent implements PlayQueueEvent {
private final int errorIndex;
private final int queueIndex;
public ErrorEvent(final int errorIndex, final int queueIndex) {
this.errorIndex = errorIndex;
this.queueIndex = queueIndex;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.ERROR;
}
public int getErrorIndex() {
return errorIndex;
}
public int getQueueIndex() {
return queueIndex;
}
}

View File

@ -1,8 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class InitEvent implements PlayQueueEvent {
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.INIT;
}
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class MoveEvent implements PlayQueueEvent {
private final int fromIndex;
private final int toIndex;
public MoveEvent(final int oldIndex, final int newIndex) {
this.fromIndex = oldIndex;
this.toIndex = newIndex;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.MOVE;
}
public int getFromIndex() {
return fromIndex;
}
public int getToIndex() {
return toIndex;
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
import java.io.Serializable;
public interface PlayQueueEvent extends Serializable {
PlayQueueEventType type();
}

View File

@ -1,27 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public enum PlayQueueEventType {
INIT,
// sent when the index is changed
SELECT,
// sent when more streams are added to the play queue
APPEND,
// sent when a pending stream is removed from the play queue
REMOVE,
// sent when two streams swap place in the play queue
MOVE,
// sent when queue is shuffled
REORDER,
// sent when recovery record is set on a stream
RECOVERY,
// sent when the item at index has caused an exception
ERROR
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class RecoveryEvent implements PlayQueueEvent {
private final int index;
private final long position;
public RecoveryEvent(final int index, final long position) {
this.index = index;
this.position = position;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.RECOVERY;
}
public int getIndex() {
return index;
}
public long getPosition() {
return position;
}
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class RemoveEvent implements PlayQueueEvent {
private final int removeIndex;
private final int queueIndex;
public RemoveEvent(final int removeIndex, final int queueIndex) {
this.removeIndex = removeIndex;
this.queueIndex = queueIndex;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.REMOVE;
}
public int getQueueIndex() {
return queueIndex;
}
public int getRemoveIndex() {
return removeIndex;
}
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class ReorderEvent implements PlayQueueEvent {
private final int fromSelectedIndex;
private final int toSelectedIndex;
public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) {
this.fromSelectedIndex = fromSelectedIndex;
this.toSelectedIndex = toSelectedIndex;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.REORDER;
}
public int getFromSelectedIndex() {
return fromSelectedIndex;
}
public int getToSelectedIndex() {
return toSelectedIndex;
}
}

View File

@ -1,24 +0,0 @@
package org.schabi.newpipe.player.playqueue.events;
public class SelectEvent implements PlayQueueEvent {
private final int oldIndex;
private final int newIndex;
public SelectEvent(final int oldIndex, final int newIndex) {
this.oldIndex = oldIndex;
this.newIndex = newIndex;
}
@Override
public PlayQueueEventType type() {
return PlayQueueEventType.SELECT;
}
public int getOldIndex() {
return oldIndex;
}
public int getNewIndex() {
return newIndex;
}
}

View File

@ -1557,6 +1557,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
@Override
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
super.onVideoSizeChanged(videoSize);
// Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0
// if the renderer is disabled. In that case, we skip updating the aspect ratio.
if (videoSize.width == 0 || videoSize.height == 0) {
return;
}
binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height);
}
//endregion

View File

@ -92,10 +92,9 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
return true;
});
final Preference resetSettings = findPreference(getString(R.string.reset_settings));
final Preference resetSettings = requirePreference(R.string.reset_settings);
// Resets all settings by deleting shared preference and restarting the app
// A dialogue will pop up to confirm if user intends to reset all settings
assert resetSettings != null;
resetSettings.setOnPreferenceClickListener(preference -> {
// Show Alert Dialogue
final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());

View File

@ -48,8 +48,8 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat {
}
@NonNull
public final Preference requirePreference(@StringRes final int resId) {
final Preference preference = findPreference(getString(resId));
public final <T extends Preference> T requirePreference(@StringRes final int resId) {
final T preference = findPreference(getString(resId));
Objects.requireNonNull(preference);
return preference;
}

View File

@ -21,24 +21,18 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
addPreferencesFromResourceRegistry();
final Preference allowHeapDumpingPreference =
findPreference(getString(R.string.allow_heap_dumping_key));
requirePreference(R.string.allow_heap_dumping_key);
final Preference showMemoryLeaksPreference =
findPreference(getString(R.string.show_memory_leaks_key));
requirePreference(R.string.show_memory_leaks_key);
final Preference checkNewStreamsPreference =
findPreference(getString(R.string.check_new_streams_key));
requirePreference(R.string.check_new_streams_key);
final Preference crashTheAppPreference =
findPreference(getString(R.string.crash_the_app_key));
requirePreference(R.string.crash_the_app_key);
final Preference showErrorSnackbarPreference =
findPreference(getString(R.string.show_error_snackbar_key));
requirePreference(R.string.show_error_snackbar_key);
final Preference createErrorNotificationPreference =
findPreference(getString(R.string.create_error_notification_key));
requirePreference(R.string.create_error_notification_key);
assert allowHeapDumpingPreference != null;
assert showMemoryLeaksPreference != null;
assert checkNewStreamsPreference != null;
assert crashTheAppPreference != null;
assert showErrorSnackbarPreference != null;
assert createErrorNotificationPreference != null;
final Optional<DebugSettingsBVDLeakCanaryAPI> optBVLeakCanary = getBVDLeakCanary();

View File

@ -25,7 +25,7 @@ public class MainSettingsFragment extends BasePreferenceFragment {
// Check if the app is updatable
if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
getPreferenceScreen().removePreference(
findPreference(getString(R.string.update_pref_screen_key)));
requirePreference(R.string.update_pref_screen_key));
defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply();
}
@ -33,7 +33,7 @@ public class MainSettingsFragment extends BasePreferenceFragment {
// Hide debug preferences in RELEASE build variant
if (!DEBUG) {
getPreferenceScreen().removePreference(
findPreference(getString(R.string.debug_pref_screen_key)));
requirePreference(R.string.debug_pref_screen_key));
}
}

View File

@ -29,8 +29,7 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.notifications_settings)
streamsNotificationsPreference =
findPreference(getString(R.string.enable_streams_notifications))
streamsNotificationsPreference = requirePreference(R.string.enable_streams_notifications)
// main check is done in onResume, but also do it here to prevent flickering
updateEnabledState(NotificationHelper.areNotificationsEnabledOnDevice(requireContext()))
@ -125,8 +124,8 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
private fun updateSubscriptions(subscriptions: List<SubscriptionEntity>) {
val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED }
val preference = findPreference<Preference>(getString(R.string.streams_notifications_channels_key))
preference?.apply { summary = "$notified/${subscriptions.size}" }
val preference = requirePreference<Preference>(R.string.streams_notifications_channels_key)
preference.summary = "$notified/${subscriptions.size}"
}
private fun onError(e: Throwable) {

View File

@ -34,9 +34,9 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
addPreferencesFromResourceRegistry();
findPreference(getString(R.string.update_app_key))
requirePreference(R.string.update_app_key)
.setOnPreferenceChangeListener(updatePreferenceChange);
findPreference(getString(R.string.manual_update_key))
requirePreference(R.string.manual_update_key)
.setOnPreferenceClickListener(manualUpdateClick);
}

View File

@ -90,12 +90,12 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
showHigherResolutions);
// get resolution preferences
final ListPreference defaultResolution = findPreference(
getString(R.string.default_resolution_key));
final ListPreference defaultPopupResolution = findPreference(
getString(R.string.default_popup_resolution_key));
final ListPreference mobileDataResolution = findPreference(
getString(R.string.limit_mobile_data_usage_key));
final ListPreference defaultResolution = requirePreference(
R.string.default_resolution_key);
final ListPreference defaultPopupResolution = requirePreference(
R.string.default_popup_resolution_key);
final ListPreference mobileDataResolution = requirePreference(
R.string.limit_mobile_data_usage_key);
// update resolution preferences with new resolutions, entries & values for each
defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0]));
@ -161,8 +161,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
}
}
final ListPreference durations = findPreference(
getString(R.string.seek_duration_key));
final ListPreference durations = requirePreference(R.string.seek_duration_key);
durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0]));
durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0]));
final int selectedDuration = Integer.parseInt(durations.getValue());

View File

@ -1,102 +0,0 @@
package org.schabi.newpipe.settings.preferencesearch;
import androidx.annotation.NonNull;
import androidx.annotation.XmlRes;
import java.util.List;
import java.util.Objects;
/**
* Represents a preference-item inside the search.
*/
public class PreferenceSearchItem {
/**
* Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}.
*/
@NonNull
private final String key;
/**
* Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
*/
@NonNull
private final String title;
/**
* Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
*/
@NonNull
private final String summary;
/**
* Possible entries of the setting, e.g. 480p,720p,...
*/
@NonNull
private final String entries;
/**
* Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
*/
@NonNull
private final String breadcrumbs;
/**
* The xml-resource where this item was found/built from.
*/
@XmlRes
private final int searchIndexItemResId;
public PreferenceSearchItem(
@NonNull final String key,
@NonNull final String title,
@NonNull final String summary,
@NonNull final String entries,
@NonNull final String breadcrumbs,
@XmlRes final int searchIndexItemResId
) {
this.key = Objects.requireNonNull(key);
this.title = Objects.requireNonNull(title);
this.summary = Objects.requireNonNull(summary);
this.entries = Objects.requireNonNull(entries);
this.breadcrumbs = Objects.requireNonNull(breadcrumbs);
this.searchIndexItemResId = searchIndexItemResId;
}
@NonNull
public String getKey() {
return key;
}
@NonNull
public String getTitle() {
return title;
}
@NonNull
public String getSummary() {
return summary;
}
@NonNull
public String getEntries() {
return entries;
}
@NonNull
public String getBreadcrumbs() {
return breadcrumbs;
}
public int getSearchIndexItemResId() {
return searchIndexItemResId;
}
boolean hasData() {
return !key.isEmpty() && !title.isEmpty();
}
public List<String> getAllRelevantSearchFields() {
return List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs());
}
@NonNull
@Override
public String toString() {
return "PreferenceItem: " + title + " " + summary + " " + key;
}
}

View File

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2022-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.preferencesearch
import androidx.annotation.XmlRes
/**
* Represents a preference-item inside the search.
*
* @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences].
* @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
* @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
* @param entries Possible entries of the setting, e.g. 480p,720p,...
* @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
* @param searchIndexItemResId The xml-resource where this item was found/built from.
*/
data class PreferenceSearchItem(
val key: String,
val title: String,
val summary: String,
val entries: String,
val breadcrumbs: String,
@XmlRes val searchIndexItemResId: Int
) {
fun hasData(): Boolean {
return !key.isEmpty() && !title.isEmpty()
}
fun getAllRelevantSearchFields(): MutableList<String?> {
return mutableListOf(title, summary, entries, breadcrumbs)
}
override fun toString(): String {
return "PreferenceItem: $title $summary $key"
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.settings.preferencesearch;
import androidx.annotation.NonNull;
public interface PreferenceSearchResultListener {
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
}

View File

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2022-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings.preferencesearch
interface PreferenceSearchResultListener {
fun onSearchResultClicked(result: PreferenceSearchItem)
}

View File

@ -1,70 +0,0 @@
package org.schabi.newpipe.util;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class FilenameUtils {
private static final String CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+";
private static final String CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+";
private FilenameUtils() { }
/**
* #143 #44 #42 #22: make sure that the filename does not contain illegal chars.
*
* @param context the context to retrieve strings and preferences from
* @param title the title to create a filename from
* @return the filename
*/
public static String createFilename(final Context context, final String title) {
final SharedPreferences sharedPreferences = PreferenceManager
.getDefaultSharedPreferences(context);
final String charsetLd = context.getString(R.string.charset_letters_and_digits_value);
final String charsetMs = context.getString(R.string.charset_most_special_value);
final String defaultCharset = context.getString(R.string.default_file_charset_value);
final String replacementChar = sharedPreferences.getString(
context.getString(R.string.settings_file_replacement_character_key), "_");
String selectedCharset = sharedPreferences.getString(
context.getString(R.string.settings_file_charset_key), null);
final String charset;
if (selectedCharset == null || selectedCharset.isEmpty()) {
selectedCharset = defaultCharset;
}
if (selectedCharset.equals(charsetLd)) {
charset = CHARSET_ONLY_LETTERS_AND_DIGITS;
} else if (selectedCharset.equals(charsetMs)) {
charset = CHARSET_MOST_SPECIAL;
} else {
charset = selectedCharset; // Is the user using a custom charset?
}
final Pattern pattern = Pattern.compile(charset);
return createFilename(title, pattern, Matcher.quoteReplacement(replacementChar));
}
/**
* Create a valid filename.
*
* @param title the title to create a filename from
* @param invalidCharacters patter matching invalid characters
* @param replacementChar the replacement
* @return the filename
*/
private static String createFilename(final String title, final Pattern invalidCharacters,
final String replacementChar) {
return title.replaceAll(invalidCharacters.pattern(), replacementChar);
}
}

View File

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.util
import android.content.Context
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.getStringSafe
import java.util.regex.Matcher
object FilenameUtils {
private const val CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+"
private const val CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+"
/**
* #143 #44 #42 #22: make sure that the filename does not contain illegal chars.
*
* @param context the context to retrieve strings and preferences from
* @param title the title to create a filename from
* @return the filename
*/
@JvmStatic
fun createFilename(context: Context, title: String): String {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val charsetLd = context.getString(R.string.charset_letters_and_digits_value)
val charsetMs = context.getString(R.string.charset_most_special_value)
val defaultCharset = context.getString(R.string.default_file_charset_value)
val replacementChar = sharedPreferences.getStringSafe(
context.getString(R.string.settings_file_replacement_character_key), "_"
)
val selectedCharset = sharedPreferences.getStringSafe(
context.getString(R.string.settings_file_charset_key), ""
).ifEmpty { defaultCharset }
val charset = when (selectedCharset) {
charsetLd -> CHARSET_ONLY_LETTERS_AND_DIGITS
charsetMs -> CHARSET_MOST_SPECIAL
else -> selectedCharset // Is the user using a custom charset?
}
return createFilename(title, charset, Matcher.quoteReplacement(replacementChar))
}
/**
* Create a valid filename.
*
* @param title the title to create a filename from
* @param invalidCharacters patter matching invalid characters
* @param replacementChar the replacement
* @return the filename
*/
private fun createFilename(
title: String,
invalidCharacters: String,
replacementChar: String
): String {
return title.replace(invalidCharacters.toRegex(), replacementChar)
}
}

View File

@ -1,50 +1,33 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.util
import android.content.Context
import org.schabi.newpipe.R
/**
* Created by Christian Schabesberger on 28.09.17.
* KioskTranslator.java is part of NewPipe.
*
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
*
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http:></http:>//www.gnu.org/licenses/>.
*
*/
object KioskTranslator {
@JvmStatic
fun getTranslatedKioskName(kioskId: String, c: Context): String {
fun getTranslatedKioskName(kioskId: String, context: Context): String {
return when (kioskId) {
"Trending" -> c.getString(R.string.trending)
"Top 50" -> c.getString(R.string.top_50)
"New & hot" -> c.getString(R.string.new_and_hot)
"Local" -> c.getString(R.string.local)
"Recently added" -> c.getString(R.string.recently_added)
"Most liked" -> c.getString(R.string.most_liked)
"conferences" -> c.getString(R.string.conferences)
"recent" -> c.getString(R.string.recent)
"live" -> c.getString(R.string.duration_live)
"Featured" -> c.getString(R.string.featured)
"Radio" -> c.getString(R.string.radio)
"trending_gaming" -> c.getString(R.string.trending_gaming)
"trending_music" -> c.getString(R.string.trending_music)
"trending_movies_and_shows" -> c.getString(R.string.trending_movies)
"trending_podcasts_episodes" -> c.getString(R.string.trending_podcasts)
"Trending" -> context.getString(R.string.trending)
"Top 50" -> context.getString(R.string.top_50)
"New & hot" -> context.getString(R.string.new_and_hot)
"Local" -> context.getString(R.string.local)
"Recently added" -> context.getString(R.string.recently_added)
"Most liked" -> context.getString(R.string.most_liked)
"conferences" -> context.getString(R.string.conferences)
"recent" -> context.getString(R.string.recent)
"live" -> context.getString(R.string.duration_live)
"Featured" -> context.getString(R.string.featured)
"Radio" -> context.getString(R.string.radio)
"trending_gaming" -> context.getString(R.string.trending_gaming)
"trending_music" -> context.getString(R.string.trending_music)
"trending_movies_and_shows" -> context.getString(R.string.trending_movies)
"trending_podcasts_episodes" -> context.getString(R.string.trending_podcasts)
else -> kioskId
}
}

View File

@ -1,195 +0,0 @@
package org.schabi.newpipe.util.image;
import static org.schabi.newpipe.extractor.Image.HEIGHT_UNKNOWN;
import static org.schabi.newpipe.extractor.Image.WIDTH_UNKNOWN;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.Image;
import java.util.Comparator;
import java.util.List;
public final class ImageStrategy {
// when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred
// image quality is to these values (H stands for "Height")
private static final int BEST_LOW_H = 75;
private static final int BEST_MEDIUM_H = 250;
private static PreferredImageQuality preferredImageQuality = PreferredImageQuality.MEDIUM;
private ImageStrategy() {
}
public static void setPreferredImageQuality(final PreferredImageQuality preferredImageQuality) {
ImageStrategy.preferredImageQuality = preferredImageQuality;
}
public static boolean shouldLoadImages() {
return preferredImageQuality != PreferredImageQuality.NONE;
}
static double estimatePixelCount(final Image image, final double widthOverHeight) {
if (image.getHeight() == HEIGHT_UNKNOWN) {
if (image.getWidth() == WIDTH_UNKNOWN) {
// images whose size is completely unknown will be in their own subgroups, so
// any one of them will do, hence returning the same value for all of them
return 0;
} else {
return image.getWidth() * image.getWidth() / widthOverHeight;
}
} else if (image.getWidth() == WIDTH_UNKNOWN) {
return image.getHeight() * image.getHeight() * widthOverHeight;
} else {
return image.getHeight() * image.getWidth();
}
}
/**
* {@link #choosePreferredImage(List)} contains the description for this function's logic.
*
* @param images the images from which to choose
* @param nonNoneQuality the preferred quality (must NOT be {@link PreferredImageQuality#NONE})
* @return the chosen preferred image, or {@link null} if the list is empty
* @see #choosePreferredImage(List)
*/
@Nullable
static String choosePreferredImage(@NonNull final List<Image> images,
final PreferredImageQuality nonNoneQuality) {
// this will be used to estimate the pixel count for images where only one of height or
// width are known
final double widthOverHeight = images.stream()
.filter(image -> image.getHeight() != HEIGHT_UNKNOWN
&& image.getWidth() != WIDTH_UNKNOWN)
.mapToDouble(image -> ((double) image.getWidth()) / image.getHeight())
.findFirst()
.orElse(1.0);
final Image.ResolutionLevel preferredLevel = nonNoneQuality.toResolutionLevel();
final Comparator<Image> initialComparator = Comparator
// the first step splits the images into groups of resolution levels
.<Image>comparingInt(i -> {
if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
return 3; // avoid unknowns as much as possible
} else if (i.getEstimatedResolutionLevel() == preferredLevel) {
return 0; // prefer a matching resolution level
} else if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.MEDIUM) {
return 1; // the preferredLevel is only 1 "step" away (either HIGH or LOW)
} else {
return 2; // the preferredLevel is the furthest away possible (2 "steps")
}
})
// then each level's group is further split into two subgroups, one with known image
// size (which is also the preferred subgroup) and the other without
.thenComparing(image ->
image.getHeight() == HEIGHT_UNKNOWN && image.getWidth() == WIDTH_UNKNOWN);
// The third step chooses, within each subgroup with known image size, the best image based
// on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups
// without known image size will be left untouched since estimatePixelCount always returns
// the same number for those.
final Comparator<Image> finalComparator = switch (nonNoneQuality) {
case NONE -> initialComparator; // unreachable
case LOW -> initialComparator.thenComparingDouble(image -> {
final double pixelCount = estimatePixelCount(image, widthOverHeight);
return Math.abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight);
});
case MEDIUM -> initialComparator.thenComparingDouble(image -> {
final double pixelCount = estimatePixelCount(image, widthOverHeight);
return Math.abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight);
});
case HIGH -> initialComparator.thenComparingDouble(
// this is reversed with a - so that the highest resolution is chosen
i -> -estimatePixelCount(i, widthOverHeight));
};
return images.stream()
// using "min" basically means "take the first group, then take the first subgroup,
// then choose the best image, while ignoring all other groups and subgroups"
.min(finalComparator)
.map(Image::getUrl)
.orElse(null);
}
/**
* Chooses an image amongst the provided list based on the user preference previously set with
* {@link #setPreferredImageQuality(PreferredImageQuality)}. {@code null} will be returned in
* case the list is empty or the user preference is to not show images.
* <br>
* These properties will be preferred, from most to least important:
* <ol>
* <li>The image's {@link Image#getEstimatedResolutionLevel()} is not unknown and is close
* to {@link #preferredImageQuality}</li>
* <li>At least one of the image's width or height are known</li>
* <li>The highest resolution image is finally chosen if the user's preference is {@link
* PreferredImageQuality#HIGH}, otherwise the chosen image is the one that has the height
* closest to {@link #BEST_LOW_H} or {@link #BEST_MEDIUM_H}</li>
* </ol>
* <br>
* Use {@link #imageListToDbUrl(List)} if the URL is going to be saved to the database, to avoid
* saving nothing in case at the moment of saving the user preference is to not show images.
*
* @param images the images from which to choose
* @return the chosen preferred image, or {@link null} if the list is empty or the user disabled
* images
* @see #imageListToDbUrl(List)
*/
@Nullable
public static String choosePreferredImage(@NonNull final List<Image> images) {
if (preferredImageQuality == PreferredImageQuality.NONE) {
return null; // do not load images
}
return choosePreferredImage(images, preferredImageQuality);
}
/**
* Like {@link #choosePreferredImage(List)}, except that if {@link #preferredImageQuality} is
* {@link PreferredImageQuality#NONE} an image will be chosen anyway (with preferred quality
* {@link PreferredImageQuality#MEDIUM}.
* <br>
* To go back to a list of images (obviously with just the one chosen image) from a URL saved in
* the database use {@link #dbUrlToImageList(String)}.
*
* @param images the images from which to choose
* @return the chosen preferred image, or {@link null} if the list is empty
* @see #choosePreferredImage(List)
* @see #dbUrlToImageList(String)
*/
@Nullable
public static String imageListToDbUrl(@NonNull final List<Image> images) {
final PreferredImageQuality quality;
if (preferredImageQuality == PreferredImageQuality.NONE) {
quality = PreferredImageQuality.MEDIUM;
} else {
quality = preferredImageQuality;
}
return choosePreferredImage(images, quality);
}
/**
* Wraps the URL (coming from the database) in a {@code List<Image>} so that it is usable
* seamlessly in all of the places where the extractor would return a list of images, including
* allowing to build info objects based on database objects.
* <br>
* To obtain a url to save to the database from a list of images use {@link
* #imageListToDbUrl(List)}.
*
* @param url the URL to wrap coming from the database, or {@code null} to get an empty list
* @return a list containing just one {@link Image} wrapping the provided URL, with unknown
* image size fields, or an empty list if the URL is {@code null}
* @see #imageListToDbUrl(List)
*/
@NonNull
public static List<Image> dbUrlToImageList(@Nullable final String url) {
if (url == null) {
return List.of();
} else {
return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN));
}
}
}

View File

@ -0,0 +1,191 @@
/*
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.util.image
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.Image.ResolutionLevel
import kotlin.math.abs
object ImageStrategy {
// when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred
// image quality is to these values (H stands for "Height")
private const val BEST_LOW_H = 75
private const val BEST_MEDIUM_H = 250
private var preferredImageQuality = PreferredImageQuality.MEDIUM
@JvmStatic
fun setPreferredImageQuality(preferredImageQuality: PreferredImageQuality) {
ImageStrategy.preferredImageQuality = preferredImageQuality
}
@JvmStatic
fun shouldLoadImages(): Boolean {
return preferredImageQuality != PreferredImageQuality.NONE
}
@JvmStatic
fun estimatePixelCount(image: Image, widthOverHeight: Double): Double {
if (image.height == Image.HEIGHT_UNKNOWN) {
if (image.width == Image.WIDTH_UNKNOWN) {
// images whose size is completely unknown will be in their own subgroups, so
// any one of them will do, hence returning the same value for all of them
return 0.0
} else {
return image.width * image.width / widthOverHeight
}
} else if (image.width == Image.WIDTH_UNKNOWN) {
return image.height * image.height * widthOverHeight
} else {
return (image.height * image.width).toDouble()
}
}
/**
* [choosePreferredImage] contains the description for this function's logic.
*
* @param images the images from which to choose
* @param nonNoneQuality the preferred quality (must NOT be [PreferredImageQuality.NONE])
* @return the chosen preferred image, or `null` if the list is empty
* @see [choosePreferredImage]
*/
@JvmStatic
fun choosePreferredImage(images: List<Image>, nonNoneQuality: PreferredImageQuality): String? {
// this will be used to estimate the pixel count for images where only one of height or
// width are known
val widthOverHeight = images
.filter { image ->
image.height != Image.HEIGHT_UNKNOWN && image.width != Image.WIDTH_UNKNOWN
}
.map { image -> (image.width.toDouble()) / image.height }
.elementAtOrNull(0) ?: 1.0
val preferredLevel = nonNoneQuality.toResolutionLevel()
// TODO: rewrite using kotlin collections API `groupBy` will be handy
val initialComparator =
Comparator // the first step splits the images into groups of resolution levels
.comparingInt { i: Image ->
return@comparingInt when (i.estimatedResolutionLevel) {
// avoid unknowns as much as possible
ResolutionLevel.UNKNOWN -> 3
// prefer a matching resolution level
preferredLevel -> 0
// the preferredLevel is only 1 "step" away (either HIGH or LOW)
ResolutionLevel.MEDIUM -> 1
// the preferredLevel is the furthest away possible (2 "steps")
else -> 2
}
}
// then each level's group is further split into two subgroups, one with known image
// size (which is also the preferred subgroup) and the other without
.thenComparing { image -> image.height == Image.HEIGHT_UNKNOWN && image.width == Image.WIDTH_UNKNOWN }
// The third step chooses, within each subgroup with known image size, the best image based
// on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups
// without known image size will be left untouched since estimatePixelCount always returns
// the same number for those.
val finalComparator = when (nonNoneQuality) {
PreferredImageQuality.NONE -> initialComparator
PreferredImageQuality.LOW -> initialComparator.thenComparingDouble { image ->
val pixelCount = estimatePixelCount(image, widthOverHeight)
abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight)
}
PreferredImageQuality.MEDIUM -> initialComparator.thenComparingDouble { image ->
val pixelCount = estimatePixelCount(image, widthOverHeight)
abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight)
}
PreferredImageQuality.HIGH -> initialComparator.thenComparingDouble { image ->
// this is reversed with a - so that the highest resolution is chosen
-estimatePixelCount(image, widthOverHeight)
}
}
return images.stream() // using "min" basically means "take the first group, then take the first subgroup,
// then choose the best image, while ignoring all other groups and subgroups"
.min(finalComparator)
.map(Image::getUrl)
.orElse(null)
}
/**
* Chooses an image amongst the provided list based on the user preference previously set with
* [setPreferredImageQuality]. `null` will be returned in
* case the list is empty or the user preference is to not show images.
* <br>
* These properties will be preferred, from most to least important:
*
* 1. The image's [Image.estimatedResolutionLevel] is not unknown and is close to [preferredImageQuality]
* 2. At least one of the image's width or height are known
* 3. The highest resolution image is finally chosen if the user's preference is
* [PreferredImageQuality.HIGH], otherwise the chosen image is the one that has the height
* closest to [BEST_LOW_H] or [BEST_MEDIUM_H]
*
* <br>
* Use [imageListToDbUrl] if the URL is going to be saved to the database, to avoid
* saving nothing in case at the moment of saving the user preference is to not show images.
*
* @param images the images from which to choose
* @return the chosen preferred image, or `null` if the list is empty or the user disabled
* images
* @see [imageListToDbUrl]
*/
@JvmStatic
fun choosePreferredImage(images: List<Image>): String? {
if (preferredImageQuality == PreferredImageQuality.NONE) {
return null // do not load images
}
return choosePreferredImage(images, preferredImageQuality)
}
/**
* Like [choosePreferredImage], except that if [preferredImageQuality] is
* [PreferredImageQuality.NONE] an image will be chosen anyway (with preferred quality
* [PreferredImageQuality.MEDIUM].
* <br></br>
* To go back to a list of images (obviously with just the one chosen image) from a URL saved in
* the database use [dbUrlToImageList].
*
* @param images the images from which to choose
* @return the chosen preferred image, or `null` if the list is empty
* @see [choosePreferredImage]
* @see [dbUrlToImageList]
*/
@JvmStatic
fun imageListToDbUrl(images: List<Image>): String? {
val quality = when (preferredImageQuality) {
PreferredImageQuality.NONE -> PreferredImageQuality.MEDIUM
else -> preferredImageQuality
}
return choosePreferredImage(images, quality)
}
/**
* Wraps the URL (coming from the database) in a `List<Image>` so that it is usable
* seamlessly in all of the places where the extractor would return a list of images, including
* allowing to build info objects based on database objects.
* <br></br>
* To obtain a url to save to the database from a list of images use [imageListToDbUrl].
*
* @param url the URL to wrap coming from the database, or `null` to get an empty list
* @return a list containing just one [Image] wrapping the provided URL, with unknown
* image size fields, or an empty list if the URL is `null`
* @see [imageListToDbUrl]
*/
@JvmStatic
fun dbUrlToImageList(url: String?): List<Image> {
return when (url) {
null -> listOf()
else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN))
}
}
}

View File

@ -1,39 +0,0 @@
package org.schabi.newpipe.util.image;
import android.content.Context;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.Image;
public enum PreferredImageQuality {
NONE,
LOW,
MEDIUM,
HIGH;
public static PreferredImageQuality fromPreferenceKey(final Context context, final String key) {
if (context.getString(R.string.image_quality_none_key).equals(key)) {
return NONE;
} else if (context.getString(R.string.image_quality_low_key).equals(key)) {
return LOW;
} else if (context.getString(R.string.image_quality_high_key).equals(key)) {
return HIGH;
} else {
return MEDIUM; // default to medium
}
}
public Image.ResolutionLevel toResolutionLevel() {
switch (this) {
case LOW:
return Image.ResolutionLevel.LOW;
case MEDIUM:
return Image.ResolutionLevel.MEDIUM;
case HIGH:
return Image.ResolutionLevel.HIGH;
default:
case NONE:
return Image.ResolutionLevel.UNKNOWN;
}
}
}

View File

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.util.image
import android.content.Context
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Image.ResolutionLevel
enum class PreferredImageQuality {
NONE,
LOW,
MEDIUM,
HIGH;
fun toResolutionLevel(): ResolutionLevel {
return when (this) {
LOW -> ResolutionLevel.LOW
MEDIUM -> ResolutionLevel.MEDIUM
HIGH -> ResolutionLevel.HIGH
NONE -> ResolutionLevel.UNKNOWN
}
}
companion object {
@JvmStatic
fun fromPreferenceKey(context: Context, key: String?): PreferredImageQuality {
return when (key) {
context.getString(R.string.image_quality_none_key) -> NONE
context.getString(R.string.image_quality_low_key) -> LOW
context.getString(R.string.image_quality_high_key) -> HIGH
else -> MEDIUM // default to medium
}
}
}
}

View File

@ -54,30 +54,6 @@ public final class TimestampExtractor {
return new TimestampMatchDTO(timestampStart, timestampEnd, seconds);
}
public static class TimestampMatchDTO {
private final int timestampStart;
private final int timestampEnd;
private final int seconds;
public TimestampMatchDTO(
final int timestampStart,
final int timestampEnd,
final int seconds) {
this.timestampStart = timestampStart;
this.timestampEnd = timestampEnd;
this.seconds = seconds;
}
public int timestampStart() {
return timestampStart;
}
public int timestampEnd() {
return timestampEnd;
}
public int seconds() {
return seconds;
}
public record TimestampMatchDTO(int timestampStart, int timestampEnd, int seconds) {
}
}

View File

@ -1,78 +0,0 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final String descriptionText;
@NonNull
private final CompositeDisposable disposables;
@NonNull
private final StreamingService relatedInfoService;
@NonNull
private final String relatedStreamUrl;
@NonNull
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
TimestampLongPressClickableSpan(
@NonNull final Context context,
@NonNull final String descriptionText,
@NonNull final CompositeDisposable disposables,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
this.context = context;
this.descriptionText = descriptionText;
this.disposables = disposables;
this.relatedInfoService = relatedInfoService;
this.relatedStreamUrl = relatedStreamUrl;
this.timestampMatchDTO = timestampMatchDTO;
}
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds());
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
}
@NonNull
private static String getTimestampTextToCopy(
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final String descriptionText,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
if (relatedInfoService == ServiceList.YouTube) {
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.SoundCloud
|| relatedInfoService == ServiceList.MediaCCC) {
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.PeerTube) {
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
}
// Return timestamp text for other services
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd()).toString();
}
}

View File

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.util.text
import android.content.Context
import android.view.View
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.StreamingService
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO
class TimestampLongPressClickableSpan(
private val context: Context,
private val descriptionText: String,
private val disposables: CompositeDisposable,
private val relatedInfoService: StreamingService,
private val relatedStreamUrl: String,
private val timestampMatchDTO: TimestampMatchDTO
) : LongPressClickableSpan() {
override fun onClick(view: View) {
InternalUrlsHandler.playOnPopup(
context,
relatedStreamUrl,
relatedInfoService,
timestampMatchDTO.seconds()
)
}
override fun onLongClick(view: View) {
ShareUtils.copyToClipboard(
context,
getTimestampTextToCopy(
relatedInfoService,
relatedStreamUrl,
descriptionText,
timestampMatchDTO
)
)
}
companion object {
private fun getTimestampTextToCopy(
relatedInfoService: StreamingService,
relatedStreamUrl: String,
descriptionText: String,
timestampMatchDTO: TimestampMatchDTO
): String {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
when (relatedInfoService) {
ServiceList.YouTube ->
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds()
ServiceList.SoundCloud, ServiceList.MediaCCC ->
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds()
ServiceList.PeerTube ->
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds()
}
// Return timestamp text for other services
return descriptionText.substring(
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd()
)
}
}
}

View File

@ -35,12 +35,12 @@ public class ExpandableSurfaceView extends SurfaceView {
&& resizeMode != RESIZE_MODE_FIT
&& verticalVideo ? maxHeight : baseHeight;
if (height == 0) {
if (width == 0 || height == 0) {
return;
}
final float viewAspectRatio = width / ((float) height);
final float aspectDeformation = videoAspectRatio / viewAspectRatio - 1;
final float aspectDeformation = (videoAspectRatio / viewAspectRatio) - 1;
scaleX = 1.0f;
scaleY = 1.0f;
@ -100,7 +100,7 @@ public class ExpandableSurfaceView extends SurfaceView {
}
public void setAspectRatio(final float aspectRatio) {
if (videoAspectRatio == aspectRatio) {
if (videoAspectRatio == aspectRatio || aspectRatio == 0 || !Float.isFinite(aspectRatio)) {
return;
}

View File

@ -85,6 +85,7 @@ public class DownloadRunnableFallback extends Thread {
if (mMission.unknownLength || mConn.getResponseCode() == 200) {
// restart amount of bytes downloaded
mMission.done = mMission.offsets[mMission.current] - mMission.offsets[0];
start = 0; // reset position to avoid writing at wrong offset
}
mF = mMission.storage.getStream();

View File

@ -117,5 +117,4 @@
<string name="detail_thumbnail_view_description">Lenn ar video, pad:</string>
<string name="what_device_headline">Titouroù:</string>
<string name="video">Video</string>
<string name="streams_notification_channel_description"></string>
</resources>

View File

@ -860,7 +860,7 @@
<string name="account_terminated_service_provides_reason">Compte fermé\n\n%1$s fournit la raison suivante : %2$s</string>
<string name="player_http_403">Erreur HTTP 403 reçue du serveur pendant la lecture, probablement causée par l\'expiration de l\'URL de streaming ou une interdiction d\'IP</string>
<string name="player_http_invalid_status">Erreur HTTP %1$s reçue du serveur pendant la lecture</string>
<string name="youtube_player_http_403">Erreur HTTP 403 reçue du serveur pendant la lecture, probablement causée par une interdiction d\'IP ou des problèmes de désobfuscation d\'URL de streaming.</string>
<string name="youtube_player_http_403">Erreur HTTP 403 reçue du serveur pendant la lecture, probablement causée par un bannissement d\'IP ou des problèmes de désobfuscation de l\'URL de streaming</string>
<string name="sign_in_confirm_not_bot_error">%1$s a refusé de fournir des données et a demandé un identifiant pour confirmer que le demandeur n\'est pas un robot.\n\nVotre adresse IP a peut-être été temporairement bannie par %1$s. Vous pouvez patienter un peu ou changer d\'adresse IP (par exemple en activant/désactivant un VPN, ou en passant du Wi-Fi aux données mobiles).</string>
<string name="unsupported_content_in_country">Ce contenu n\'est pas disponible pour le pays actuellement sélectionné.\n\nModifiez votre sélection dans « Paramètres &gt; Contenu &gt; Pays par défaut ».</string>
</resources>

View File

@ -826,4 +826,8 @@
<string name="no_feed_group_created_yet">ਅਜੇ ਤੱਕ ਕੋਈ ਫੀਡ ਗਰੁੱਪ ਨਹੀਂ ਬਣਾਇਆ ਗਿਆ</string>
<string name="feed_group_page_summary">ਚੈਨਲ ਗਰੁੱਪ ਪੰਨਾ</string>
<string name="channel_tab_likes">ਪਸੰਦ</string>
<string name="delete_file">ਫ਼ਾਈਲ ਮਿਟਾਓ</string>
<string name="delete_entry">ਐਂਟਰੀ ਮਿਟਾਓ</string>
<string name="account_terminated_service_provides_reason">ਖ਼ਾਤਾ ਬੰਦ ਕੀਤਾ ਗਿਆ\n\n%1$s ਇਹ ਕਾਰਨ ਪ੍ਰਦਾਨ ਕਰਦਾ ਹੈ: %2$s</string>
<string name="entry_deleted">ਐਂਟਰੀ ਮਿਟਾ ਦਿੱਤੀ ਗਈ</string>
</resources>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

View File

@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import android.content.Context
@ -9,7 +14,6 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS
import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST
import java.util.stream.Stream
class ExportPlaylistTest {
@ -41,9 +45,7 @@ class ExportPlaylistTest {
*/
val playlist = asPlaylist(
(10..70)
.map { id -> "https://www.youtube.com/watch?v=aaaaaaaaa$id" } // YouTube video IDs are 11 characters long
.stream()
(10..70).map { id -> "https://www.youtube.com/watch?v=aaaaaaaaa$id" } // YouTube video IDs are 11 characters long
)
val url = export(YOUTUBE_TEMP_PLAYLIST, playlist, mock(Context::class.java))
@ -78,13 +80,11 @@ class ExportPlaylistTest {
}
fun asPlaylist(vararg urls: String): List<PlaylistStreamEntry> {
return asPlaylist(Stream.of(*urls))
return asPlaylist(listOf(*urls))
}
fun asPlaylist(urls: Stream<String>): List<PlaylistStreamEntry> {
return urls
.map { url: String -> newPlaylistStreamEntry(url) }
.toList()
fun asPlaylist(urls: List<String>): List<PlaylistStreamEntry> {
return urls.map { newPlaylistStreamEntry(it) }
}
fun newPlaylistStreamEntry(url: String): PlaylistStreamEntry {

View File

@ -0,0 +1,16 @@
# 개선됨
타임스탬프를 클릭할 때 현재 플레이어를 유지합니다.
가능한 경우 보류 중인 다운로드 미션을 복구하세요.
파일 삭제 없이 다운로드를 삭제하는 옵션을 추가하세요.
오버레이 권한: Android > R에 대한 설명 대화 상자 표시
사운드클라우드 링크 열기 지원
많은 작은 개선과 최적화
# 고정
7 이하의 안드로이드 버전에 대한 짧은 숫자 형식을 수정하세요.
고스트 알림 수정
SRT 자막 파일 수정
고정된 수많은 충돌 사고
# 개발
내부 코드 현대화

View File

@ -0,0 +1,16 @@
# Förbättrat
Behåll aktuell spelare när du klickar på tidsstämplar
Försök att återställa väntande nedladdningsuppdrag när det är möjligt
Lägg till alternativ för att ta bort en nedladdning utan att också ta bort filen
Överläggsbehörighet: visa förklarande dialogruta för Android > R
Stöd för att öppna on.soundcloud-länkar
Många små förbättringar och optimeringar
# Åtgärdat
Åtgärdade formatering av korta antal för Android-versioner under 7
Åtgärdade Ghost Notifications
Åtgärdade för SRT-undertextfiler
Åtgärdade massor av krascher
# Utveckling
Intern kodmodernisering

View File

@ -0,0 +1,16 @@
# Покращено
Зберігати поточний програвач при натисканні на часові позначки
Намагатися відновлювати місії, що очікують завантаження, коли це можливо
Додано опцію видалення завантаження без одночасного видалення файлу
Дозвіл на накладання: відображення пояснювального діалогового вікна для Android > R
Підтримка відкриття посилання на .soundcloud
Багато дрібних покращень та оптимізацій
# Виправлено
Виправлено форматування короткого лічильника для версій Android нижче 7
Виправлено сповіщення-примари
Виправлення для файлів субтитрів SRT
Виправлено безліч збоїв
# Розробка
Модернізація внутрішнього коду

View File

@ -36,7 +36,7 @@ lazy-column-scrollbar = "2.2.0"
leakcanary = "2.14"
lifecycle = "2.9.4" # Newer versions require minSdk >= 23
markwon = "4.6.2"
material = "1.13.0"
material = "1.11.0" # TODO: update to newer version after bug is fixed. See https://github.com/TeamNewPipe/NewPipe/pull/13018
media = "1.7.1"
mockitoCore = "5.21.0"
navigation-compose = "2.8.3"