Merge branch 'dev' into refactor
This commit is contained in:
commit
f5245eac91
46
.github/workflows/backport-pr.yml
vendored
Normal file
46
.github/workflows/backport-pr.yml
vendored
Normal 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 }}
|
||||
6
.github/workflows/build-release-apk.yml
vendored
6
.github/workflows/build-release-apk.yml
vendored
@ -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
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
6
.github/workflows/image-minimizer.yml
vendored
6
.github/workflows/image-minimizer.yml
vendored
@ -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
@ -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")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 + "]";
|
||||
}
|
||||
}
|
||||
@ -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]"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
*/
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
public enum PlayListShareMode {
|
||||
|
||||
JUST_URLS,
|
||||
WITH_TITLES,
|
||||
YOUTUBE_TEMP_PLAYLIST
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
public enum PlayerType {
|
||||
MAIN,
|
||||
AUDIO,
|
||||
POPUP;
|
||||
}
|
||||
12
app/src/main/java/org/schabi/newpipe/player/PlayerType.kt
Normal file
12
app/src/main/java/org/schabi/newpipe/player/PlayerType.kt
Normal 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
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class InitEvent implements PlayQueueEvent {
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.INIT;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public interface PlayQueueEvent extends Serializable {
|
||||
PlayQueueEventType type();
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface PreferenceSearchResultListener {
|
||||
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
64
app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt
Normal file
64
app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
Normal file
191
app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 > Contenu > Pays par défaut ».</string>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
|
||||
3
app/src/main/res/values-rom/strings.xml
Normal file
3
app/src/main/res/values-rom/strings.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
@ -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 {
|
||||
|
||||
16
fastlane/metadata/android/ko/changelogs/1006.txt
Normal file
16
fastlane/metadata/android/ko/changelogs/1006.txt
Normal file
@ -0,0 +1,16 @@
|
||||
# 개선됨
|
||||
타임스탬프를 클릭할 때 현재 플레이어를 유지합니다.
|
||||
가능한 경우 보류 중인 다운로드 미션을 복구하세요.
|
||||
파일 삭제 없이 다운로드를 삭제하는 옵션을 추가하세요.
|
||||
오버레이 권한: Android > R에 대한 설명 대화 상자 표시
|
||||
사운드클라우드 링크 열기 지원
|
||||
많은 작은 개선과 최적화
|
||||
|
||||
# 고정
|
||||
7 이하의 안드로이드 버전에 대한 짧은 숫자 형식을 수정하세요.
|
||||
고스트 알림 수정
|
||||
SRT 자막 파일 수정
|
||||
고정된 수많은 충돌 사고
|
||||
|
||||
# 개발
|
||||
내부 코드 현대화
|
||||
16
fastlane/metadata/android/sv/changelogs/1006.txt
Normal file
16
fastlane/metadata/android/sv/changelogs/1006.txt
Normal 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
|
||||
16
fastlane/metadata/android/uk/changelogs/1006.txt
Normal file
16
fastlane/metadata/android/uk/changelogs/1006.txt
Normal file
@ -0,0 +1,16 @@
|
||||
# Покращено
|
||||
Зберігати поточний програвач при натисканні на часові позначки
|
||||
Намагатися відновлювати місії, що очікують завантаження, коли це можливо
|
||||
Додано опцію видалення завантаження без одночасного видалення файлу
|
||||
Дозвіл на накладання: відображення пояснювального діалогового вікна для Android > R
|
||||
Підтримка відкриття посилання на .soundcloud
|
||||
Багато дрібних покращень та оптимізацій
|
||||
|
||||
# Виправлено
|
||||
Виправлено форматування короткого лічильника для версій Android нижче 7
|
||||
Виправлено сповіщення-примари
|
||||
Виправлення для файлів субтитрів SRT
|
||||
Виправлено безліч збоїв
|
||||
|
||||
# Розробка
|
||||
Модернізація внутрішнього коду
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user