diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt similarity index 86% rename from app/src/main/java/org/schabi/newpipe/error/UserAction.java rename to app/src/main/java/org/schabi/newpipe/error/UserAction.kt index d3af9d32e1a..2d2358310d7 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt @@ -1,9 +1,14 @@ -package org.schabi.newpipe.error; +/* + * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors + * 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; - } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java deleted file mode 100644 index 83f68dbb571..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java +++ /dev/null @@ -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 + "]"; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt new file mode 100644 index 00000000000..1317f9acb9e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors + * 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]" +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java deleted file mode 100644 index 6a330be0fe4..00000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java +++ /dev/null @@ -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 { - 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 { - @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 - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt new file mode 100644 index 00000000000..4eb4c1574d1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors + * 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(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() { + 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 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt similarity index 65% rename from app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java rename to app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt index 447c540a0cd..703191bb96d 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt @@ -1,9 +1,14 @@ -package org.schabi.newpipe.info_list; +/* + * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors + * 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. */ diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.java b/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.java deleted file mode 100644 index f0433aba8c3..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -public enum PlayListShareMode { - - JUST_URLS, - WITH_TITLES, - YOUTUBE_TEMP_PLAYLIST -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt new file mode 100644 index 00000000000..5595ce7fa47 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2025 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.local.playlist + +enum class PlayListShareMode { + JUST_URLS, + WITH_TITLES, + YOUTUBE_TEMP_PLAYLIST +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java deleted file mode 100644 index 08b203a7e32..00000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ /dev/null @@ -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> getPlaylists() { - return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); - } - - public Flowable getPlaylist(final long playlistId) { - return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylist(final PlaylistInfo info) { - return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) - .subscribeOn(Schedulers.io()); - } - - public Single deletePlaylist(final long playlistId) { - return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId)) - .subscribeOn(Schedulers.io()); - } - - public Completable updatePlaylists(final List updateItems, - final List 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 onBookmark(final PlaylistInfo playlistInfo) { - return Single.fromCallable(() -> { - final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); - return playlistRemoteTable.upsert(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Single 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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt new file mode 100644 index 00000000000..6961b6bb46b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors + * 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> + get() = playlistRemoteTable.playlists.subscribeOn(Schedulers.io()) + + fun getPlaylist(playlistId: Long): Flowable { + return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()) + } + + fun getPlaylist(info: PlaylistInfo): Flowable> { + return playlistRemoteTable.getPlaylist(info.serviceId.toLong(), info.url) + .subscribeOn(Schedulers.io()) + } + + fun deletePlaylist(playlistId: Long): Single { + return Single.fromCallable { playlistRemoteTable.deletePlaylist(playlistId) } + .subscribeOn(Schedulers.io()) + } + + fun updatePlaylists( + updateItems: List, + deletedItems: List + ): Completable { + return Completable.fromRunnable { + database.runInTransaction { + deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) } + updateItems.forEach { playlistRemoteTable.upsert(it) } + } + }.subscribeOn(Schedulers.io()) + } + + fun onBookmark(playlistInfo: PlaylistInfo): Single { + return Single.fromCallable { + val playlist = PlaylistRemoteEntity(playlistInfo) + playlistRemoteTable.upsert(playlist) + }.subscribeOn(Schedulers.io()) + } + + fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single { + return Single.fromCallable { + val playlist = PlaylistRemoteEntity(playlistInfo).apply { uid = playlistId } + playlistRemoteTable.update(playlist) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java deleted file mode 100644 index f74389d79c8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.player; - -public enum PlayerType { - MAIN, - AUDIO, - POPUP; -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt new file mode 100644 index 00000000000..42b2e113139 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.player + +enum class PlayerType { + MAIN, + AUDIO, + POPUP +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 88d7145bceb..5cffc7f62f4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -17,10 +17,10 @@ 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; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java index 97196805dfb..d430faf0f66 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java @@ -4,15 +4,14 @@ import androidx.annotation.Nullable; import org.schabi.newpipe.MainActivity; -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.ArrayList; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java index dd95fb4d509..2e19672e56d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java @@ -10,12 +10,11 @@ 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; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt new file mode 100644 index 00000000000..f1952ef9504 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2017-2026 NewPipe contributors + * 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 } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java deleted file mode 100644 index cc922dbb1c8..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java deleted file mode 100644 index 7b7e392126d..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java deleted file mode 100644 index 559975b3514..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class InitEvent implements PlayQueueEvent { - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.INIT; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java deleted file mode 100644 index 55d1989237f..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java deleted file mode 100644 index 431053e7be4..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -import java.io.Serializable; - -public interface PlayQueueEvent extends Serializable { - PlayQueueEventType type(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java deleted file mode 100644 index 1cc710c7b4c..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java +++ /dev/null @@ -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 -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java deleted file mode 100644 index 6f21b36cd84..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java deleted file mode 100644 index a5872906d9b..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java deleted file mode 100644 index 4f4f147562e..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java deleted file mode 100644 index 95e34421104..00000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java deleted file mode 100644 index 33856326cb7..00000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java +++ /dev/null @@ -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 getAllRelevantSearchFields() { - return List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs()); - } - - @NonNull - @Override - public String toString() { - return "PreferenceItem: " + title + " " + summary + " " + key; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt new file mode 100644 index 00000000000..750e40eae03 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2022-2025 NewPipe contributors + * 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 { + return mutableListOf(title, summary, entries, breadcrumbs) + } + + override fun toString(): String { + return "PreferenceItem: $title $summary $key" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java deleted file mode 100644 index 1f063645431..00000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import androidx.annotation.NonNull; - -public interface PreferenceSearchResultListener { - void onSearchResultClicked(@NonNull PreferenceSearchItem result); -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt new file mode 100644 index 00000000000..7b7b7884aef --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.schabi.newpipe.settings.preferencesearch + +interface PreferenceSearchResultListener { + fun onSearchResultClicked(result: PreferenceSearchItem) +} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java deleted file mode 100644 index bc15f3f0242..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt new file mode 100644 index 00000000000..bfa50beef35 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors + * 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java deleted file mode 100644 index da97179b640..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java +++ /dev/null @@ -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 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 initialComparator = Comparator - // the first step splits the images into groups of resolution levels - .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 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. - *
- * These properties will be preferred, from most to least important: - *
    - *
  1. The image's {@link Image#getEstimatedResolutionLevel()} is not unknown and is close - * to {@link #preferredImageQuality}
  2. - *
  3. At least one of the image's width or height are known
  4. - *
  5. 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}
  6. - *
- *
- * 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 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}. - *
- * 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 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} 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. - *
- * 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 dbUrlToImageList(@Nullable final String url) { - if (url == null) { - return List.of(); - } else { - return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt new file mode 100644 index 00000000000..aa59b4d0add --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors + * 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, 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. + *
+ * 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] + * + *
+ * 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): 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]. + *

+ * 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): 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` 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. + *

+ * 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 { + return when (url) { + null -> listOf() + else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN)) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java deleted file mode 100644 index 7106359b36e..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java +++ /dev/null @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt new file mode 100644 index 00000000000..b90ba87aad7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors + * 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 + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java index be603f41aa5..b1357b94364 100644 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java @@ -54,30 +54,6 @@ public static TimestampMatchDTO getTimestampFromMatcher( 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) { } } diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java deleted file mode 100644 index 35a9fd996bb..00000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt new file mode 100644 index 00000000000..a76c5c31aa6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors + * 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() + ) + } + } +}