mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-01-14 02:32:40 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f063a78ba | ||
|
|
7dc38286c0 | ||
|
|
77d62deeed | ||
|
|
914feef5e9 | ||
|
|
4ed2b9748f | ||
|
|
8e389c49e6 | ||
|
|
9ba89d418b | ||
|
|
27b8a72f19 | ||
|
|
df92431ee3 | ||
|
|
cafb1398cb | ||
|
|
694124814e | ||
|
|
e61bc012d9 | ||
|
|
d36a9f01d3 | ||
|
|
418e34172a | ||
|
|
2704c20fea | ||
|
|
a7e4afe7f7 | ||
|
|
6dba8b3c44 | ||
|
|
20b43b521b | ||
|
|
08008ca6f9 | ||
|
|
25ea75f10e | ||
|
|
61c0d134d7 | ||
|
|
a3673f8c3b | ||
|
|
fc66bee429 | ||
|
|
35eb08baf0 | ||
|
|
23b7f21d7c | ||
|
|
0747b3a0a5 | ||
|
|
7283701073 | ||
|
|
3ffcf11a3a | ||
|
|
1fb2b4a42e | ||
|
|
83596ca907 | ||
|
|
d9682f5e0a | ||
|
|
ab3314eb1c | ||
|
|
7d1d88fb87 | ||
|
|
8379aa0a9d | ||
|
|
cd4cb40e6d | ||
|
|
f1b111212d | ||
|
|
84c646713d | ||
|
|
873b2be9ca | ||
|
|
4ef4ed15f1 | ||
|
|
fef8a2455c | ||
|
|
3398b4cdc9 | ||
|
|
74cf302bd6 |
48
.github/workflows/backport-pr.yml
vendored
Normal file
48
.github/workflows/backport-pr.yml
vendored
Normal 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 }}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.schabi.newpipe.error;
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
/**
|
||||
* The user actions that can cause an error.
|
||||
*/
|
||||
public enum UserAction {
|
||||
enum class UserAction(val message: String) {
|
||||
USER_REPORT("user report"),
|
||||
UI_ERROR("ui error"),
|
||||
DATABASE_IMPORT_EXPORT("database import or export"),
|
||||
@@ -36,14 +41,4 @@ public enum UserAction {
|
||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
|
||||
PLAY_ON_POPUP("play on popup"),
|
||||
SUBSCRIPTIONS("loading subscriptions");
|
||||
|
||||
private final String message;
|
||||
|
||||
UserAction(final String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.search;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class SuggestionItem {
|
||||
final boolean fromHistory;
|
||||
public final String query;
|
||||
|
||||
public SuggestionItem(final boolean fromHistory, final String query) {
|
||||
this.fromHistory = fromHistory;
|
||||
this.query = query;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object o) {
|
||||
if (o instanceof SuggestionItem) {
|
||||
return query.equals(((SuggestionItem) o).query);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return query.hashCode();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "[" + fromHistory + "→" + query + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.fragments.list.search
|
||||
|
||||
class SuggestionItem(@JvmField val fromHistory: Boolean, @JvmField val query: String) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other is SuggestionItem) {
|
||||
return query == other.query
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun hashCode() = query.hashCode()
|
||||
|
||||
override fun toString() = "[$fromHistory→$query]"
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.search;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
import androidx.recyclerview.widget.ListAdapter;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding;
|
||||
|
||||
public class SuggestionListAdapter
|
||||
extends ListAdapter<SuggestionItem, SuggestionListAdapter.SuggestionItemHolder> {
|
||||
private OnSuggestionItemSelected listener;
|
||||
|
||||
public SuggestionListAdapter() {
|
||||
super(new SuggestionItemCallback());
|
||||
}
|
||||
|
||||
public void setListener(final OnSuggestionItemSelected listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
|
||||
final int viewType) {
|
||||
return new SuggestionItemHolder(ItemSearchSuggestionBinding
|
||||
.inflate(LayoutInflater.from(parent.getContext()), parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final SuggestionItemHolder holder, final int position) {
|
||||
final SuggestionItem currentItem = getItem(position);
|
||||
holder.updateFrom(currentItem);
|
||||
holder.itemBinding.suggestionSearch.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemSelected(currentItem);
|
||||
}
|
||||
});
|
||||
holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemLongClick(currentItem);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
holder.itemBinding.suggestionInsert.setOnClickListener(v -> {
|
||||
if (listener != null) {
|
||||
listener.onSuggestionItemInserted(currentItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public interface OnSuggestionItemSelected {
|
||||
void onSuggestionItemSelected(SuggestionItem item);
|
||||
|
||||
void onSuggestionItemInserted(SuggestionItem item);
|
||||
|
||||
void onSuggestionItemLongClick(SuggestionItem item);
|
||||
}
|
||||
|
||||
public static final class SuggestionItemHolder extends RecyclerView.ViewHolder {
|
||||
private final ItemSearchSuggestionBinding itemBinding;
|
||||
|
||||
private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) {
|
||||
super(binding.getRoot());
|
||||
this.itemBinding = binding;
|
||||
}
|
||||
|
||||
private void updateFrom(final SuggestionItem item) {
|
||||
itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history
|
||||
: R.drawable.ic_search);
|
||||
itemBinding.itemSuggestionQuery.setText(item.query);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SuggestionItemCallback
|
||||
extends DiffUtil.ItemCallback<SuggestionItem> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
|
||||
@NonNull final SuggestionItem newItem) {
|
||||
return oldItem.fromHistory == newItem.fromHistory
|
||||
&& oldItem.query.equals(newItem.query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem,
|
||||
@NonNull final SuggestionItem newItem) {
|
||||
return true; // items' contents never change; the list of items themselves does
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.fragments.list.search
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding
|
||||
import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder
|
||||
|
||||
class SuggestionListAdapter :
|
||||
ListAdapter<SuggestionItem, SuggestionItemHolder>(SuggestionItemCallback()) {
|
||||
|
||||
var listener: OnSuggestionItemSelected? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionItemHolder {
|
||||
return SuggestionItemHolder(
|
||||
ItemSearchSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) {
|
||||
val currentItem = getItem(position)
|
||||
holder.updateFrom(currentItem)
|
||||
holder.binding.suggestionSearch.setOnClickListener {
|
||||
listener?.onSuggestionItemSelected(currentItem)
|
||||
}
|
||||
holder.binding.suggestionSearch.setOnLongClickListener {
|
||||
listener?.onSuggestionItemLongClick(currentItem)
|
||||
true
|
||||
}
|
||||
holder.binding.suggestionInsert.setOnClickListener {
|
||||
listener?.onSuggestionItemInserted(currentItem)
|
||||
}
|
||||
}
|
||||
|
||||
interface OnSuggestionItemSelected {
|
||||
fun onSuggestionItemSelected(item: SuggestionItem)
|
||||
|
||||
fun onSuggestionItemInserted(item: SuggestionItem)
|
||||
|
||||
fun onSuggestionItemLongClick(item: SuggestionItem)
|
||||
}
|
||||
|
||||
class SuggestionItemHolder(val binding: ItemSearchSuggestionBinding) :
|
||||
RecyclerView.ViewHolder(binding.getRoot()) {
|
||||
fun updateFrom(item: SuggestionItem) {
|
||||
binding.itemSuggestionIcon.setImageResource(
|
||||
if (item.fromHistory) {
|
||||
R.drawable.ic_history
|
||||
} else {
|
||||
R.drawable.ic_search
|
||||
}
|
||||
)
|
||||
binding.itemSuggestionQuery.text = item.query
|
||||
}
|
||||
}
|
||||
|
||||
private class SuggestionItemCallback : DiffUtil.ItemCallback<SuggestionItem>() {
|
||||
override fun areItemsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
|
||||
return oldItem.fromHistory == newItem.fromHistory && oldItem.query == newItem.query
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean {
|
||||
return true // items' contents never change; the list of items themselves does
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.schabi.newpipe.info_list;
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.info_list
|
||||
|
||||
/**
|
||||
* Item view mode for streams & playlist listing screens.
|
||||
*/
|
||||
public enum ItemViewMode {
|
||||
enum class ItemViewMode {
|
||||
/**
|
||||
* Default mode.
|
||||
*/
|
||||
@@ -1,108 +0,0 @@
|
||||
package org.schabi.newpipe.local.history;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import java.text.DateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
|
||||
|
||||
/**
|
||||
* This is an adapter for history entries.
|
||||
*
|
||||
* @param <E> the type of the entries
|
||||
* @param <VH> the type of the view holder
|
||||
*/
|
||||
public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
|
||||
extends RecyclerView.Adapter<VH> {
|
||||
private final ArrayList<E> mEntries;
|
||||
private final DateFormat mDateFormat;
|
||||
private final Context mContext;
|
||||
private OnHistoryItemClickListener<E> onHistoryItemClickListener = null;
|
||||
|
||||
public HistoryEntryAdapter(final Context context) {
|
||||
super();
|
||||
mContext = context;
|
||||
mEntries = new ArrayList<>();
|
||||
mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM,
|
||||
Localization.getPreferredLocale(context));
|
||||
}
|
||||
|
||||
public void setEntries(@NonNull final Collection<E> historyEntries) {
|
||||
mEntries.clear();
|
||||
mEntries.addAll(historyEntries);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public Collection<E> getItems() {
|
||||
return mEntries;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
mEntries.clear();
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
protected String getFormattedDate(final Date date) {
|
||||
return mDateFormat.format(date);
|
||||
}
|
||||
|
||||
protected String getFormattedViewString(final long viewCount) {
|
||||
return Localization.shortViewCount(mContext, viewCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return mEntries.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final VH holder, final int position) {
|
||||
final E entry = mEntries.get(position);
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
if (onHistoryItemClickListener != null) {
|
||||
onHistoryItemClickListener.onHistoryItemClick(entry);
|
||||
}
|
||||
});
|
||||
|
||||
holder.itemView.setOnLongClickListener(view -> {
|
||||
if (onHistoryItemClickListener != null) {
|
||||
onHistoryItemClickListener.onHistoryItemLongClick(entry);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
onBindViewHolder(holder, entry, position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull final VH holder) {
|
||||
super.onViewRecycled(holder);
|
||||
holder.itemView.setOnClickListener(null);
|
||||
}
|
||||
|
||||
abstract void onBindViewHolder(VH holder, E entry, int position);
|
||||
|
||||
public void setOnHistoryItemClickListener(
|
||||
@Nullable final OnHistoryItemClickListener<E> onHistoryItemClickListener) {
|
||||
this.onHistoryItemClickListener = onHistoryItemClickListener;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return mEntries.isEmpty();
|
||||
}
|
||||
|
||||
public interface OnHistoryItemClickListener<E> {
|
||||
void onHistoryItemClick(E item);
|
||||
|
||||
void onHistoryItemLongClick(E item);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import android.content.Context
|
||||
@@ -21,11 +26,7 @@ fun export(
|
||||
}
|
||||
}
|
||||
|
||||
fun exportWithTitles(
|
||||
playlist: List<PlaylistStreamEntry>,
|
||||
context: Context
|
||||
): String {
|
||||
|
||||
private fun exportWithTitles(playlist: List<PlaylistStreamEntry>, context: Context): String {
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity }
|
||||
.map { entity ->
|
||||
@@ -38,18 +39,14 @@ fun exportWithTitles(
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.joinToString(separator = "\n")
|
||||
private fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
|
||||
return playlist.joinToString(separator = "\n") { it.streamEntity.url }
|
||||
}
|
||||
|
||||
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
|
||||
private fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
val videoIDs = playlist.asReversed().asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.mapNotNull(::getYouTubeId)
|
||||
.mapNotNull { getYouTubeId(it.streamEntity.url) }
|
||||
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
|
||||
.toList()
|
||||
.asReversed()
|
||||
@@ -58,7 +55,7 @@ fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
|
||||
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
|
||||
}
|
||||
|
||||
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
|
||||
private val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
|
||||
|
||||
/**
|
||||
* Gets the video id from a YouTube URL.
|
||||
@@ -66,7 +63,7 @@ val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFacto
|
||||
* @param url YouTube URL
|
||||
* @return the video id
|
||||
*/
|
||||
fun getYouTubeId(url: String): String? {
|
||||
private fun getYouTubeId(url: String): String? {
|
||||
|
||||
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
public enum PlayListShareMode {
|
||||
|
||||
JUST_URLS,
|
||||
WITH_TITLES,
|
||||
YOUTUBE_TEMP_PLAYLIST
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
enum class PlayListShareMode {
|
||||
JUST_URLS,
|
||||
WITH_TITLES,
|
||||
YOUTUBE_TEMP_PLAYLIST
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class RemotePlaylistManager {
|
||||
|
||||
private final AppDatabase database;
|
||||
private final PlaylistRemoteDAO playlistRemoteTable;
|
||||
|
||||
public RemotePlaylistManager(final AppDatabase db) {
|
||||
database = db;
|
||||
playlistRemoteTable = db.playlistRemoteDAO();
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistRemoteEntity>> getPlaylists() {
|
||||
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
|
||||
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
|
||||
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deletePlaylist(final long playlistId) {
|
||||
return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable updatePlaylists(final List<PlaylistRemoteEntity> updateItems,
|
||||
final List<Long> deletedItems) {
|
||||
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
|
||||
for (final Long uid: deletedItems) {
|
||||
playlistRemoteTable.deletePlaylist(uid);
|
||||
}
|
||||
for (final PlaylistRemoteEntity item: updateItems) {
|
||||
playlistRemoteTable.upsert(item);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Long> onBookmark(final PlaylistInfo playlistInfo) {
|
||||
return Single.fromCallable(() -> {
|
||||
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
|
||||
return playlistRemoteTable.upsert(playlist);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> onUpdate(final long playlistId, final PlaylistInfo playlistInfo) {
|
||||
return Single.fromCallable(() -> {
|
||||
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);
|
||||
playlist.setUid(playlistId);
|
||||
return playlistRemoteTable.update(playlist);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
|
||||
|
||||
class RemotePlaylistManager(private val database: AppDatabase) {
|
||||
private val playlistRemoteTable = database.playlistRemoteDAO()
|
||||
|
||||
val playlists: Flowable<MutableList<PlaylistRemoteEntity>>
|
||||
get() = playlistRemoteTable.playlists.subscribeOn(Schedulers.io())
|
||||
|
||||
fun getPlaylist(playlistId: Long): Flowable<PlaylistRemoteEntity> {
|
||||
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getPlaylist(info: PlaylistInfo): Flowable<MutableList<PlaylistRemoteEntity>> {
|
||||
return playlistRemoteTable.getPlaylist(info.serviceId.toLong(), info.url)
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deletePlaylist(playlistId: Long): Single<Int> {
|
||||
return Single.fromCallable { playlistRemoteTable.deletePlaylist(playlistId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updatePlaylists(
|
||||
updateItems: List<PlaylistRemoteEntity>,
|
||||
deletedItems: List<Long>
|
||||
): Completable {
|
||||
return Completable.fromRunnable {
|
||||
database.runInTransaction {
|
||||
deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) }
|
||||
updateItems.forEach { playlistRemoteTable.upsert(it) }
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun onBookmark(playlistInfo: PlaylistInfo): Single<Long> {
|
||||
return Single.fromCallable {
|
||||
val playlist = PlaylistRemoteEntity(playlistInfo)
|
||||
playlistRemoteTable.upsert(playlist)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
val playlist = PlaylistRemoteEntity(playlistInfo).apply { uid = playlistId }
|
||||
playlistRemoteTable.update(playlist)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
public enum PlayerType {
|
||||
MAIN,
|
||||
AUDIO,
|
||||
POPUP;
|
||||
}
|
||||
12
app/src/main/java/org/schabi/newpipe/player/PlayerType.kt
Normal file
12
app/src/main/java/org/schabi/newpipe/player/PlayerType.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.player
|
||||
|
||||
enum class PlayerType {
|
||||
MAIN,
|
||||
AUDIO,
|
||||
POPUP
|
||||
}
|
||||
@@ -17,10 +17,10 @@ import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
|
||||
import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
|
||||
@@ -4,15 +4,14 @@ import 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;
|
||||
|
||||
@@ -10,12 +10,11 @@ import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.player.playqueue.events.AppendEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.ErrorEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.SelectEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent;
|
||||
import org.schabi.newpipe.util.FallbackViewHolder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.player.playqueue
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
sealed interface PlayQueueEvent : Serializable {
|
||||
fun type(): Type
|
||||
|
||||
class InitEvent : PlayQueueEvent {
|
||||
override fun type() = Type.INIT
|
||||
}
|
||||
|
||||
// sent when the index is changed
|
||||
class SelectEvent(val oldIndex: Int, val newIndex: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.SELECT
|
||||
}
|
||||
|
||||
// sent when more streams are added to the play queue
|
||||
class AppendEvent(val amount: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.APPEND
|
||||
}
|
||||
|
||||
// sent when a pending stream is removed from the play queue
|
||||
class RemoveEvent(val removeIndex: Int, val queueIndex: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.REMOVE
|
||||
}
|
||||
|
||||
// sent when two streams swap place in the play queue
|
||||
class MoveEvent(val fromIndex: Int, val toIndex: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.MOVE
|
||||
}
|
||||
|
||||
// sent when queue is shuffled
|
||||
class ReorderEvent(val fromSelectedIndex: Int, val toSelectedIndex: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.REORDER
|
||||
}
|
||||
|
||||
// sent when recovery record is set on a stream
|
||||
class RecoveryEvent(val index: Int, val position: Long) : PlayQueueEvent {
|
||||
override fun type() = Type.RECOVERY
|
||||
}
|
||||
|
||||
// sent when the item at index has caused an exception
|
||||
class ErrorEvent(val errorIndex: Int, val queueIndex: Int) : PlayQueueEvent {
|
||||
override fun type() = Type.ERROR
|
||||
}
|
||||
|
||||
// It is necessary only for use in java code. Remove it and use kotlin pattern
|
||||
// matching when all users of this enum are converted to kotlin
|
||||
enum class Type { INIT, SELECT, APPEND, REMOVE, MOVE, REORDER, RECOVERY, ERROR }
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class AppendEvent implements PlayQueueEvent {
|
||||
private final int amount;
|
||||
|
||||
public AppendEvent(final int amount) {
|
||||
this.amount = amount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.APPEND;
|
||||
}
|
||||
|
||||
public int getAmount() {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class ErrorEvent implements PlayQueueEvent {
|
||||
private final int errorIndex;
|
||||
private final int queueIndex;
|
||||
|
||||
public ErrorEvent(final int errorIndex, final int queueIndex) {
|
||||
this.errorIndex = errorIndex;
|
||||
this.queueIndex = queueIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.ERROR;
|
||||
}
|
||||
|
||||
public int getErrorIndex() {
|
||||
return errorIndex;
|
||||
}
|
||||
|
||||
public int getQueueIndex() {
|
||||
return queueIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class InitEvent implements PlayQueueEvent {
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.INIT;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class MoveEvent implements PlayQueueEvent {
|
||||
private final int fromIndex;
|
||||
private final int toIndex;
|
||||
|
||||
public MoveEvent(final int oldIndex, final int newIndex) {
|
||||
this.fromIndex = oldIndex;
|
||||
this.toIndex = newIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.MOVE;
|
||||
}
|
||||
|
||||
public int getFromIndex() {
|
||||
return fromIndex;
|
||||
}
|
||||
|
||||
public int getToIndex() {
|
||||
return toIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public interface PlayQueueEvent extends Serializable {
|
||||
PlayQueueEventType type();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public enum PlayQueueEventType {
|
||||
INIT,
|
||||
|
||||
// sent when the index is changed
|
||||
SELECT,
|
||||
|
||||
// sent when more streams are added to the play queue
|
||||
APPEND,
|
||||
|
||||
// sent when a pending stream is removed from the play queue
|
||||
REMOVE,
|
||||
|
||||
// sent when two streams swap place in the play queue
|
||||
MOVE,
|
||||
|
||||
// sent when queue is shuffled
|
||||
REORDER,
|
||||
|
||||
// sent when recovery record is set on a stream
|
||||
RECOVERY,
|
||||
|
||||
// sent when the item at index has caused an exception
|
||||
ERROR
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class RecoveryEvent implements PlayQueueEvent {
|
||||
private final int index;
|
||||
private final long position;
|
||||
|
||||
public RecoveryEvent(final int index, final long position) {
|
||||
this.index = index;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.RECOVERY;
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public long getPosition() {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class RemoveEvent implements PlayQueueEvent {
|
||||
private final int removeIndex;
|
||||
private final int queueIndex;
|
||||
|
||||
public RemoveEvent(final int removeIndex, final int queueIndex) {
|
||||
this.removeIndex = removeIndex;
|
||||
this.queueIndex = queueIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.REMOVE;
|
||||
}
|
||||
|
||||
public int getQueueIndex() {
|
||||
return queueIndex;
|
||||
}
|
||||
|
||||
public int getRemoveIndex() {
|
||||
return removeIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class ReorderEvent implements PlayQueueEvent {
|
||||
private final int fromSelectedIndex;
|
||||
private final int toSelectedIndex;
|
||||
|
||||
public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) {
|
||||
this.fromSelectedIndex = fromSelectedIndex;
|
||||
this.toSelectedIndex = toSelectedIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.REORDER;
|
||||
}
|
||||
|
||||
public int getFromSelectedIndex() {
|
||||
return fromSelectedIndex;
|
||||
}
|
||||
|
||||
public int getToSelectedIndex() {
|
||||
return toSelectedIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue.events;
|
||||
|
||||
public class SelectEvent implements PlayQueueEvent {
|
||||
private final int oldIndex;
|
||||
private final int newIndex;
|
||||
|
||||
public SelectEvent(final int oldIndex, final int newIndex) {
|
||||
this.oldIndex = oldIndex;
|
||||
this.newIndex = newIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueueEventType type() {
|
||||
return PlayQueueEventType.SELECT;
|
||||
}
|
||||
|
||||
public int getOldIndex() {
|
||||
return oldIndex;
|
||||
}
|
||||
|
||||
public int getNewIndex() {
|
||||
return newIndex;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.XmlRes;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a preference-item inside the search.
|
||||
*/
|
||||
public class PreferenceSearchItem {
|
||||
/**
|
||||
* Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}.
|
||||
*/
|
||||
@NonNull
|
||||
private final String key;
|
||||
/**
|
||||
* Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String title;
|
||||
/**
|
||||
* Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
|
||||
*/
|
||||
@NonNull
|
||||
private final String summary;
|
||||
/**
|
||||
* Possible entries of the setting, e.g. 480p,720p,...
|
||||
*/
|
||||
@NonNull
|
||||
private final String entries;
|
||||
/**
|
||||
* Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
||||
*/
|
||||
@NonNull
|
||||
private final String breadcrumbs;
|
||||
/**
|
||||
* The xml-resource where this item was found/built from.
|
||||
*/
|
||||
@XmlRes
|
||||
private final int searchIndexItemResId;
|
||||
|
||||
public PreferenceSearchItem(
|
||||
@NonNull final String key,
|
||||
@NonNull final String title,
|
||||
@NonNull final String summary,
|
||||
@NonNull final String entries,
|
||||
@NonNull final String breadcrumbs,
|
||||
@XmlRes final int searchIndexItemResId
|
||||
) {
|
||||
this.key = Objects.requireNonNull(key);
|
||||
this.title = Objects.requireNonNull(title);
|
||||
this.summary = Objects.requireNonNull(summary);
|
||||
this.entries = Objects.requireNonNull(entries);
|
||||
this.breadcrumbs = Objects.requireNonNull(breadcrumbs);
|
||||
this.searchIndexItemResId = searchIndexItemResId;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getEntries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getBreadcrumbs() {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
public int getSearchIndexItemResId() {
|
||||
return searchIndexItemResId;
|
||||
}
|
||||
|
||||
boolean hasData() {
|
||||
return !key.isEmpty() && !title.isEmpty();
|
||||
}
|
||||
|
||||
public List<String> getAllRelevantSearchFields() {
|
||||
return List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs());
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PreferenceItem: " + title + " " + summary + " " + key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings.preferencesearch
|
||||
|
||||
import androidx.annotation.XmlRes
|
||||
|
||||
/**
|
||||
* Represents a preference-item inside the search.
|
||||
*
|
||||
* @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences].
|
||||
* @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
||||
* @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
|
||||
* @param entries Possible entries of the setting, e.g. 480p,720p,...
|
||||
* @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
||||
* @param searchIndexItemResId The xml-resource where this item was found/built from.
|
||||
*/
|
||||
|
||||
data class PreferenceSearchItem(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val entries: String,
|
||||
val breadcrumbs: String,
|
||||
@XmlRes val searchIndexItemResId: Int
|
||||
) {
|
||||
fun hasData(): Boolean {
|
||||
return !key.isEmpty() && !title.isEmpty()
|
||||
}
|
||||
|
||||
fun getAllRelevantSearchFields(): MutableList<String?> {
|
||||
return mutableListOf(title, summary, entries, breadcrumbs)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "PreferenceItem: $title $summary $key"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.schabi.newpipe.settings.preferencesearch;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public interface PreferenceSearchResultListener {
|
||||
void onSearchResultClicked(@NonNull PreferenceSearchItem result);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2022-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings.preferencesearch
|
||||
|
||||
interface PreferenceSearchResultListener {
|
||||
fun onSearchResultClicked(result: PreferenceSearchItem)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class FilenameUtils {
|
||||
private static final String CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+";
|
||||
private static final String CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+";
|
||||
|
||||
private FilenameUtils() { }
|
||||
|
||||
/**
|
||||
* #143 #44 #42 #22: make sure that the filename does not contain illegal chars.
|
||||
*
|
||||
* @param context the context to retrieve strings and preferences from
|
||||
* @param title the title to create a filename from
|
||||
* @return the filename
|
||||
*/
|
||||
public static String createFilename(final Context context, final String title) {
|
||||
final SharedPreferences sharedPreferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(context);
|
||||
|
||||
final String charsetLd = context.getString(R.string.charset_letters_and_digits_value);
|
||||
final String charsetMs = context.getString(R.string.charset_most_special_value);
|
||||
final String defaultCharset = context.getString(R.string.default_file_charset_value);
|
||||
|
||||
final String replacementChar = sharedPreferences.getString(
|
||||
context.getString(R.string.settings_file_replacement_character_key), "_");
|
||||
String selectedCharset = sharedPreferences.getString(
|
||||
context.getString(R.string.settings_file_charset_key), null);
|
||||
|
||||
final String charset;
|
||||
|
||||
if (selectedCharset == null || selectedCharset.isEmpty()) {
|
||||
selectedCharset = defaultCharset;
|
||||
}
|
||||
|
||||
if (selectedCharset.equals(charsetLd)) {
|
||||
charset = CHARSET_ONLY_LETTERS_AND_DIGITS;
|
||||
} else if (selectedCharset.equals(charsetMs)) {
|
||||
charset = CHARSET_MOST_SPECIAL;
|
||||
} else {
|
||||
charset = selectedCharset; // Is the user using a custom charset?
|
||||
}
|
||||
|
||||
final Pattern pattern = Pattern.compile(charset);
|
||||
|
||||
return createFilename(title, pattern, Matcher.quoteReplacement(replacementChar));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a valid filename.
|
||||
*
|
||||
* @param title the title to create a filename from
|
||||
* @param invalidCharacters patter matching invalid characters
|
||||
* @param replacementChar the replacement
|
||||
* @return the filename
|
||||
*/
|
||||
private static String createFilename(final String title, final Pattern invalidCharacters,
|
||||
final String replacementChar) {
|
||||
return title.replaceAll(invalidCharacters.pattern(), replacementChar);
|
||||
}
|
||||
}
|
||||
64
app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt
Normal file
64
app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.ktx.getStringSafe
|
||||
import java.util.regex.Matcher
|
||||
|
||||
object FilenameUtils {
|
||||
private const val CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+"
|
||||
private const val CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+"
|
||||
|
||||
/**
|
||||
* #143 #44 #42 #22: make sure that the filename does not contain illegal chars.
|
||||
*
|
||||
* @param context the context to retrieve strings and preferences from
|
||||
* @param title the title to create a filename from
|
||||
* @return the filename
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createFilename(context: Context, title: String): String {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
val charsetLd = context.getString(R.string.charset_letters_and_digits_value)
|
||||
val charsetMs = context.getString(R.string.charset_most_special_value)
|
||||
val defaultCharset = context.getString(R.string.default_file_charset_value)
|
||||
|
||||
val replacementChar = sharedPreferences.getStringSafe(
|
||||
context.getString(R.string.settings_file_replacement_character_key), "_"
|
||||
)
|
||||
val selectedCharset = sharedPreferences.getStringSafe(
|
||||
context.getString(R.string.settings_file_charset_key), ""
|
||||
).ifEmpty { defaultCharset }
|
||||
|
||||
val charset = when (selectedCharset) {
|
||||
charsetLd -> CHARSET_ONLY_LETTERS_AND_DIGITS
|
||||
charsetMs -> CHARSET_MOST_SPECIAL
|
||||
else -> selectedCharset // Is the user using a custom charset?
|
||||
}
|
||||
|
||||
return createFilename(title, charset, Matcher.quoteReplacement(replacementChar))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a valid filename.
|
||||
*
|
||||
* @param title the title to create a filename from
|
||||
* @param invalidCharacters patter matching invalid characters
|
||||
* @param replacementChar the replacement
|
||||
* @return the filename
|
||||
*/
|
||||
private fun createFilename(
|
||||
title: String,
|
||||
invalidCharacters: String,
|
||||
replacementChar: String
|
||||
): String {
|
||||
return title.replace(invalidCharacters.toRegex(), replacementChar)
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package org.schabi.newpipe.util.image;
|
||||
|
||||
import static org.schabi.newpipe.extractor.Image.HEIGHT_UNKNOWN;
|
||||
import static org.schabi.newpipe.extractor.Image.WIDTH_UNKNOWN;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class ImageStrategy {
|
||||
|
||||
// when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred
|
||||
// image quality is to these values (H stands for "Height")
|
||||
private static final int BEST_LOW_H = 75;
|
||||
private static final int BEST_MEDIUM_H = 250;
|
||||
|
||||
private static PreferredImageQuality preferredImageQuality = PreferredImageQuality.MEDIUM;
|
||||
|
||||
private ImageStrategy() {
|
||||
}
|
||||
|
||||
public static void setPreferredImageQuality(final PreferredImageQuality preferredImageQuality) {
|
||||
ImageStrategy.preferredImageQuality = preferredImageQuality;
|
||||
}
|
||||
|
||||
public static boolean shouldLoadImages() {
|
||||
return preferredImageQuality != PreferredImageQuality.NONE;
|
||||
}
|
||||
|
||||
|
||||
static double estimatePixelCount(final Image image, final double widthOverHeight) {
|
||||
if (image.getHeight() == HEIGHT_UNKNOWN) {
|
||||
if (image.getWidth() == WIDTH_UNKNOWN) {
|
||||
// images whose size is completely unknown will be in their own subgroups, so
|
||||
// any one of them will do, hence returning the same value for all of them
|
||||
return 0;
|
||||
} else {
|
||||
return image.getWidth() * image.getWidth() / widthOverHeight;
|
||||
}
|
||||
} else if (image.getWidth() == WIDTH_UNKNOWN) {
|
||||
return image.getHeight() * image.getHeight() * widthOverHeight;
|
||||
} else {
|
||||
return image.getHeight() * image.getWidth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #choosePreferredImage(List)} contains the description for this function's logic.
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @param nonNoneQuality the preferred quality (must NOT be {@link PreferredImageQuality#NONE})
|
||||
* @return the chosen preferred image, or {@link null} if the list is empty
|
||||
* @see #choosePreferredImage(List)
|
||||
*/
|
||||
@Nullable
|
||||
static String choosePreferredImage(@NonNull final List<Image> images,
|
||||
final PreferredImageQuality nonNoneQuality) {
|
||||
// this will be used to estimate the pixel count for images where only one of height or
|
||||
// width are known
|
||||
final double widthOverHeight = images.stream()
|
||||
.filter(image -> image.getHeight() != HEIGHT_UNKNOWN
|
||||
&& image.getWidth() != WIDTH_UNKNOWN)
|
||||
.mapToDouble(image -> ((double) image.getWidth()) / image.getHeight())
|
||||
.findFirst()
|
||||
.orElse(1.0);
|
||||
|
||||
final Image.ResolutionLevel preferredLevel = nonNoneQuality.toResolutionLevel();
|
||||
final Comparator<Image> initialComparator = Comparator
|
||||
// the first step splits the images into groups of resolution levels
|
||||
.<Image>comparingInt(i -> {
|
||||
if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
|
||||
return 3; // avoid unknowns as much as possible
|
||||
} else if (i.getEstimatedResolutionLevel() == preferredLevel) {
|
||||
return 0; // prefer a matching resolution level
|
||||
} else if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.MEDIUM) {
|
||||
return 1; // the preferredLevel is only 1 "step" away (either HIGH or LOW)
|
||||
} else {
|
||||
return 2; // the preferredLevel is the furthest away possible (2 "steps")
|
||||
}
|
||||
})
|
||||
// then each level's group is further split into two subgroups, one with known image
|
||||
// size (which is also the preferred subgroup) and the other without
|
||||
.thenComparing(image ->
|
||||
image.getHeight() == HEIGHT_UNKNOWN && image.getWidth() == WIDTH_UNKNOWN);
|
||||
|
||||
// The third step chooses, within each subgroup with known image size, the best image based
|
||||
// on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups
|
||||
// without known image size will be left untouched since estimatePixelCount always returns
|
||||
// the same number for those.
|
||||
final Comparator<Image> finalComparator = switch (nonNoneQuality) {
|
||||
case NONE -> initialComparator; // unreachable
|
||||
case LOW -> initialComparator.thenComparingDouble(image -> {
|
||||
final double pixelCount = estimatePixelCount(image, widthOverHeight);
|
||||
return Math.abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight);
|
||||
});
|
||||
case MEDIUM -> initialComparator.thenComparingDouble(image -> {
|
||||
final double pixelCount = estimatePixelCount(image, widthOverHeight);
|
||||
return Math.abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight);
|
||||
});
|
||||
case HIGH -> initialComparator.thenComparingDouble(
|
||||
// this is reversed with a - so that the highest resolution is chosen
|
||||
i -> -estimatePixelCount(i, widthOverHeight));
|
||||
};
|
||||
|
||||
return images.stream()
|
||||
// using "min" basically means "take the first group, then take the first subgroup,
|
||||
// then choose the best image, while ignoring all other groups and subgroups"
|
||||
.min(finalComparator)
|
||||
.map(Image::getUrl)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses an image amongst the provided list based on the user preference previously set with
|
||||
* {@link #setPreferredImageQuality(PreferredImageQuality)}. {@code null} will be returned in
|
||||
* case the list is empty or the user preference is to not show images.
|
||||
* <br>
|
||||
* These properties will be preferred, from most to least important:
|
||||
* <ol>
|
||||
* <li>The image's {@link Image#getEstimatedResolutionLevel()} is not unknown and is close
|
||||
* to {@link #preferredImageQuality}</li>
|
||||
* <li>At least one of the image's width or height are known</li>
|
||||
* <li>The highest resolution image is finally chosen if the user's preference is {@link
|
||||
* PreferredImageQuality#HIGH}, otherwise the chosen image is the one that has the height
|
||||
* closest to {@link #BEST_LOW_H} or {@link #BEST_MEDIUM_H}</li>
|
||||
* </ol>
|
||||
* <br>
|
||||
* Use {@link #imageListToDbUrl(List)} if the URL is going to be saved to the database, to avoid
|
||||
* saving nothing in case at the moment of saving the user preference is to not show images.
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @return the chosen preferred image, or {@link null} if the list is empty or the user disabled
|
||||
* images
|
||||
* @see #imageListToDbUrl(List)
|
||||
*/
|
||||
@Nullable
|
||||
public static String choosePreferredImage(@NonNull final List<Image> images) {
|
||||
if (preferredImageQuality == PreferredImageQuality.NONE) {
|
||||
return null; // do not load images
|
||||
}
|
||||
|
||||
return choosePreferredImage(images, preferredImageQuality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #choosePreferredImage(List)}, except that if {@link #preferredImageQuality} is
|
||||
* {@link PreferredImageQuality#NONE} an image will be chosen anyway (with preferred quality
|
||||
* {@link PreferredImageQuality#MEDIUM}.
|
||||
* <br>
|
||||
* To go back to a list of images (obviously with just the one chosen image) from a URL saved in
|
||||
* the database use {@link #dbUrlToImageList(String)}.
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @return the chosen preferred image, or {@link null} if the list is empty
|
||||
* @see #choosePreferredImage(List)
|
||||
* @see #dbUrlToImageList(String)
|
||||
*/
|
||||
@Nullable
|
||||
public static String imageListToDbUrl(@NonNull final List<Image> images) {
|
||||
final PreferredImageQuality quality;
|
||||
if (preferredImageQuality == PreferredImageQuality.NONE) {
|
||||
quality = PreferredImageQuality.MEDIUM;
|
||||
} else {
|
||||
quality = preferredImageQuality;
|
||||
}
|
||||
|
||||
return choosePreferredImage(images, quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the URL (coming from the database) in a {@code List<Image>} so that it is usable
|
||||
* seamlessly in all of the places where the extractor would return a list of images, including
|
||||
* allowing to build info objects based on database objects.
|
||||
* <br>
|
||||
* To obtain a url to save to the database from a list of images use {@link
|
||||
* #imageListToDbUrl(List)}.
|
||||
*
|
||||
* @param url the URL to wrap coming from the database, or {@code null} to get an empty list
|
||||
* @return a list containing just one {@link Image} wrapping the provided URL, with unknown
|
||||
* image size fields, or an empty list if the URL is {@code null}
|
||||
* @see #imageListToDbUrl(List)
|
||||
*/
|
||||
@NonNull
|
||||
public static List<Image> dbUrlToImageList(@Nullable final String url) {
|
||||
if (url == null) {
|
||||
return List.of();
|
||||
} else {
|
||||
return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN));
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
Normal file
191
app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util.image
|
||||
|
||||
import org.schabi.newpipe.extractor.Image
|
||||
import org.schabi.newpipe.extractor.Image.ResolutionLevel
|
||||
import kotlin.math.abs
|
||||
|
||||
object ImageStrategy {
|
||||
// when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred
|
||||
// image quality is to these values (H stands for "Height")
|
||||
private const val BEST_LOW_H = 75
|
||||
private const val BEST_MEDIUM_H = 250
|
||||
|
||||
private var preferredImageQuality = PreferredImageQuality.MEDIUM
|
||||
|
||||
@JvmStatic
|
||||
fun setPreferredImageQuality(preferredImageQuality: PreferredImageQuality) {
|
||||
ImageStrategy.preferredImageQuality = preferredImageQuality
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldLoadImages(): Boolean {
|
||||
return preferredImageQuality != PreferredImageQuality.NONE
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun estimatePixelCount(image: Image, widthOverHeight: Double): Double {
|
||||
if (image.height == Image.HEIGHT_UNKNOWN) {
|
||||
if (image.width == Image.WIDTH_UNKNOWN) {
|
||||
// images whose size is completely unknown will be in their own subgroups, so
|
||||
// any one of them will do, hence returning the same value for all of them
|
||||
return 0.0
|
||||
} else {
|
||||
return image.width * image.width / widthOverHeight
|
||||
}
|
||||
} else if (image.width == Image.WIDTH_UNKNOWN) {
|
||||
return image.height * image.height * widthOverHeight
|
||||
} else {
|
||||
return (image.height * image.width).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [choosePreferredImage] contains the description for this function's logic.
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @param nonNoneQuality the preferred quality (must NOT be [PreferredImageQuality.NONE])
|
||||
* @return the chosen preferred image, or `null` if the list is empty
|
||||
* @see [choosePreferredImage]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun choosePreferredImage(images: List<Image>, nonNoneQuality: PreferredImageQuality): String? {
|
||||
// this will be used to estimate the pixel count for images where only one of height or
|
||||
// width are known
|
||||
val widthOverHeight = images
|
||||
.filter { image ->
|
||||
image.height != Image.HEIGHT_UNKNOWN && image.width != Image.WIDTH_UNKNOWN
|
||||
}
|
||||
.map { image -> (image.width.toDouble()) / image.height }
|
||||
.elementAtOrNull(0) ?: 1.0
|
||||
|
||||
val preferredLevel = nonNoneQuality.toResolutionLevel()
|
||||
// TODO: rewrite using kotlin collections API `groupBy` will be handy
|
||||
val initialComparator =
|
||||
Comparator // the first step splits the images into groups of resolution levels
|
||||
.comparingInt { i: Image ->
|
||||
return@comparingInt when (i.estimatedResolutionLevel) {
|
||||
// avoid unknowns as much as possible
|
||||
ResolutionLevel.UNKNOWN -> 3
|
||||
|
||||
// prefer a matching resolution level
|
||||
preferredLevel -> 0
|
||||
|
||||
// the preferredLevel is only 1 "step" away (either HIGH or LOW)
|
||||
ResolutionLevel.MEDIUM -> 1
|
||||
|
||||
// the preferredLevel is the furthest away possible (2 "steps")
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
// then each level's group is further split into two subgroups, one with known image
|
||||
// size (which is also the preferred subgroup) and the other without
|
||||
.thenComparing { image -> image.height == Image.HEIGHT_UNKNOWN && image.width == Image.WIDTH_UNKNOWN }
|
||||
|
||||
// The third step chooses, within each subgroup with known image size, the best image based
|
||||
// on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups
|
||||
// without known image size will be left untouched since estimatePixelCount always returns
|
||||
// the same number for those.
|
||||
val finalComparator = when (nonNoneQuality) {
|
||||
PreferredImageQuality.NONE -> initialComparator
|
||||
PreferredImageQuality.LOW -> initialComparator.thenComparingDouble { image ->
|
||||
val pixelCount = estimatePixelCount(image, widthOverHeight)
|
||||
abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight)
|
||||
}
|
||||
|
||||
PreferredImageQuality.MEDIUM -> initialComparator.thenComparingDouble { image ->
|
||||
val pixelCount = estimatePixelCount(image, widthOverHeight)
|
||||
abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight)
|
||||
}
|
||||
|
||||
PreferredImageQuality.HIGH -> initialComparator.thenComparingDouble { image ->
|
||||
// this is reversed with a - so that the highest resolution is chosen
|
||||
-estimatePixelCount(image, widthOverHeight)
|
||||
}
|
||||
}
|
||||
|
||||
return images.stream() // using "min" basically means "take the first group, then take the first subgroup,
|
||||
// then choose the best image, while ignoring all other groups and subgroups"
|
||||
.min(finalComparator)
|
||||
.map(Image::getUrl)
|
||||
.orElse(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses an image amongst the provided list based on the user preference previously set with
|
||||
* [setPreferredImageQuality]. `null` will be returned in
|
||||
* case the list is empty or the user preference is to not show images.
|
||||
* <br>
|
||||
* These properties will be preferred, from most to least important:
|
||||
*
|
||||
* 1. The image's [Image.estimatedResolutionLevel] is not unknown and is close to [preferredImageQuality]
|
||||
* 2. At least one of the image's width or height are known
|
||||
* 3. The highest resolution image is finally chosen if the user's preference is
|
||||
* [PreferredImageQuality.HIGH], otherwise the chosen image is the one that has the height
|
||||
* closest to [BEST_LOW_H] or [BEST_MEDIUM_H]
|
||||
*
|
||||
* <br>
|
||||
* Use [imageListToDbUrl] if the URL is going to be saved to the database, to avoid
|
||||
* saving nothing in case at the moment of saving the user preference is to not show images.
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @return the chosen preferred image, or `null` if the list is empty or the user disabled
|
||||
* images
|
||||
* @see [imageListToDbUrl]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun choosePreferredImage(images: List<Image>): String? {
|
||||
if (preferredImageQuality == PreferredImageQuality.NONE) {
|
||||
return null // do not load images
|
||||
}
|
||||
|
||||
return choosePreferredImage(images, preferredImageQuality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [choosePreferredImage], except that if [preferredImageQuality] is
|
||||
* [PreferredImageQuality.NONE] an image will be chosen anyway (with preferred quality
|
||||
* [PreferredImageQuality.MEDIUM].
|
||||
* <br></br>
|
||||
* To go back to a list of images (obviously with just the one chosen image) from a URL saved in
|
||||
* the database use [dbUrlToImageList].
|
||||
*
|
||||
* @param images the images from which to choose
|
||||
* @return the chosen preferred image, or `null` if the list is empty
|
||||
* @see [choosePreferredImage]
|
||||
* @see [dbUrlToImageList]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun imageListToDbUrl(images: List<Image>): String? {
|
||||
val quality = when (preferredImageQuality) {
|
||||
PreferredImageQuality.NONE -> PreferredImageQuality.MEDIUM
|
||||
else -> preferredImageQuality
|
||||
}
|
||||
|
||||
return choosePreferredImage(images, quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the URL (coming from the database) in a `List<Image>` so that it is usable
|
||||
* seamlessly in all of the places where the extractor would return a list of images, including
|
||||
* allowing to build info objects based on database objects.
|
||||
* <br></br>
|
||||
* To obtain a url to save to the database from a list of images use [imageListToDbUrl].
|
||||
*
|
||||
* @param url the URL to wrap coming from the database, or `null` to get an empty list
|
||||
* @return a list containing just one [Image] wrapping the provided URL, with unknown
|
||||
* image size fields, or an empty list if the URL is `null`
|
||||
* @see [imageListToDbUrl]
|
||||
*/
|
||||
@JvmStatic
|
||||
fun dbUrlToImageList(url: String?): List<Image> {
|
||||
return when (url) {
|
||||
null -> listOf()
|
||||
else -> listOf(Image(url, -1, -1, ResolutionLevel.UNKNOWN))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.schabi.newpipe.util.image;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
|
||||
public enum PreferredImageQuality {
|
||||
NONE,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH;
|
||||
|
||||
public static PreferredImageQuality fromPreferenceKey(final Context context, final String key) {
|
||||
if (context.getString(R.string.image_quality_none_key).equals(key)) {
|
||||
return NONE;
|
||||
} else if (context.getString(R.string.image_quality_low_key).equals(key)) {
|
||||
return LOW;
|
||||
} else if (context.getString(R.string.image_quality_high_key).equals(key)) {
|
||||
return HIGH;
|
||||
} else {
|
||||
return MEDIUM; // default to medium
|
||||
}
|
||||
}
|
||||
|
||||
public Image.ResolutionLevel toResolutionLevel() {
|
||||
switch (this) {
|
||||
case LOW:
|
||||
return Image.ResolutionLevel.LOW;
|
||||
case MEDIUM:
|
||||
return Image.ResolutionLevel.MEDIUM;
|
||||
case HIGH:
|
||||
return Image.ResolutionLevel.HIGH;
|
||||
default:
|
||||
case NONE:
|
||||
return Image.ResolutionLevel.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util.image
|
||||
|
||||
import android.content.Context
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Image.ResolutionLevel
|
||||
|
||||
enum class PreferredImageQuality {
|
||||
NONE,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH;
|
||||
|
||||
fun toResolutionLevel(): ResolutionLevel {
|
||||
return when (this) {
|
||||
LOW -> ResolutionLevel.LOW
|
||||
MEDIUM -> ResolutionLevel.MEDIUM
|
||||
HIGH -> ResolutionLevel.HIGH
|
||||
NONE -> ResolutionLevel.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromPreferenceKey(context: Context, key: String?): PreferredImageQuality {
|
||||
return when (key) {
|
||||
context.getString(R.string.image_quality_none_key) -> NONE
|
||||
context.getString(R.string.image_quality_low_key) -> LOW
|
||||
context.getString(R.string.image_quality_high_key) -> HIGH
|
||||
else -> MEDIUM // default to medium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,30 +54,6 @@ public final class TimestampExtractor {
|
||||
return new TimestampMatchDTO(timestampStart, timestampEnd, seconds);
|
||||
}
|
||||
|
||||
public static class TimestampMatchDTO {
|
||||
private final int timestampStart;
|
||||
private final int timestampEnd;
|
||||
private final int seconds;
|
||||
|
||||
public TimestampMatchDTO(
|
||||
final int timestampStart,
|
||||
final int timestampEnd,
|
||||
final int seconds) {
|
||||
this.timestampStart = timestampStart;
|
||||
this.timestampEnd = timestampEnd;
|
||||
this.seconds = seconds;
|
||||
}
|
||||
|
||||
public int timestampStart() {
|
||||
return timestampStart;
|
||||
}
|
||||
|
||||
public int timestampEnd() {
|
||||
return timestampEnd;
|
||||
}
|
||||
|
||||
public int seconds() {
|
||||
return seconds;
|
||||
}
|
||||
public record TimestampMatchDTO(int timestampStart, int timestampEnd, int seconds) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package org.schabi.newpipe.util.text;
|
||||
|
||||
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
|
||||
|
||||
@NonNull
|
||||
private final Context context;
|
||||
@NonNull
|
||||
private final String descriptionText;
|
||||
@NonNull
|
||||
private final CompositeDisposable disposables;
|
||||
@NonNull
|
||||
private final StreamingService relatedInfoService;
|
||||
@NonNull
|
||||
private final String relatedStreamUrl;
|
||||
@NonNull
|
||||
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
|
||||
|
||||
TimestampLongPressClickableSpan(
|
||||
@NonNull final Context context,
|
||||
@NonNull final String descriptionText,
|
||||
@NonNull final CompositeDisposable disposables,
|
||||
@NonNull final StreamingService relatedInfoService,
|
||||
@NonNull final String relatedStreamUrl,
|
||||
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
|
||||
this.context = context;
|
||||
this.descriptionText = descriptionText;
|
||||
this.disposables = disposables;
|
||||
this.relatedInfoService = relatedInfoService;
|
||||
this.relatedStreamUrl = relatedStreamUrl;
|
||||
this.timestampMatchDTO = timestampMatchDTO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(@NonNull final View view) {
|
||||
playOnPopup(context, relatedStreamUrl, relatedInfoService,
|
||||
timestampMatchDTO.seconds());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongClick(@NonNull final View view) {
|
||||
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
|
||||
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String getTimestampTextToCopy(
|
||||
@NonNull final StreamingService relatedInfoService,
|
||||
@NonNull final String relatedStreamUrl,
|
||||
@NonNull final String descriptionText,
|
||||
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
|
||||
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
|
||||
if (relatedInfoService == ServiceList.YouTube) {
|
||||
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
|
||||
} else if (relatedInfoService == ServiceList.SoundCloud
|
||||
|| relatedInfoService == ServiceList.MediaCCC) {
|
||||
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
|
||||
} else if (relatedInfoService == ServiceList.PeerTube) {
|
||||
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
|
||||
}
|
||||
|
||||
// Return timestamp text for other services
|
||||
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
|
||||
timestampMatchDTO.timestampEnd()).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2023-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.util.text
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.StreamingService
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO
|
||||
|
||||
class TimestampLongPressClickableSpan(
|
||||
private val context: Context,
|
||||
private val descriptionText: String,
|
||||
private val disposables: CompositeDisposable,
|
||||
private val relatedInfoService: StreamingService,
|
||||
private val relatedStreamUrl: String,
|
||||
private val timestampMatchDTO: TimestampMatchDTO
|
||||
) : LongPressClickableSpan() {
|
||||
override fun onClick(view: View) {
|
||||
InternalUrlsHandler.playOnPopup(
|
||||
context,
|
||||
relatedStreamUrl,
|
||||
relatedInfoService,
|
||||
timestampMatchDTO.seconds()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLongClick(view: View) {
|
||||
ShareUtils.copyToClipboard(
|
||||
context,
|
||||
getTimestampTextToCopy(
|
||||
relatedInfoService,
|
||||
relatedStreamUrl,
|
||||
descriptionText,
|
||||
timestampMatchDTO
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun getTimestampTextToCopy(
|
||||
relatedInfoService: StreamingService,
|
||||
relatedStreamUrl: String,
|
||||
descriptionText: String,
|
||||
timestampMatchDTO: TimestampMatchDTO
|
||||
): String {
|
||||
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
|
||||
when (relatedInfoService) {
|
||||
ServiceList.YouTube ->
|
||||
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds()
|
||||
ServiceList.SoundCloud, ServiceList.MediaCCC ->
|
||||
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds()
|
||||
ServiceList.PeerTube ->
|
||||
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds()
|
||||
}
|
||||
|
||||
// Return timestamp text for other services
|
||||
return descriptionText.substring(
|
||||
timestampMatchDTO.timestampStart(),
|
||||
timestampMatchDTO.timestampEnd()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user