1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-01-14 02:32:40 +00:00

Compare commits

...

42 Commits
v0.28.1 ... dev

Author SHA1 Message Date
Tobi
2f063a78ba Merge pull request #13052 from RinZ27/fix/ci-shell-injection
ci: fix shell injection in backport workflow
2026-01-13 06:36:01 -08:00
RinCodeForge927
7dc38286c0 ci: fix shell injection in backport workflow 2026-01-13 20:42:10 +07:00
Tobi
77d62deeed Merge pull request #13049 from dustdfg/playlist_bug
Fix playlist item dragging video to only neighbor positions
2026-01-12 12:17:47 -08:00
Yevhen Babiichuk (DustDFG)
914feef5e9 Fix playlist item dragging video to only neighbor positions
Call `saveImmediate` only after used actually dropped item instead
of every time View is updated which happens several times to show
user a feedback where item would be moved
2026-01-12 20:35:14 +02:00
TobiGr
4ed2b9748f Merge branch 'master' into dev 2026-01-11 22:53:11 +01:00
Hosted Weblate
8e389c49e6 Translated using Weblate (Polish)
Currently translated at 56.3% (49 of 87 strings)

Translated using Weblate (French)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pl/
Translation: NewPipe/Metadata
2026-01-11 15:01:07 +01:00
tobigr
9ba89d418b Remove script for fastlane check 2026-01-09 21:26:03 +01:00
tobigr
27b8a72f19 Fixed length of changelogs 2026-01-09 21:13:57 +01:00
Hosted Weblate
df92431ee3 Translated using Weblate (Bengali (India))
Currently translated at 39.2% (300 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 0.3% (3 of 764 strings)

Translated using Weblate (French)

Currently translated at 75.8% (66 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Tigrinya)

Currently translated at 21.8% (167 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.0% (757 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 3.4% (3 of 87 strings)

Translated using Weblate (Santali)

Currently translated at 1.1% (1 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 78.1% (68 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 68.9% (60 of 87 strings)

Co-authored-by: 135 <135135@users.noreply.hosted.weblate.org>
Co-authored-by: Balázs Meskó <meskobalazs@mailbox.org>
Co-authored-by: Ding User <dengus@users.noreply.hosted.weblate.org>
Co-authored-by: Dizro <weblate.delirium794@passmail.net>
Co-authored-by: Erenay <erenaydev@proton.me>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kuko <kuko7@protonmail.ch>
Co-authored-by: MatthieuPh <matthieu.philippe@protonmail.com>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Prasanta-Hembram <Prasantahembram720@gmail.com>
Co-authored-by: STV <steeven.lombardi@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Sumon Kayal <sumankayalsuman4@gmail.com>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/bg/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hi/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pa/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sat/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translation: NewPipe/Metadata
2026-01-09 20:56:42 +01:00
Tobi
cafb1398cb Merge pull request #12995 from siddhesh-06/siddhesh-06/fix/crash-on-soundcloud-import
Fix crash on screen rotation while entering SoundCloud import URL
2026-01-09 10:44:03 -08:00
Tobi
694124814e Merge pull request #13014 from jloutsch/fix/download-resume-corruption
Fix download resume corruption when server returns HTTP 200
2026-01-08 19:05:49 -08:00
Tobi
e61bc012d9 Merge pull request #12990 from dustdfg/db_migration_kotlin
Use "factory" method for creating db migrations
2026-01-08 17:23:12 -08:00
Tobi
d36a9f01d3 Add workflow to backport PRs to another branch (#12964)
The workflow can be triggered by creating a comment on a merged PR: /backport <TARGET_BRANCH>
The backport can only be triggered by people with write access to the repository.

Co-authored-by: AbsurdlyLongUsername <22662897+absurdlylongusername@users.noreply.github.com>
2026-01-08 17:06:50 -08:00
Siddhesh Dhainje
418e34172a Removed restoreInstanceState and resultServiceIntent condition 2026-01-08 21:31:31 +05:30
Clippy
2704c20fea Merge pull request #13020 from absurdlylongusername/add-note-to-material-component
Add note to upgrade material components once they fix later versions
2026-01-07 23:31:17 +00:00
AbsurdlyLongUsername
a7e4afe7f7 Add note to upgrade material components once they fix later versions 2026-01-07 21:49:42 +00:00
Tobi
6dba8b3c44 Merge pull request #13018 from absurdlylongusername/revert-google-material-components
Revert Google Material Components to 1.11.0
2026-01-07 03:32:30 -08:00
AbsurdlyLongUsername
20b43b521b Revert Google Material Components to 1.11.0 2026-01-07 07:48:32 +00:00
Justin L
08008ca6f9 Fix download resume corruption when server returns HTTP 200
When resuming a download after interruption, if the server returns
HTTP 200 (full resource) instead of HTTP 206 (partial content), the
code correctly resets mMission.done but fails to reset the 'start'
variable. This causes the subsequent file seek to use a stale offset,
writing new data at incorrect positions.

This bug causes file corruption for large downloads (>5GB) that are
interrupted and resumed, particularly when:
- Switching between WiFi networks
- Server CDN returning different responses
- Connection drops during long downloads

The corruption manifests as duplicate data regions in the file,
which for MP4 downloads results in multiple MOOV atoms and
broken seek functionality.

Fix: Reset start=0 when HTTP 200 is received, ensuring the file
write position correctly restarts from the beginning of the current
resource.
2026-01-06 09:03:44 -05:00
Tobi
25ea75f10e Merge pull request #13005 from dustdfg/db_immediate
Commit all the playlist changes to db immediately
2026-01-05 14:03:30 -08:00
Yevhen Babiichuk (DustDFG)
61c0d134d7 Commit all the playlist changes to db immediately
+ some additional minor code cleanup in the file
2026-01-05 22:59:14 +02:00
Siddhesh Dhainje
a3673f8c3b Used requireArguments instead of getArguments 2026-01-04 21:40:40 +05:30
Tobi
fc66bee429 Merge pull request #13000 from dustdfg/orphaned_history_entry_adapter
Delete long orphaned file
2026-01-04 04:26:03 -08:00
Yevhen Babiichuk (DustDFG)
35eb08baf0 Delete long orphaned file
Was oprhaned at 004c2fa55a
2026-01-04 13:53:43 +02:00
Siddhesh Dhainje
23b7f21d7c Fix crash on screen rotation while entering SoundCloud import URL 2026-01-04 01:06:18 +05:30
Yevhen Babiichuk (DustDFG)
0747b3a0a5 Use "factory" method for creating db migrations 2026-01-02 12:25:25 +02:00
Aayush Gupta
7283701073 Merge pull request #12978 from dustdfg/kotlin_merged
Conversion to kotlin of multiple files
2026-01-02 15:46:29 +08:00
Yevhen Babiichuk (DustDFG)
3ffcf11a3a Merge inheritors of newpipe/player/playqueue/PlayQueueEvent and
convert it to kotlin
2026-01-01 23:05:16 +02:00
Aayush Gupta
1fb2b4a42e Merge pull request #12981 from dustdfg/export_playlist_refactor
Refactor ExportPlaylist to use more idiomatic kotlin code
2026-01-01 21:40:05 +08:00
Yevhen Babiichuk (DustDFG)
83596ca907 Convert newpipe/settings/preferencesearch/PreferenceSearchResultListener to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
d9682f5e0a Convert newpipe/player/PlayerType to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
ab3314eb1c Convert newpipe/info_list/ItemViewMode to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
7d1d88fb87 Convert newpipe/local/playlist/PlayListShareMode to kotlin 2026-01-01 15:08:21 +02:00
Yevhen Babiichuk (DustDFG)
8379aa0a9d Refactor ExportPlaylist to use more idiomatic kotlin code 2026-01-01 15:03:29 +02:00
Yevhen Babiichuk (DustDFG)
cd4cb40e6d Convert newpipe/error/UserAction.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
f1b111212d Convert newpipe/util/FilenameUtils.java to kotlin
Co-authored-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
84c646713d Convert newpipe/settings/preferencesearch/PreferenceSearchItem.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
873b2be9ca Convert newpipe/util/text/TimestampLongPressClickableSpan.java to kotlin
Also convert one class used by it into java record
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
4ef4ed15f1 Convert newpipe/util/image/PreferredImageQuality to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
fef8a2455c Convert newpipe/util/image/ImageStrategy to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
3398b4cdc9 Convert newpipe/fragments/list/search/Suggestion{Item,ListAdapter} to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
74cf302bd6 Convert newpipe/local/playlist/RemotePlaylistManager to kotlin 2026-01-01 11:04:19 +02:00
46 changed files with 1049 additions and 1408 deletions

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

@@ -0,0 +1,48 @@
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`
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
body="$COMMENT_BODY"
line=${body%%$'\n'*} # Get the first line
if [[ $line =~ ^/backport[[:space:]]+([^[:space:]]+) ]]; then
echo "BACKPORT_TARGET=${BASH_REMATCH[1]}" >> "$GITHUB_ENV"
else
echo "Usage: /backport <target-branch>" >&2
exit 1
fi
- name: Create backport pull request
uses: korthout/backport-action@v4
with:
add_labels: 'backport'
copy_labels_pattern: '.*'
label_pattern: ''
target_branches: ${{ env.BACKPORT_TARGET }}

View File

@@ -34,335 +34,319 @@ object Migrations {
private val TAG = Migrations::class.java.getName()
private val isDebug = MainActivity.DEBUG
val MIGRATION_1_2 = object : Migration(DB_VER_1, DB_VER_2) {
override fun migrate(db: SupportSQLiteDatabase) {
if (isDebug) {
Log.d(TAG, "Start migrating database")
}
val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { db ->
if (isDebug) {
Log.d(TAG, "Start migrating database")
}
/*
* Unfortunately these queries must be hardcoded due to the possibility of
* schema and names changing at a later date, thus invalidating the older migration
* scripts if they are not hardcoded.
* */
/*
* Unfortunately these queries must be hardcoded due to the possibility of
* schema and names changing at a later date, thus invalidating the older migration
* scripts if they are not hardcoded.
* */
// Not much we can do about this, since room doesn't create tables before migration.
// It's either this or blasting the entire database anew.
// Not much we can do about this, since room doesn't create tables before migration.
// It's either this or blasting the entire database anew.
db.execSQL(
"CREATE INDEX `index_search_history_search` " +
"ON `search_history` (`search`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `streams` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
"`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
"`thumbnail_url` TEXT)"
)
db.execSQL(
"CREATE UNIQUE INDEX `index_streams_service_id_url` " +
"ON `streams` (`service_id`, `url`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `stream_history` " +
"(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
"`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX `index_stream_history_stream_id` " +
"ON `stream_history` (`stream_id`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `stream_state` " +
"(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
"PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
"REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlists` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`name` TEXT, `thumbnail_url` TEXT)"
)
db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
"(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
"`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
"FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL(
"CREATE UNIQUE INDEX " +
"`index_playlist_stream_join_playlist_id_join_index` " +
"ON `playlist_stream_join` (`playlist_id`, `join_index`)"
)
db.execSQL(
"CREATE INDEX `index_playlist_stream_join_stream_id` " +
"ON `playlist_stream_join` (`stream_id`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `remote_playlists` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
"`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
)
db.execSQL(
"CREATE INDEX `index_remote_playlists_name` " +
"ON `remote_playlists` (`name`)"
)
db.execSQL(
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
"ON `remote_playlists` (`service_id`, `url`)"
)
// Populate streams table with existing entries in watch history
// Latest data first, thus ignoring older entries with the same indices
db.execSQL(
"INSERT OR IGNORE INTO streams (service_id, url, title, " +
"stream_type, duration, uploader, thumbnail_url) " +
"SELECT service_id, url, title, 'VIDEO_STREAM', duration, " +
"uploader, thumbnail_url " +
"FROM watch_history " +
"ORDER BY creation_date DESC"
)
// Once the streams have PKs, join them with the normalized history table
// and populate it with the remaining data from watch history
db.execSQL(
"INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
"SELECT uid, creation_date, 1 " +
"FROM watch_history INNER JOIN streams " +
"ON watch_history.service_id == streams.service_id " +
"AND watch_history.url == streams.url " +
"ORDER BY creation_date DESC"
)
db.execSQL("DROP TABLE IF EXISTS watch_history")
if (isDebug) {
Log.d(TAG, "Stop migrating database")
}
}
val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { db ->
// Add NOT NULLs and new fields
db.execSQL(
"CREATE TABLE IF NOT EXISTS streams_new " +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
"stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " +
"uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " +
"textual_upload_date TEXT, upload_date INTEGER, " +
"is_upload_date_approximation INTEGER)"
)
db.execSQL(
"INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
"duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
"upload_date, is_upload_date_approximation) " +
"SELECT uid, service_id, url, ifnull(title, ''), " +
"ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " +
"ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " +
"FROM streams WHERE url IS NOT NULL"
)
db.execSQL("DROP TABLE streams")
db.execSQL("ALTER TABLE streams_new RENAME TO streams")
db.execSQL(
"CREATE UNIQUE INDEX index_streams_service_id_url " +
"ON streams (service_id, url)"
)
// Tables for feed feature
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed " +
"(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
"PRIMARY KEY(stream_id, subscription_id), " +
"FOREIGN KEY(stream_id) REFERENCES streams(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_group " +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
"icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
)
db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
"(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
"PRIMARY KEY(group_id, subscription_id), " +
"FOREIGN KEY(group_id) REFERENCES feed_group(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL(
"CREATE INDEX index_feed_group_subscription_join_subscription_id " +
"ON feed_group_subscription_join (subscription_id)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_last_updated " +
"(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
"PRIMARY KEY(subscription_id), " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
}
val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { db ->
db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
}
val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db ->
db.execSQL(
"ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db ->
db.execSQL(
"ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db ->
// Create a new column thumbnail_stream_id
db.execSQL(
"ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
"INTEGER NOT NULL DEFAULT -1"
)
// Migrate the thumbnail_url to the thumbnail_stream_id
db.execSQL(
"UPDATE playlists SET thumbnail_stream_id = (" +
" SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
" FROM (" +
" SELECT p.uid AS playlist_uid, s.uid AS stream_uid" +
" FROM playlists p" +
" LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" +
" LEFT JOIN streams s ON s.uid = ps.stream_id" +
" WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" +
" WHERE playlist_uid = playlists.uid)"
)
// Remove the thumbnail_url field in the playlist table
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlists_new`" +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"name TEXT, " +
"is_thumbnail_permanent INTEGER NOT NULL, " +
"thumbnail_stream_id INTEGER NOT NULL)"
)
db.execSQL(
"INSERT INTO playlists_new" +
" SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
" FROM playlists"
)
db.execSQL("DROP TABLE playlists")
db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
db.execSQL(
"CREATE INDEX IF NOT EXISTS " +
"`index_playlists_name` ON `playlists` (`name`)"
)
}
val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { db ->
db.execSQL(
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
"MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
)
db.execSQL("UPDATE search_history SET search = trim(search)")
}
val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { db ->
try {
db.beginTransaction()
// Update playlists.
// Create a temp table to initialize display_index.
db.execSQL(
"CREATE INDEX `index_search_history_search` " +
"ON `search_history` (`search`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `streams` " +
"CREATE TABLE `playlists_tmp` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
"`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
"`thumbnail_url` TEXT)"
"`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
"`thumbnail_stream_id` INTEGER NOT NULL, " +
"`display_index` INTEGER NOT NULL)"
)
db.execSQL(
"CREATE UNIQUE INDEX `index_streams_service_id_url` " +
"ON `streams` (`service_id`, `url`)"
"INSERT INTO `playlists_tmp` " +
"(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
"`display_index`) " +
"SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
"-1 " +
"FROM `playlists`"
)
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
db.execSQL("DROP TABLE `playlists`")
db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
// Update remote_playlists.
// Create a temp table to initialize display_index.
db.execSQL(
"CREATE TABLE IF NOT EXISTS `stream_history` " +
"(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
"`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX `index_stream_history_stream_id` " +
"ON `stream_history` (`stream_id`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `stream_state` " +
"(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
"PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
"REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlists` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`name` TEXT, `thumbnail_url` TEXT)"
)
db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
"(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
"`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
"FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL(
"CREATE UNIQUE INDEX " +
"`index_playlist_stream_join_playlist_id_join_index` " +
"ON `playlist_stream_join` (`playlist_id`, `join_index`)"
)
db.execSQL(
"CREATE INDEX `index_playlist_stream_join_stream_id` " +
"ON `playlist_stream_join` (`stream_id`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `remote_playlists` " +
"CREATE TABLE `remote_playlists_tmp` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
"`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
"`thumbnail_url` TEXT, `uploader` TEXT, " +
"`display_index` INTEGER NOT NULL," +
"`stream_count` INTEGER)"
)
db.execSQL(
"CREATE INDEX `index_remote_playlists_name` " +
"ON `remote_playlists` (`name`)"
"INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
"`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
"`stream_count`)" +
"SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
"-1, `stream_count` FROM `remote_playlists`"
)
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
db.execSQL("DROP TABLE `remote_playlists`")
db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
// Create index on the new table.
db.execSQL(
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
"ON `remote_playlists` (`service_id`, `url`)"
)
// Populate streams table with existing entries in watch history
// Latest data first, thus ignoring older entries with the same indices
db.execSQL(
"INSERT OR IGNORE INTO streams (service_id, url, title, " +
"stream_type, duration, uploader, thumbnail_url) " +
"SELECT service_id, url, title, 'VIDEO_STREAM', duration, " +
"uploader, thumbnail_url " +
"FROM watch_history " +
"ORDER BY creation_date DESC"
)
// Once the streams have PKs, join them with the normalized history table
// and populate it with the remaining data from watch history
db.execSQL(
"INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
"SELECT uid, creation_date, 1 " +
"FROM watch_history INNER JOIN streams " +
"ON watch_history.service_id == streams.service_id " +
"AND watch_history.url == streams.url " +
"ORDER BY creation_date DESC"
)
db.execSQL("DROP TABLE IF EXISTS watch_history")
if (isDebug) {
Log.d(TAG, "Stop migrating database")
}
}
}
val MIGRATION_2_3 = object : Migration(DB_VER_2, DB_VER_3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add NOT NULLs and new fields
db.execSQL(
"CREATE TABLE IF NOT EXISTS streams_new " +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
"stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " +
"uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " +
"textual_upload_date TEXT, upload_date INTEGER, " +
"is_upload_date_approximation INTEGER)"
)
db.execSQL(
"INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
"duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
"upload_date, is_upload_date_approximation) " +
"SELECT uid, service_id, url, ifnull(title, ''), " +
"ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " +
"ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " +
"FROM streams WHERE url IS NOT NULL"
)
db.execSQL("DROP TABLE streams")
db.execSQL("ALTER TABLE streams_new RENAME TO streams")
db.execSQL(
"CREATE UNIQUE INDEX index_streams_service_id_url " +
"ON streams (service_id, url)"
)
// Tables for feed feature
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed " +
"(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
"PRIMARY KEY(stream_id, subscription_id), " +
"FOREIGN KEY(stream_id) REFERENCES streams(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_group " +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
"icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
)
db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
"(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
"PRIMARY KEY(group_id, subscription_id), " +
"FOREIGN KEY(group_id) REFERENCES feed_group(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
db.execSQL(
"CREATE INDEX index_feed_group_subscription_join_subscription_id " +
"ON feed_group_subscription_join (subscription_id)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS feed_last_updated " +
"(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
"PRIMARY KEY(subscription_id), " +
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
)
}
}
val MIGRATION_3_4 = object : Migration(DB_VER_3, DB_VER_4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
}
}
val MIGRATION_4_5 = object : Migration(DB_VER_4, DB_VER_5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
}
val MIGRATION_5_6 = object : Migration(DB_VER_5, DB_VER_6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
"INTEGER NOT NULL DEFAULT 0"
)
}
}
val MIGRATION_6_7 = object : Migration(DB_VER_6, DB_VER_7) {
override fun migrate(db: SupportSQLiteDatabase) {
// Create a new column thumbnail_stream_id
db.execSQL(
"ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
"INTEGER NOT NULL DEFAULT -1"
)
// Migrate the thumbnail_url to the thumbnail_stream_id
db.execSQL(
"UPDATE playlists SET thumbnail_stream_id = (" +
" SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
" FROM (" +
" SELECT p.uid AS playlist_uid, s.uid AS stream_uid" +
" FROM playlists p" +
" LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" +
" LEFT JOIN streams s ON s.uid = ps.stream_id" +
" WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" +
" WHERE playlist_uid = playlists.uid)"
)
// Remove the thumbnail_url field in the playlist table
db.execSQL(
"CREATE TABLE IF NOT EXISTS `playlists_new`" +
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"name TEXT, " +
"is_thumbnail_permanent INTEGER NOT NULL, " +
"thumbnail_stream_id INTEGER NOT NULL)"
)
db.execSQL(
"INSERT INTO playlists_new" +
" SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
" FROM playlists"
)
db.execSQL("DROP TABLE playlists")
db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
db.execSQL(
"CREATE INDEX IF NOT EXISTS " +
"`index_playlists_name` ON `playlists` (`name`)"
)
}
}
val MIGRATION_7_8 = object : Migration(DB_VER_7, DB_VER_8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
"MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
)
db.execSQL("UPDATE search_history SET search = trim(search)")
}
}
val MIGRATION_8_9 = object : Migration(DB_VER_8, DB_VER_9) {
override fun migrate(db: SupportSQLiteDatabase) {
try {
db.beginTransaction()
// Update playlists.
// Create a temp table to initialize display_index.
db.execSQL(
"CREATE TABLE `playlists_tmp` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
"`thumbnail_stream_id` INTEGER NOT NULL, " +
"`display_index` INTEGER NOT NULL)"
)
db.execSQL(
"INSERT INTO `playlists_tmp` " +
"(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
"`display_index`) " +
"SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
"-1 " +
"FROM `playlists`"
)
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
db.execSQL("DROP TABLE `playlists`")
db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
// Update remote_playlists.
// Create a temp table to initialize display_index.
db.execSQL(
"CREATE TABLE `remote_playlists_tmp` " +
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
"`thumbnail_url` TEXT, `uploader` TEXT, " +
"`display_index` INTEGER NOT NULL," +
"`stream_count` INTEGER)"
)
db.execSQL(
"INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
"`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
"`stream_count`)" +
"SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
"-1, `stream_count` FROM `remote_playlists`"
)
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
db.execSQL("DROP TABLE `remote_playlists`")
db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
// Create index on the new table.
db.execSQL(
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
"ON `remote_playlists` (`service_id`, `url`)"
)
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -768,11 +768,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
debounceSaver.setHasChangesToSave();
saveImmediate();
}
return isSwapped;
}
@Override
public void clearView(@NonNull final RecyclerView recyclerView,
@NonNull final RecyclerView.ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
saveImmediate();
}
@Override
public boolean isLongPressDragEnabled() {
return false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,15 +4,14 @@ import androidx.annotation.NonNull;
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ leakcanary = "2.14"
lifecycle = "2.9.4" # Newer versions require minSdk >= 23
localbroadcastmanager = "1.1.0"
markwon = "4.6.2"
material = "1.11.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"
okhttp = "5.3.2"