1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-02-04 01:00:18 +00:00

Compare commits

..

112 Commits

Author SHA1 Message Date
Stypox
addf1e23b3 Add changelogs for v0.28.2 (1007) 2026-01-28 03:28:23 +01:00
Stypox
a40d7ff70e Hotfix release v0.28.2 (1007) 2026-01-28 03:28:23 +01:00
Hosted Weblate
d53f7acfa4 Translated using Weblate (Somali)
Currently translated at 71.5% (547 of 764 strings)

Translated using Weblate (Somali)

Currently translated at 71.5% (547 of 764 strings)

Translated using Weblate (Danish)

Currently translated at 98.5% (753 of 764 strings)

Translated using Weblate (Danish)

Currently translated at 98.5% (753 of 764 strings)

Translated using Weblate (Kurdish (Central))

Currently translated at 80.4% (615 of 764 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 73.5% (562 of 764 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 73.5% (562 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Georgian)

Currently translated at 92.5% (707 of 764 strings)

Translated using Weblate (Kurdish)

Currently translated at 60.9% (466 of 764 strings)

Translated using Weblate (Catalan)

Currently translated at 96.0% (734 of 764 strings)

Translated using Weblate (Catalan)

Currently translated at 96.0% (734 of 764 strings)

Translated using Weblate (Greek)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (French)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (French)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Japanese)

Currently translated at 95.5% (730 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 98.8% (755 of 764 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.3% (759 of 764 strings)

Translated using Weblate (Odia)

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Russian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Russian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Korean)

Currently translated at 98.4% (752 of 764 strings)

Translated using Weblate (Korean)

Currently translated at 98.4% (752 of 764 strings)

Translated using Weblate (Interlingua)

Currently translated at 31.1% (238 of 764 strings)

Translated using Weblate (Interlingua)

Currently translated at 31.1% (238 of 764 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Tamazight (Central Atlas))

Currently translated at 18.5% (142 of 764 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Turkish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Arabic (Libya))

Currently translated at 93.4% (714 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Lithuanian)

Currently translated at 98.9% (756 of 764 strings)

Translated using Weblate (Lithuanian)

Currently translated at 98.9% (756 of 764 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Czech)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Asturian)

Currently translated at 60.6% (463 of 764 strings)

Translated using Weblate (Asturian)

Currently translated at 60.6% (463 of 764 strings)

Translated using Weblate (Hebrew)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Hebrew)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Sardinian)

Currently translated at 97.9% (748 of 764 strings)

Translated using Weblate (Sardinian)

Currently translated at 97.9% (748 of 764 strings)

Translated using Weblate (Chinese (Traditional Han script, Hong Kong))

Currently translated at 95.1% (727 of 764 strings)

Translated using Weblate (Albanian)

Currently translated at 76.3% (583 of 764 strings)

Translated using Weblate (Albanian)

Currently translated at 76.3% (583 of 764 strings)

Translated using Weblate (Nepali)

Currently translated at 56.2% (430 of 764 strings)

Translated using Weblate (Nepali)

Currently translated at 56.2% (430 of 764 strings)

Translated using Weblate (Finnish)

Currently translated at 94.2% (720 of 764 strings)

Translated using Weblate (Finnish)

Currently translated at 94.2% (720 of 764 strings)

Translated using Weblate (Uzbek (Latin script))

Currently translated at 59.2% (453 of 764 strings)

Translated using Weblate (Uzbek (Latin script))

Currently translated at 59.2% (453 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Kurdish (Northern))

Currently translated at 62.5% (478 of 764 strings)

Translated using Weblate (Kurdish (Northern))

Currently translated at 62.5% (478 of 764 strings)

Translated using Weblate (Icelandic)

Currently translated at 98.1% (750 of 764 strings)

Translated using Weblate (N’Ko)

Currently translated at 85.7% (655 of 764 strings)

Translated using Weblate (Croatian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Serbian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Serbian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Bengali)

Currently translated at 74.2% (567 of 764 strings)

Translated using Weblate (German)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (German)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Romanian)

Currently translated at 97.6% (746 of 764 strings)

Translated using Weblate (Romanian)

Currently translated at 97.6% (746 of 764 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (ryu (generated) (ryu))

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Arabic)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Galician)

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Galician)

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Italian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Italian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Dutch)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Dutch)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Estonian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Estonian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Malayalam)

Currently translated at 73.0% (558 of 764 strings)

Translated using Weblate (Malayalam)

Currently translated at 73.0% (558 of 764 strings)

Translated using Weblate (Swedish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Swedish)

Currently translated at 99.7% (762 of 764 strings)

Translated using Weblate (Persian)

Currently translated at 95.6% (731 of 764 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 89.9% (687 of 764 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 89.9% (687 of 764 strings)

Translated using Weblate (Polish)

Currently translated at 99.7% (762 of 764 strings)

Co-authored-by: 2-Seol <2Seol.0117@gmail.com>
Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Andreas Westrell <andreas.westrell@gmail.com>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Anxhelo Lushka <github@lushka.al>
Co-authored-by: AudricV <AudricV@users.noreply.hosted.weblate.org>
Co-authored-by: Aurelian Ciocîltan <aurelianciociltan@gmail.com>
Co-authored-by: Bakary Kaba <mbkaba@live.fr>
Co-authored-by: Balázs Meskó <meskobalazs@mailbox.org>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: D D <keptawesome@gmail.com>
Co-authored-by: David Rebolo Magariños <davidre345@hotmail.com>
Co-authored-by: Deleted User <jsdelvani@users.noreply.hosted.weblate.org>
Co-authored-by: Deleted User <noreply+10474@weblate.org>
Co-authored-by: Deleted User <noreply+19964@weblate.org>
Co-authored-by: Dormin <nkzo3d+1uozh1scczh8c@sharklasers.com>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Enol P. <enolp@softastur.org>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Francesco Saltori <francescosaltori@gmail.com>
Co-authored-by: GET100PERCENT <eraofphysics@yahoo.com>
Co-authored-by: Guillem <guillemglez@gmail.com>
Co-authored-by: Hakim Oubouali <hakim.oubouali.skr@gmail.com>
Co-authored-by: Hoseok Seo <ddinghoya@gmail.com>
Co-authored-by: Igor Nedoboy <i.nedoboy@mail.ru>
Co-authored-by: Ishwor Ghimire <ghimire.esor09@gmail.com>
Co-authored-by: Kiss Attila <gaxeco4855@pro5g.com>
Co-authored-by: Ldm Public <ldmpub@gmail.com>
Co-authored-by: MS-PC <MSPCtranslator@gmail.com>
Co-authored-by: Marian Hanzel <marulinko@gmail.com>
Co-authored-by: Mathias Hamza Vedsted-Mirza <mathiashamzamirza@outlook.com>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mukhamadjonov <abdukodir.9507@gmail.com>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Nadir Nour <dudethatwascool2@gmail.com>
Co-authored-by: Nikoloz <nukushatugushi@gmail.com>
Co-authored-by: Olivia Ng <uloo592@gmail.com>
Co-authored-by: Oymate <dhruboadittya96@gmail.com>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: SecularSteve <fairfull.playing@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: Sérgio Marques <smarquespt@gmail.com>
Co-authored-by: Theophine Savio Theodore <theophinetheodore@gmail.com>
Co-authored-by: Ville Rantanen <v.r@iki.fi>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: cat <catsnote@proton.me>
Co-authored-by: cehnemdark <cehennem1001@gmail.com>
Co-authored-by: gymka <gymka@archlinux.lt>
Co-authored-by: nautilusx <translate@disroot.org>
Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Co-authored-by: winqooq <winqooq@gmail.com>
Co-authored-by: zmni <zmni@outlook.com>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Co-authored-by: Слободан Симић(Slobodan Simić) <slsimic@gmail.com>
2026-01-28 03:28:23 +01:00
Hosted Weblate
ee52b08546 Translated using Weblate (Basque)
Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 20.6% (18 of 87 strings)

Translated using Weblate (Latvian)

Currently translated at 96.9% (741 of 764 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.6% (761 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Thai)

Currently translated at 37.3% (285 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (French)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Spanish)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 4.9% (38 of 764 strings)

Translated using Weblate (Lombard)

Currently translated at 2.2% (2 of 87 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Arabic)

Currently translated at 97.7% (85 of 87 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.8% (732 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.8% (732 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.2% (728 of 764 strings)

Translated using Weblate (Latvian)

Currently translated at 95.6% (731 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Basque)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Spanish)

Currently translated at 74.7% (65 of 87 strings)

Added translation using Weblate (Yiddish)

Co-authored-by: AudricV <AudricV@users.noreply.hosted.weblate.org>
Co-authored-by: CJ Montero <cristlad@proton.me>
Co-authored-by: Erenay <erenaydev@proton.me>
Co-authored-by: Hasan Kara <hasanbeytullahkara@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Igi 5216 <dr.siantonov@gmail.com>
Co-authored-by: Mohammed al-Qubati <mhraqeeb@gmail.com>
Co-authored-by: Nicolás Pérez <ccnicolasperez@gmail.com>
Co-authored-by: TXRdev Archive <lckphanaf9999@gmail.com>
Co-authored-by: Thadah D. Denyse <thadahdenyse@protonmail.com>
Co-authored-by: ThaiWithNoBraincell <altofskgd@gmail.com>
Co-authored-by: What's news <fishelschonfeld@gmail.com>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ar/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/es/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/eu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/lmo/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/lv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translation: NewPipe/Metadata
2026-01-28 03:03:51 +01:00
Stypox
864725bf0f Merge pull request #12601 from AudricV/live-prefer-dash-and-fetch-bg-audio-only 2026-01-28 02:41:09 +01:00
Stypox
c2723096ab Avoid rebuilding BackgroundPlayerUi if already in place 2026-01-28 02:39:56 +01:00
Stypox
eb7351c858 Remove file committed by accident 2026-01-28 02:20:58 +01:00
Stypox
216867c597 Address review comments 2026-01-28 02:20:33 +01:00
AudricV
1d8ea0181f Disable fetching video and text tracks in background player
This reduces data usage for manifest sources with demuxed audio and
video, such as livestreams, for non-HLS sources only due to an
ExoPlayer bug.
2026-01-28 02:13:14 +01:00
AudricV
4648cac9c6 Allow changing video and text tracks state without stream info
This allows disabling these track types when stream info has been not
loaded while the ExoPlayer instance is.

It is now possible to do so with the background player, in order to
disable fetching video and text tracks for manifest sources,
especially used for livestreams.

Also set the recovery first before reloading play queue manager in the
useVideoAndSubtitles method of the Player class.
2026-01-28 02:13:14 +01:00
AudricV
0578e7fde0 Rename useVideoSource to useVideoAndSubtitles in Player
As both subtitles and video tracks are disabled in this method, the
goal of this rename is to highlight disabling/enabled subtitles.
2026-01-28 02:13:14 +01:00
AudricV
c670ad80ee Use DASH first instead of HLS and YouTube's DASH parser for lives 2026-01-28 02:13:14 +01:00
AudricV
077f34c922 Add a YouTube DASH manifest parser to make live DASH manifests usable
This is a hacky solution, a better one should be investigated and used.
2026-01-28 02:13:14 +01:00
Stypox
635b306de0 Merge pull request #13134 from TeamNewPipe/revert-12781-feat/similar-youtube-client-screen-rotation 2026-01-28 02:12:35 +01:00
Stypox
11af6a2902 Merge pull request #13129 from AudricV/npe_update 2026-01-28 02:08:26 +01:00
Stypox
7e3657831c Merge pull request #13133 from Stypox/missing-report-button 2026-01-28 02:05:16 +01:00
Stypox
c0613b5e54 Merge pull request #13132 from Stypox/regression-detailfragment-flickers 2026-01-28 02:04:15 +01:00
AudricV
bffee48bcb Update NewPipeExtractor 2026-01-28 02:01:45 +01:00
Stypox
49e95d95a1 Revert "Remember and restore orientation on fullscreen exit" 2026-01-28 01:44:39 +01:00
Stypox
dc160da034 Allow reporting ContentNotAvailableException 2026-01-28 00:14:21 +01:00
Stypox
5155b24ed6 Partial revert: fix VideoDetailFragment flickering
This partially reverts commit 92a07a3445, which was needed to fix ghost notifications. There I broke the "cycle" causing the useless notifications to popup in 2 different places (see points 3 and 4 of the commit description).
However, breaking the cycle in point 4 ("`PlayerHolder::tryBindIfNeeded()` is now used to passively try to bind, instead of `PlayerHolder::startService()`" was not correct, for the following reason.
I assumed that `ACTION_PLAYER_STARTED` was used for notifying that the player was instantiated anew, while it actually is used to notify that something is now ready for use: it could be the player, but it could also just be that the bottom sheet view was just added and thus the VideoDetailFragment needs to start the player.
Therefore, when handling `ACTION_PLAYER_STARTED` it is correct to start the player service and not just try to bind to it.
The other point in which I broke the cycle (point 3) should still prevent ghost notifications, although I could not test.
2026-01-27 23:44:25 +01:00
Stypox
4e0d542994 Merge pull request #12929 from TeamNewPipe/fix/playlist-remove-watched 2026-01-27 22:07:10 +01:00
Stypox
817fccb7a3 Swap && to reduce computation 2026-01-27 22:02:01 +01:00
tobigr
8d9af62736 Extract dialog creation into its own method 2026-01-27 22:02:01 +01:00
tobigr
8f32532acd "Removed watched videos" changed to "Remove watched streams"
Playlists can also contain audio-only items. Therefore, the term "stream" is used.
2026-01-27 22:01:59 +01:00
tobigr
0611d650e7 Use checkbox to remove partially watched videos 2026-01-27 22:00:15 +01:00
tobigr
d1f6337c6e Fix removing unwatched streams from playlist when using "remove watched"
The bug is caused by a wanted but forgotten inconsistency in the database.
A stream can be listed in the watch history (StreamHistoryEntity) while having no corresponding playback state (StreamStateEntity) containing the matching playback position. This is caused by the fact that NewPipe does not consider a watch time of less than five seconds to be worthy to be put into the StreamStateEntity because the video was most likely played by error. Those videos are, however, counted and stored in the watch history.
2026-01-27 22:00:15 +01:00
Stypox
d7dffb7a90 Add deprecation to LocalItemListAdapter.showFooter(true) 2026-01-27 21:35:21 +01:00
Stypox
d0f32b3842 Merge pull request #12996 from whistlingwoods/reapply-local-list-header-fix-by-j-haldane 2026-01-27 21:32:29 +01:00
AudricV
25b133946d [YouTube] Adapt YoutubeHttpDataSource to TVHTML5 client removal in NPE 2026-01-23 22:04:05 +01:00
Aayush Gupta
460cadf694 Merge pull request #13081 from TeamNewPipe/ktlint
Fix and enable multiple ktlint rules
2026-01-24 00:41:17 +08:00
Aayush Gupta
2fd2822053 ktlint: Drop non-required backing-property-naming supression
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:03 +08:00
Aayush Gupta
8ac8424cab ktlint: Drop unused chain-method-continuation violation supression
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:03 +08:00
Aayush Gupta
6b2a1cedef ktlint: Fix function-literal violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:03 +08:00
Aayush Gupta
8c5f13ab5c ktlint: Fix function-signature violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:03 +08:00
Aayush Gupta
eb97366e41 ktlint: Drop non-required violation supression
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:03 +08:00
Aayush Gupta
81ddd5a115 ktlint: Fix no-empty-first-line-in-method-block violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:32:02 +08:00
Aayush Gupta
7d5647b0ba ktlint: Fix standard_argument-list-wrapping violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
9b7874ff51 ktlint: Fix blank-line-between-when-conditions violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
ff2390b144 ktlint: Fix enum wrapping violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
81fd089a32 ktlint: Fix block comment violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
1466dd17b1 ktlint: Fix indentation violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
555cd3acb7 ktlint: Fix standard_kdoc violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
ea105e9026 ktlint: Drop non-required violation supressions again
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
e86846ba6a ktlint: Fix spacing-between-declarations-with-annotations violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
dcb2460c81 ktlint: Fix spacing-between-declarations-with-comments violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:44 +08:00
Aayush Gupta
6190db7d2f ktlint: Fix violations regarding statement wrapping
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:43 +08:00
Aayush Gupta
c60339fc58 ktlint: Drop non-required violation supression
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
80a543e7ab ktlint: Fix violation related to comment placement
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
c76d14dfd4 ktlint: Drop unused trailing commas
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
d6f3dee9f4 ktlint: Drop unused semi-colons
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
247cbf3d6f ktlint: Drop non-required violation supressions
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
0641c19388 ktlint: Fix multi-line if-else violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
2c808b0e86 ktlint: Fix ktlint_standard_annotation violations
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:14 +08:00
Aayush Gupta
f23d8eff57 ktlint: Order imports in lexicographic order
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:31:13 +08:00
Aayush Gupta
ed4b77b5aa ktlint: Set codestyle and ignore function naming for Composable
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-24 00:30:04 +08:00
Aayush Gupta
675dbd35d0 Merge pull request #13036 from dustdfg/kotlin_misc_refactor
Misc small kotlin based refactors
2026-01-23 14:51:15 +08:00
Aayush Gupta
eec3ac166a Merge pull request #12936 from TobiGr/uddate-extractor
Update NewPipe Extractor, checkstyle and sonarqube to latest versions
2026-01-22 17:53:14 +08:00
Aayush Gupta
bcb7469d30 Run checkstyle with JDK 21
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-22 17:41:08 +08:00
Aayush Gupta
40d7dcf3d3 Update Gradle to 9.3.0
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-22 17:41:08 +08:00
Aayush Gupta
16415d13ed Update more dependencies to latest stable release
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-22 17:41:08 +08:00
tobigr
3ab414252f Update NewPipe Extractor, checkstyle and sonarqube to latest versions
Add note that new webkit versions require a minSdk >= 23
2026-01-22 17:41:08 +08:00
Aayush Gupta
7d95ec71aa Merge pull request #13019 from dustdfg/orphaned_again
Remove file orphaned for long time
2026-01-19 14:01:48 +08:00
whistlingwoods
cea5dd474b Merge branch dev into reapply-local-list-header-fix-by-j-haldane 2026-01-15 19:24:32 +05:30
Tobi
4ef5aa7b20 Merge pull request #13055 from dustdfg/playlist_add_empty
Fixed a bug that prevented the display of multiple empty playlists
2026-01-15 04:18:29 -08:00
Yevhen Babiichuk (DustDFG)
402f43e895 Fixed a bug that prevented the display of multiple empty playlists
Turned out this bug was already fixed two years ago but unfortunately
it made its way back again. It was solved in #9642 but back then another
method was used for querying playlists from db (for add playlist dialog) then in
ef4a6238c8 was introduced another method
which had the same problem as fixed in #9642 and which eventually replaced
original method in code for querying playlists from db (for add playlist dialog)
2026-01-15 12:27:07 +02:00
Tobi
2f063a78ba Merge pull request #13052 from RinZ27/fix/ci-shell-injection
ci: fix shell injection in backport workflow
2026-01-13 06:36:01 -08:00
RinCodeForge927
7dc38286c0 ci: fix shell injection in backport workflow 2026-01-13 20:42:10 +07:00
Tobi
77d62deeed Merge pull request #13049 from dustdfg/playlist_bug
Fix playlist item dragging video to only neighbor positions
2026-01-12 12:17:47 -08:00
Yevhen Babiichuk (DustDFG)
914feef5e9 Fix playlist item dragging video to only neighbor positions
Call `saveImmediate` only after used actually dropped item instead
of every time View is updated which happens several times to show
user a feedback where item would be moved
2026-01-12 20:35:14 +02:00
TobiGr
4ed2b9748f Merge branch 'master' into dev 2026-01-11 22:53:11 +01:00
Yevhen Babiichuk (DustDFG)
4f0e62e599 Misc small kotlin based refactors
Java file here because it uses kotlin function which returns non null
2026-01-11 17:10:13 +02:00
Hosted Weblate
8e389c49e6 Translated using Weblate (Polish)
Currently translated at 56.3% (49 of 87 strings)

Translated using Weblate (French)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

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

Translated using Weblate (Lombard)

Currently translated at 0.3% (3 of 764 strings)

Translated using Weblate (French)

Currently translated at 75.8% (66 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Tigrinya)

Currently translated at 21.8% (167 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.0% (757 of 764 strings)

Translated using Weblate (Slovak)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Bulgarian)

Currently translated at 3.4% (3 of 87 strings)

Translated using Weblate (Santali)

Currently translated at 1.1% (1 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (764 of 764 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Turkish)

Currently translated at 33.3% (29 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 78.1% (68 of 87 strings)

Translated using Weblate (German)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (French)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (87 of 87 strings)

Translated using Weblate (Slovak)

Currently translated at 74.7% (65 of 87 strings)

Translated using Weblate (Hindi)

Currently translated at 77.0% (67 of 87 strings)

Translated using Weblate (Punjabi)

Currently translated at 68.9% (60 of 87 strings)

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

Co-authored-by: AbsurdlyLongUsername <22662897+absurdlylongusername@users.noreply.github.com>
2026-01-08 17:06:50 -08:00
Siddhesh Dhainje
418e34172a Removed restoreInstanceState and resultServiceIntent condition 2026-01-08 21:31:31 +05:30
Clippy
2704c20fea Merge pull request #13020 from absurdlylongusername/add-note-to-material-component
Add note to upgrade material components once they fix later versions
2026-01-07 23:31:17 +00:00
AbsurdlyLongUsername
a7e4afe7f7 Add note to upgrade material components once they fix later versions 2026-01-07 21:49:42 +00:00
Yevhen Babiichuk (DustDFG)
1eeba8daa7 Remove file orphaned for long time
It was not deleted by mistake, delete it now

Was orphaned at 1d69bd48be
2026-01-07 20:33:35 +02:00
Tobi
6dba8b3c44 Merge pull request #13018 from absurdlylongusername/revert-google-material-components
Revert Google Material Components to 1.11.0
2026-01-07 03:32:30 -08:00
AbsurdlyLongUsername
20b43b521b Revert Google Material Components to 1.11.0 2026-01-07 07:48:32 +00:00
Justin L
08008ca6f9 Fix download resume corruption when server returns HTTP 200
When resuming a download after interruption, if the server returns
HTTP 200 (full resource) instead of HTTP 206 (partial content), the
code correctly resets mMission.done but fails to reset the 'start'
variable. This causes the subsequent file seek to use a stale offset,
writing new data at incorrect positions.

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

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

Fix: Reset start=0 when HTTP 200 is received, ensuring the file
write position correctly restarts from the beginning of the current
resource.
2026-01-06 09:03:44 -05:00
Tobi
25ea75f10e Merge pull request #13005 from dustdfg/db_immediate
Commit all the playlist changes to db immediately
2026-01-05 14:03:30 -08:00
Yevhen Babiichuk (DustDFG)
61c0d134d7 Commit all the playlist changes to db immediately
+ some additional minor code cleanup in the file
2026-01-05 22:59:14 +02:00
Siddhesh Dhainje
a3673f8c3b Used requireArguments instead of getArguments 2026-01-04 21:40:40 +05:30
Tobi
fc66bee429 Merge pull request #13000 from dustdfg/orphaned_history_entry_adapter
Delete long orphaned file
2026-01-04 04:26:03 -08:00
Yevhen Babiichuk (DustDFG)
35eb08baf0 Delete long orphaned file
Was oprhaned at 004c2fa55a
2026-01-04 13:53:43 +02:00
Siddhesh Dhainje
23b7f21d7c Fix crash on screen rotation while entering SoundCloud import URL 2026-01-04 01:06:18 +05:30
whistlingwoods
738338d092 Remove unneeded LayoutInflater from LocalItemListAdapter
Co-Authored-By: j-haldane <200528955+j-haldane@users.noreply.github.com>
2026-01-04 00:43:47 +05:30
whistlingwoods
e758b5f890 Adapt header handling changes from other recyclerview adapters to fix...
... Crash in lists (ViewHolder views not attached) in StatisticsPlaylistFragment

Co-Authored-By: j-haldane <200528955+j-haldane@users.noreply.github.com>
2026-01-04 00:40:25 +05:30
Yevhen Babiichuk (DustDFG)
0747b3a0a5 Use "factory" method for creating db migrations 2026-01-02 12:25:25 +02:00
Aayush Gupta
7283701073 Merge pull request #12978 from dustdfg/kotlin_merged
Conversion to kotlin of multiple files
2026-01-02 15:46:29 +08:00
Yevhen Babiichuk (DustDFG)
3ffcf11a3a Merge inheritors of newpipe/player/playqueue/PlayQueueEvent and
convert it to kotlin
2026-01-01 23:05:16 +02:00
Aayush Gupta
1fb2b4a42e Merge pull request #12981 from dustdfg/export_playlist_refactor
Refactor ExportPlaylist to use more idiomatic kotlin code
2026-01-01 21:40:05 +08:00
Yevhen Babiichuk (DustDFG)
83596ca907 Convert newpipe/settings/preferencesearch/PreferenceSearchResultListener to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
d9682f5e0a Convert newpipe/player/PlayerType to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
ab3314eb1c Convert newpipe/info_list/ItemViewMode to kotlin 2026-01-01 15:33:32 +02:00
Yevhen Babiichuk (DustDFG)
7d1d88fb87 Convert newpipe/local/playlist/PlayListShareMode to kotlin 2026-01-01 15:08:21 +02:00
Yevhen Babiichuk (DustDFG)
8379aa0a9d Refactor ExportPlaylist to use more idiomatic kotlin code 2026-01-01 15:03:29 +02:00
Yevhen Babiichuk (DustDFG)
cd4cb40e6d Convert newpipe/error/UserAction.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
f1b111212d Convert newpipe/util/FilenameUtils.java to kotlin
Co-authored-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
84c646713d Convert newpipe/settings/preferencesearch/PreferenceSearchItem.java to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
873b2be9ca Convert newpipe/util/text/TimestampLongPressClickableSpan.java to kotlin
Also convert one class used by it into java record
2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
4ef4ed15f1 Convert newpipe/util/image/PreferredImageQuality to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
fef8a2455c Convert newpipe/util/image/ImageStrategy to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
3398b4cdc9 Convert newpipe/fragments/list/search/Suggestion{Item,ListAdapter} to kotlin 2026-01-01 11:04:19 +02:00
Yevhen Babiichuk (DustDFG)
74cf302bd6 Convert newpipe/local/playlist/RemotePlaylistManager to kotlin 2026-01-01 11:04:19 +02:00
310 changed files with 3293 additions and 2820 deletions

View File

@@ -6,39 +6,13 @@
root = true
[*.{kt,kts}]
ktlint_standard_annotation = disabled
ktlint_standard_argument-list-wrapping = disabled
ktlint_standard_backing-property-naming = disabled
ktlint_standard_blank-line-before-declaration = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
ktlint_standard_chain-method-continuation = disabled
ktlint_code_style = android_studio
# https://pinterest.github.io/ktlint/latest/rules/standard/#function-naming
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_class-signature = disabled
ktlint_standard_comment-wrapping = disabled
ktlint_standard_enum-wrapping = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_indent = disabled
ktlint_standard_kdoc = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_mixed-condition-operators = disabled
ktlint_standard_multiline-expression-wrapping = disabled
ktlint_standard_multiline-if-else = disabled
ktlint_standard_no-blank-line-in-list = disabled
ktlint_standard_no-consecutive-comments = disabled
ktlint_standard_no-empty-first-line-in-class-body = disabled
ktlint_standard_no-empty-first-line-in-method-block = disabled
ktlint_standard_no-line-break-after-else = disabled
ktlint_standard_no-semi = disabled
ktlint_standard_no-single-line-block-comment = disabled
ktlint_standard_package-name = disabled
ktlint_standard_parameter-list-wrapping = disabled
ktlint_standard_property-naming = disabled
ktlint_standard_spacing-between-declarations-with-annotations = disabled
ktlint_standard_spacing-between-declarations-with-comments = disabled
ktlint_standard_statement-wrapping = disabled
ktlint_standard_string-template-indent = disabled
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled
ktlint_standard_try-catch-finally-spacing = disabled
ktlint_standard_when-entry-bracing = disabled

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

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

View File

@@ -42,9 +42,9 @@ android {
minSdk = 21
targetSdk = 35
versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1006
versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1007
versionName = "0.28.1"
versionName = "0.28.2"
System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -134,6 +134,13 @@ ksp {
// Custom dependency configuration for ktlint
val ktlint by configurations.creating
// https://checkstyle.org/#JRE_and_JDK
tasks.withType<Checkstyle>().configureEach {
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
}
}
checkstyle {
configDirectory = rootProject.file("checkstyle")
isIgnoreFailures = false

View File

@@ -176,28 +176,32 @@ class DatabaseMigrationTest {
databaseInV7.run {
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
"search_history",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
"search_history",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch2)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
"search_history",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
"search_history",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch2)
@@ -207,13 +211,17 @@ class DatabaseMigrationTest {
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_8,
true, Migrations.MIGRATION_7_8
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_8,
true,
Migrations.MIGRATION_7_8
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_9,
true, Migrations.MIGRATION_8_9
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_9,
true,
Migrations.MIGRATION_8_9
)
val migratedDatabaseV8 = getMigratedDatabase()
@@ -235,7 +243,8 @@ class DatabaseMigrationTest {
val remoteUid2: Long
databaseInV8.run {
localUid1 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
"playlists",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "1")
put("is_thumbnail_permanent", false)
@@ -243,7 +252,8 @@ class DatabaseMigrationTest {
}
)
localUid2 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
"playlists",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "2")
put("is_thumbnail_permanent", false)
@@ -251,25 +261,29 @@ class DatabaseMigrationTest {
}
)
delete(
"playlists", "uid = ?",
"playlists",
"uid = ?",
Array(1) { localUid1 }
)
remoteUid1 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
"remote_playlists",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
put("url", DEFAULT_URL)
}
)
remoteUid2 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
"remote_playlists",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SECOND_SERVICE_ID)
put("url", DEFAULT_SECOND_URL)
}
)
delete(
"remote_playlists", "uid = ?",
"remote_playlists",
"uid = ?",
Array(1) { remoteUid2 }
)
close()

View File

@@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.core.Single
import java.io.IOException
import java.time.OffsetDateTime
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -20,9 +22,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamType
import java.io.IOException
import java.time.OffsetDateTime
import kotlin.streams.toList
class FeedDAOTest {
private lateinit var db: AppDatabase
@@ -41,14 +40,21 @@ class FeedDAOTest {
private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
private val allStreams = listOf(
stream1, stream2, stream3, stream4, stream5, stream6, stream7
stream1,
stream2,
stream3,
stream4,
stream5,
stream6,
stream7
)
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java
context,
AppDatabase::class.java
).build()
feedDAO = db.feedDAO()
streamDAO = db.streamDAO()
@@ -65,7 +71,10 @@ class FeedDAOTest {
fun testUnlinkStreamsOlderThan_KeepOne() {
setupUnlinkDelete("2023-08-15T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
FeedGroupEntity.GROUP_ALL_ID,
includePlayed = true,
includePartiallyPlayed = true,
null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream5, stream6, stream7)
@@ -76,7 +85,10 @@ class FeedDAOTest {
fun testUnlinkStreamsOlderThan_KeepMultiple() {
setupUnlinkDelete("2023-08-01T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
FeedGroupEntity.GROUP_ALL_ID,
includePlayed = true,
includePartiallyPlayed = true,
null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7)
@@ -112,7 +124,7 @@ class FeedDAOTest {
SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")),
SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")),
SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")),
SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")),
SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4"))
)
)
feedDAO.insertAll(
@@ -123,7 +135,7 @@ class FeedDAOTest {
FeedEntity(4, 2),
FeedEntity(5, 2),
FeedEntity(6, 3),
FeedEntity(7, 4),
FeedEntity(7, 4)
)
)
}

View File

@@ -1,6 +1,9 @@
package org.schabi.newpipe.local.history
import androidx.test.core.app.ApplicationProvider
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import org.assertj.core.api.Assertions.assertThat
import org.junit.After
import org.junit.Assert.assertEquals
@@ -11,9 +14,6 @@ import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
import org.schabi.newpipe.testUtil.TestDatabase
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
class HistoryRecordManagerTest {
@@ -54,7 +54,7 @@ class HistoryRecordManagerTest {
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 0, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 1, search = "B"),
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B"),
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B")
)
// make sure all 4 were inserted
@@ -85,7 +85,7 @@ class HistoryRecordManagerTest {
val entries = listOf(
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "B"),
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C"),
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C")
)
// make sure all 3 were inserted
@@ -98,7 +98,6 @@ class HistoryRecordManagerTest {
}
private fun insertShuffledRelatedSearches(relatedSearches: Collection<SearchHistoryEntry>) {
// shuffle to make sure the order of items returned by queries depends only on
// SearchHistoryEntry.creationDate, not on the actual insertion time, so that we can
// verify that the `ORDER BY` clause does its job
@@ -121,7 +120,7 @@ class HistoryRecordManagerTest {
RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places)
RELATED_SEARCHES_ENTRIES[4].search, // B
RELATED_SEARCHES_ENTRIES[5].search, // AA
RELATED_SEARCHES_ENTRIES[2].search, // BA
RELATED_SEARCHES_ENTRIES[2].search // BA
)
}
@@ -136,7 +135,7 @@ class HistoryRecordManagerTest {
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 3, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA"),
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA")
)
insertShuffledRelatedSearches(relatedSearches)
@@ -153,7 +152,7 @@ class HistoryRecordManagerTest {
assertThat(searches).containsExactly(
RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places)
RELATED_SEARCHES_ENTRIES[5].search, // AA
RELATED_SEARCHES_ENTRIES[1].search, // BA
RELATED_SEARCHES_ENTRIES[1].search // BA
)
// also make sure that the string comparison is case insensitive
@@ -171,7 +170,7 @@ class HistoryRecordManagerTest {
SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "B"),
SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 2, search = "AA"),
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A")
)
}
}

View File

@@ -33,8 +33,12 @@ class LocalPlaylistManagerTest {
fun createPlaylist() {
val NEWPIPE_URL = "https://newpipe.net/"
val stream = StreamEntity(
serviceId = 1, url = NEWPIPE_URL, title = "title",
streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader",
serviceId = 1,
url = NEWPIPE_URL,
title = "title",
streamType = StreamType.VIDEO_STREAM,
duration = 1,
uploader = "uploader",
uploaderUrl = NEWPIPE_URL
)
@@ -58,14 +62,22 @@ class LocalPlaylistManagerTest {
@Test()
fun createPlaylist_nonExistentStreamsAreUpserted() {
val stream = StreamEntity(
serviceId = 1, url = "https://newpipe.net/", title = "title",
streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader",
serviceId = 1,
url = "https://newpipe.net/",
title = "title",
streamType = StreamType.VIDEO_STREAM,
duration = 1,
uploader = "uploader",
uploaderUrl = "https://newpipe.net/"
)
database.streamDAO().insert(stream)
val upserted = StreamEntity(
serviceId = 1, url = "https://newpipe.net/2", title = "title2",
streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader",
serviceId = 1,
url = "https://newpipe.net/2",
title = "title2",
streamType = StreamType.VIDEO_STREAM,
duration = 1,
uploader = "uploader",
uploaderUrl = "https://newpipe.net/"
)

View File

@@ -17,21 +17,20 @@ class TrampolineSchedulerRule : TestRule {
private val scheduler = Schedulers.trampoline()
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
try {
RxJavaPlugins.setComputationSchedulerHandler { scheduler }
RxJavaPlugins.setIoSchedulerHandler { scheduler }
RxJavaPlugins.setNewThreadSchedulerHandler { scheduler }
RxJavaPlugins.setSingleSchedulerHandler { scheduler }
RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler }
override fun apply(base: Statement, description: Description): Statement = object : Statement() {
override fun evaluate() {
try {
RxJavaPlugins.setComputationSchedulerHandler { scheduler }
RxJavaPlugins.setIoSchedulerHandler { scheduler }
RxJavaPlugins.setNewThreadSchedulerHandler { scheduler }
RxJavaPlugins.setSingleSchedulerHandler { scheduler }
RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler }
base.evaluate()
} finally {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
}
base.evaluate()
} finally {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
}
}
}
}

View File

@@ -156,41 +156,51 @@ class StreamItemAdapterTest {
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))),
1
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))),
2
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3
getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))),
3
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))),
4
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))),
5, MediaFormat.OGG
5,
MediaFormat.OGG
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))),
6, MediaFormat.FLAC
6,
MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))),
7, MediaFormat.AIFF
7,
MediaFormat.AIFF
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))),
8, MediaFormat.M4A
8,
MediaFormat.M4A
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))),
9, MediaFormat.OPUS
9,
MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))),
10, MediaFormat.OPUS
10,
MediaFormat.OPUS
)
}
@@ -213,16 +223,24 @@ class StreamItemAdapterTest {
helper.assertInvalidResponse(getResponse(mapOf()), 7)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC
getResponse(mapOf(Pair("Content-Type", "audio/flac"))),
8,
MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV
getResponse(mapOf(Pair("Content-Type", "audio/wav"))),
9,
MediaFormat.WAV
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS
getResponse(mapOf(Pair("Content-Type", "audio/opus"))),
10,
MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF
getResponse(mapOf(Pair("Content-Type", "audio/aiff"))),
11,
MediaFormat.AIFF
)
}
@@ -230,39 +248,37 @@ class StreamItemAdapterTest {
* @return a list of video streams, in which their video only property mirrors the provided
* [videoOnly] vararg.
*/
private fun getVideoStreams(vararg videoOnly: Boolean) =
StreamItemAdapter.StreamInfoWrapper(
videoOnly.map {
VideoStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com", true)
.setMediaFormat(MediaFormat.MPEG_4)
.setResolution("720p")
.setIsVideoOnly(it)
.build()
},
context
)
private fun getVideoStreams(vararg videoOnly: Boolean) = StreamInfoWrapper(
videoOnly.map {
VideoStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com", true)
.setMediaFormat(MediaFormat.MPEG_4)
.setResolution("720p")
.setIsVideoOnly(it)
.build()
},
context
)
/**
* @return a list of audio streams, containing valid and null elements mirroring the provided
* [shouldBeValid] vararg.
*/
private fun getAudioStreams(vararg shouldBeValid: Boolean) =
getSecondaryStreamsFromList(
shouldBeValid.map {
if (it) {
AudioStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com", true)
.setMediaFormat(MediaFormat.OPUS)
.setAverageBitrate(192)
.build()
} else {
null
}
private fun getAudioStreams(vararg shouldBeValid: Boolean) = getSecondaryStreamsFromList(
shouldBeValid.map {
if (it) {
AudioStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com", true)
.setMediaFormat(MediaFormat.OPUS)
.setAverageBitrate(192)
.build()
} else {
null
}
)
}
)
private fun getIncompleteAudioStreams(size: Int): List<AudioStream> {
val list = ArrayList<AudioStream>(size)
@@ -292,7 +308,7 @@ class StreamItemAdapterTest {
Assert.assertEquals(
"normal visibility (pos=[$position]) is not correct",
findViewById<View>(R.id.wo_sound_icon).visibility,
normalVisibility,
normalVisibility
)
}
spinner.adapter.getDropDownView(position, null, spinner).run {
@@ -307,18 +323,17 @@ class StreamItemAdapterTest {
/**
* Helper function that builds a secondary stream list.
*/
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
SparseArrayCompat<SecondaryStreamHelper<T>?>(streams.size).apply {
streams.forEachIndexed { index, stream ->
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
SecondaryStreamHelper(
StreamItemAdapter.StreamInfoWrapper(streams, context),
it
)
}
put(index, secondaryStreamHelper)
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) = SparseArrayCompat<SecondaryStreamHelper<T>?>(streams.size).apply {
streams.forEachIndexed { index, stream ->
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
SecondaryStreamHelper(
StreamItemAdapter.StreamInfoWrapper(streams, context),
it
)
}
put(index, secondaryStreamHelper)
}
}
private fun getResponse(headers: Map<String, String>): Response {
val listHeaders = HashMap<String, List<String>>()
@@ -345,7 +360,8 @@ class StreamItemAdapterTest {
index: Int
) {
assertFalse(
"invalid header returns valid value", retrieveMediaFormat(streams[index], response)
"invalid header returns valid value",
retrieveMediaFormat(streams[index], response)
)
assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index))
}
@@ -359,7 +375,8 @@ class StreamItemAdapterTest {
format: MediaFormat
) {
assertTrue(
"header was not recognized", retrieveMediaFormat(streams[index], response)
"header was not recognized",
retrieveMediaFormat(streams[index], response)
)
assertEquals("Wrong media format extracted", format, wrapper.getFormat(index))
}

View File

@@ -161,9 +161,7 @@ public final class DownloaderImpl extends Downloader {
String responseBodyToReturn = null;
try (ResponseBody body = response.body()) {
if (body != null) {
responseBodyToReturn = body.string();
}
responseBodyToReturn = body.string();
}
final String latestUrl = response.request().url().toString();

View File

@@ -8,6 +8,7 @@ package org.schabi.newpipe
import android.content.Context
import androidx.room.Room.databaseBuilder
import kotlin.concurrent.Volatile
import org.schabi.newpipe.database.AppDatabase
import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
@@ -17,7 +18,6 @@ import org.schabi.newpipe.database.Migrations.MIGRATION_5_6
import org.schabi.newpipe.database.Migrations.MIGRATION_6_7
import org.schabi.newpipe.database.Migrations.MIGRATION_7_8
import org.schabi.newpipe.database.Migrations.MIGRATION_8_9
import kotlin.concurrent.Volatile
object NewPipeDatabase {

View File

@@ -18,10 +18,10 @@ import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonParserException
import java.io.IOException
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.util.ReleaseVersionUtil
import java.io.IOException
class NewVersionWorker(
context: Context,
@@ -46,7 +46,8 @@ class NewVersionWorker(
// Show toast stating that the app is up-to-date if the update check was manual.
ContextCompat.getMainExecutor(applicationContext).execute {
Toast.makeText(
applicationContext, R.string.app_update_unavailable_toast,
applicationContext,
R.string.app_update_unavailable_toast,
Toast.LENGTH_SHORT
).show()
}
@@ -58,7 +59,11 @@ class NewVersionWorker(
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntentCompat.getActivity(
applicationContext, 0, intent, 0, false
applicationContext,
0,
intent,
0,
false
)
val channelId = applicationContext.getString(R.string.app_update_notification_channel_id)
val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
@@ -71,7 +76,8 @@ class NewVersionWorker(
)
.setContentText(
applicationContext.getString(
R.string.app_update_available_notification_text, versionName
R.string.app_update_available_notification_text,
versionName
)
)

View File

@@ -116,86 +116,145 @@ class AboutActivity : AppCompatActivity() {
*/
private val SOFTWARE_COMPONENTS = arrayListOf(
SoftwareComponent(
"ACRA", "2013", "Kevin Gaudin",
"https://github.com/ACRA/acra", StandardLicenses.APACHE2
"ACRA",
"2013",
"Kevin Gaudin",
"https://github.com/ACRA/acra",
StandardLicenses.APACHE2
),
SoftwareComponent(
"AndroidX", "2005 - 2011", "The Android Open Source Project",
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
"AndroidX",
"2005 - 2011",
"The Android Open Source Project",
"https://developer.android.com/jetpack",
StandardLicenses.APACHE2
),
SoftwareComponent(
"ExoPlayer", "2014 - 2020", "Google, Inc.",
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
"ExoPlayer",
"2014 - 2020",
"Google, Inc.",
"https://github.com/google/ExoPlayer",
StandardLicenses.APACHE2
),
SoftwareComponent(
"GigaGet", "2014 - 2015", "Peter Cai",
"https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3
"GigaGet",
"2014 - 2015",
"Peter Cai",
"https://github.com/PaperAirplane-Dev-Team/GigaGet",
StandardLicenses.GPL3
),
SoftwareComponent(
"Groupie", "2016", "Lisa Wray",
"https://github.com/lisawray/groupie", StandardLicenses.MIT
"Groupie",
"2016",
"Lisa Wray",
"https://github.com/lisawray/groupie",
StandardLicenses.MIT
),
SoftwareComponent(
"Android-State", "2018", "Evernote",
"https://github.com/Evernote/android-state", StandardLicenses.EPL1
"Android-State",
"2018",
"Evernote",
"https://github.com/Evernote/android-state",
StandardLicenses.EPL1
),
SoftwareComponent(
"Bridge", "2021", "Livefront",
"https://github.com/livefront/bridge", StandardLicenses.APACHE2
"Bridge",
"2021",
"Livefront",
"https://github.com/livefront/bridge",
StandardLicenses.APACHE2
),
SoftwareComponent(
"Jsoup", "2009 - 2020", "Jonathan Hedley",
"https://github.com/jhy/jsoup", StandardLicenses.MIT
"Jsoup",
"2009 - 2020",
"Jonathan Hedley",
"https://github.com/jhy/jsoup",
StandardLicenses.MIT
),
SoftwareComponent(
"Markwon", "2019", "Dimitry Ivanov",
"https://github.com/noties/Markwon", StandardLicenses.APACHE2
"Markwon",
"2019",
"Dimitry Ivanov",
"https://github.com/noties/Markwon",
StandardLicenses.APACHE2
),
SoftwareComponent(
"Material Components for Android", "2016 - 2020", "Google, Inc.",
"Material Components for Android",
"2016 - 2020",
"Google, Inc.",
"https://github.com/material-components/material-components-android",
StandardLicenses.APACHE2
),
SoftwareComponent(
"NewPipe Extractor", "2017 - 2020", "Christian Schabesberger",
"https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3
"NewPipe Extractor",
"2017 - 2020",
"Christian Schabesberger",
"https://github.com/TeamNewPipe/NewPipeExtractor",
StandardLicenses.GPL3
),
SoftwareComponent(
"NoNonsense-FilePicker", "2016", "Jonas Kalderstam",
"https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2
"NoNonsense-FilePicker",
"2016",
"Jonas Kalderstam",
"https://github.com/spacecowboy/NoNonsense-FilePicker",
StandardLicenses.MPL2
),
SoftwareComponent(
"OkHttp", "2019", "Square, Inc.",
"https://square.github.io/okhttp/", StandardLicenses.APACHE2
"OkHttp",
"2019",
"Square, Inc.",
"https://square.github.io/okhttp/",
StandardLicenses.APACHE2
),
SoftwareComponent(
"Picasso", "2013", "Square, Inc.",
"https://square.github.io/picasso/", StandardLicenses.APACHE2
"Picasso",
"2013",
"Square, Inc.",
"https://square.github.io/picasso/",
StandardLicenses.APACHE2
),
SoftwareComponent(
"PrettyTime", "2012 - 2020", "Lincoln Baxter, III",
"https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2
"PrettyTime",
"2012 - 2020",
"Lincoln Baxter, III",
"https://github.com/ocpsoft/prettytime",
StandardLicenses.APACHE2
),
SoftwareComponent(
"ProcessPhoenix", "2015", "Jake Wharton",
"https://github.com/JakeWharton/ProcessPhoenix", StandardLicenses.APACHE2
"ProcessPhoenix",
"2015",
"Jake Wharton",
"https://github.com/JakeWharton/ProcessPhoenix",
StandardLicenses.APACHE2
),
SoftwareComponent(
"RxAndroid", "2015", "The RxAndroid authors",
"https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2
"RxAndroid",
"2015",
"The RxAndroid authors",
"https://github.com/ReactiveX/RxAndroid",
StandardLicenses.APACHE2
),
SoftwareComponent(
"RxBinding", "2015", "Jake Wharton",
"https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2
"RxBinding",
"2015",
"Jake Wharton",
"https://github.com/JakeWharton/RxBinding",
StandardLicenses.APACHE2
),
SoftwareComponent(
"RxJava", "2016 - 2020", "RxJava Contributors",
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
"RxJava",
"2016 - 2020",
"RxJava Contributors",
"https://github.com/ReactiveX/RxJava",
StandardLicenses.APACHE2
),
SoftwareComponent(
"SearchPreference", "2018", "ByteHamster",
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
),
"SearchPreference",
"2018",
"ByteHamster",
"https://github.com/ByteHamster/SearchPreference",
StandardLicenses.MIT
)
)
}
}

View File

@@ -1,8 +1,8 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
import kotlinx.parcelize.Parcelize
/**
* Class for storing information about a software license.

View File

@@ -97,7 +97,8 @@ class LicenseFragment : Fragment() {
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
formattedLicense.toByteArray(), Base64.NO_PADDING
formattedLicense.toByteArray(),
Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")

View File

@@ -1,9 +1,9 @@
package org.schabi.newpipe.about
import android.content.Context
import java.io.IOException
import org.schabi.newpipe.R
import org.schabi.newpipe.util.ThemeHelper
import java.io.IOException
/**
* @param context the context to use
@@ -28,13 +28,16 @@ fun getFormattedLicense(context: Context, license: License): String {
fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
val licenseBackgroundColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
context,
if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
)
val licenseTextColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
context,
if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
)
val youtubePrimaryColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
context,
if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
)
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"

View File

@@ -1,8 +1,8 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
import kotlinx.parcelize.Parcelize
@Parcelize
class SoftwareComponent

View File

@@ -1,11 +1,11 @@
package org.schabi.newpipe.database
import androidx.room.TypeConverter
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
class Converters {
/**

View File

@@ -14,6 +14,6 @@ interface LocalItem {
PLAYLIST_REMOTE_ITEM,
PLAYLIST_STREAM_ITEM,
STATISTIC_STREAM_ITEM,
STATISTIC_STREAM_ITEM
}
}

View File

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

View File

@@ -8,6 +8,7 @@ import androidx.room.Transaction
import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import java.time.OffsetDateTime
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
@@ -15,7 +16,6 @@ import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime
@Dao
abstract class FeedDAO {

View File

@@ -19,13 +19,17 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
entity = StreamEntity::class,
parentColumns = [StreamEntity.STREAM_ID],
childColumns = [STREAM_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true
),
ForeignKey(
entity = SubscriptionEntity::class,
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
childColumns = [SUBSCRIPTION_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true
)
]
)

View File

@@ -18,14 +18,18 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
entity = FeedGroupEntity::class,
parentColumns = [FeedGroupEntity.ID],
childColumns = [GROUP_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true
),
ForeignKey(
entity = SubscriptionEntity::class,
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
childColumns = [SUBSCRIPTION_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true
)
]
)

View File

@@ -4,10 +4,10 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import java.time.OffsetDateTime
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime
@Entity(
tableName = FEED_LAST_UPDATED_TABLE,
@@ -16,7 +16,9 @@ import java.time.OffsetDateTime
entity = SubscriptionEntity::class,
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
childColumns = [SUBSCRIPTION_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
deferred = true
)
]
)

View File

@@ -29,7 +29,7 @@ data class SearchHistoryEntry @JvmOverloads constructor(
@ColumnInfo(name = ID)
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val id: Long = 0
) {
@Ignore

View File

@@ -11,12 +11,12 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import java.time.OffsetDateTime
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import java.time.OffsetDateTime
/**
* @param streamUid the stream id this history item will refer to

View File

@@ -2,10 +2,10 @@ package org.schabi.newpipe.database.history.model
import androidx.room.ColumnInfo
import androidx.room.Embedded
import java.time.OffsetDateTime
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime
data class StreamHistoryEntry(
@Embedded
@@ -30,16 +30,15 @@ data class StreamHistoryEntry(
accessDate.isEqual(other.accessDate)
}
fun toStreamInfoItem(): StreamInfoItem =
StreamInfoItem(
streamEntity.serviceId,
streamEntity.url,
streamEntity.title,
streamEntity.streamType,
).apply {
duration = streamEntity.duration
uploaderName = streamEntity.uploader
uploaderUrl = streamEntity.uploaderUrl
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
fun toStreamInfoItem(): StreamInfoItem = StreamInfoItem(
streamEntity.serviceId,
streamEntity.url,
streamEntity.title,
streamEntity.streamType
).apply {
duration = streamEntity.duration
uploaderName = streamEntity.uploader
uploaderUrl = streamEntity.uploaderUrl
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
}

View File

@@ -68,6 +68,11 @@ interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
)
fun getOrderedStreamsOf(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>>
// If a playlist has no streams, there wont be any rows in the **playlist_stream_join** table
// that have a foreign key to that playlist. Thus, the **playlist_id** will not have a
// corresponding value in any rows of the join table. So, if you group by the **playlist_id**,
// only playlists that contain videos are grouped and displayed. Look at #9642 #13055
@Transaction
@Query(
"""
@@ -103,6 +108,11 @@ interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
)
fun getStreamsWithoutDuplicates(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>>
// If a playlist has no streams, there wont be any rows in the **playlist_stream_join** table
// that have a foreign key to that playlist. Thus, the **playlist_id** will not have a
// corresponding value in any rows of the join table. So, if you group by the **playlist_id**,
// only playlists that contain videos are grouped and displayed. Look at #9642 #13055
@Transaction
@Query(
"""
@@ -118,7 +128,7 @@ interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
LEFT JOIN streams
ON streams.uid = stream_id AND :streamUrl = :streamUrl
GROUP BY playlist_id
GROUP BY playlists.uid
ORDER BY display_index, name
"""
)

View File

@@ -37,7 +37,7 @@ data class PlaylistEntity @JvmOverloads constructor(
name = item.orderingName,
isThumbnailPermanent = item.isThumbnailPermanent!!,
thumbnailStreamId = item.thumbnailStreamId!!,
displayIndex = item.displayIndex!!,
displayIndex = item.displayIndex!!
)
companion object {

View File

@@ -9,13 +9,13 @@ package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Ignore
import java.time.OffsetDateTime
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime
data class StreamStatisticsEntry(
@Embedded

View File

@@ -8,12 +8,12 @@ import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import java.time.OffsetDateTime
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.StreamTypeUtil
import java.time.OffsetDateTime
@Dao
abstract class StreamDAO : BasicDAO<StreamEntity> {
@@ -91,7 +91,6 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
newerStream.uid = existentMinimalStream.uid
if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
// Use the existent upload date if the newer stream does not have a better precision
// (i.e. is an approximation). This is done to prevent unnecessary changes.
val hasBetterPrecision =

View File

@@ -5,6 +5,8 @@ import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import java.io.Serializable
import java.time.OffsetDateTime
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
@@ -14,8 +16,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.io.Serializable
import java.time.OffsetDateTime
@Entity(
tableName = STREAM_TABLE,
@@ -86,8 +86,12 @@ data class StreamEntity(
@Ignore
constructor(item: PlayQueueItem) : this(
serviceId = item.serviceId, url = item.url, title = item.title,
streamType = item.streamType, duration = item.duration, uploader = item.uploader,
serviceId = item.serviceId,
url = item.url,
title = item.title,
streamType = item.streamType,
duration = item.duration,
uploader = item.uploader,
uploaderUrl = item.uploaderUrl,
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails)
)

View File

@@ -7,6 +7,7 @@ import androidx.core.content.ContextCompat
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.Loader
import java.net.UnknownHostException
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info
@@ -28,7 +29,6 @@ import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentExcepti
import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.player.mediasource.FailedMediaSource
import org.schabi.newpipe.player.resolver.PlaybackResolver
import java.net.UnknownHostException
/**
* An error has occurred in the app. This class contains plain old parcelable data that can be used
@@ -59,7 +59,7 @@ class ErrorInfo private constructor(
* If present, this resource can alternatively be opened in browser (useful if NewPipe is
* badly broken).
*/
val openInBrowserUrl: String?,
val openInBrowserUrl: String?
) : Parcelable {
@JvmOverloads
@@ -68,7 +68,7 @@ class ErrorInfo private constructor(
userAction: UserAction,
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
openInBrowserUrl: String? = null
) : this(
throwableToStringList(throwable),
userAction,
@@ -78,7 +78,7 @@ class ErrorInfo private constructor(
isReportable(throwable),
isRetryable(throwable),
(throwable as? ReCaptchaException)?.url,
openInBrowserUrl,
openInBrowserUrl
)
@JvmOverloads
@@ -87,7 +87,7 @@ class ErrorInfo private constructor(
userAction: UserAction,
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
openInBrowserUrl: String? = null
) : this(
throwableListToStringList(throwables),
userAction,
@@ -97,7 +97,7 @@ class ErrorInfo private constructor(
throwables.any(::isReportable),
throwables.isEmpty() || throwables.any(::isRetryable),
throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
openInBrowserUrl,
openInBrowserUrl
)
// constructor to manually build ErrorInfo when no throwable is available
@@ -118,7 +118,7 @@ class ErrorInfo private constructor(
throwable: Throwable,
userAction: UserAction,
request: String,
info: Info?,
info: Info?
) :
this(throwable, userAction, request, info?.serviceId, info?.url)
@@ -127,7 +127,7 @@ class ErrorInfo private constructor(
throwables: List<Throwable>,
userAction: UserAction,
request: String,
info: Info?,
info: Info?
) :
this(throwables, userAction, request, info?.serviceId, info?.url)
@@ -144,7 +144,7 @@ class ErrorInfo private constructor(
class ErrorMessage(
@StringRes
private val stringRes: Int,
private vararg val formatArgs: String,
private vararg val formatArgs: String
) : Parcelable {
fun getString(context: Context): String {
return if (formatArgs.isEmpty()) {
@@ -160,21 +160,19 @@ class ErrorInfo private constructor(
const val SERVICE_NONE = "<unknown_service>"
private fun getServiceName(serviceId: Int?) =
// not using getNameOfServiceById since we want to accept a nullable serviceId and we
private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
// want to default to SERVICE_NONE
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
?: SERVICE_NONE
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
fun throwableListToStringList(throwableList: List<Throwable>) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
fun throwableListToStringList(throwableList: List<Throwable>) = throwableList.map { it.stackTraceToString() }.toTypedArray()
fun getMessage(
throwable: Throwable?,
action: UserAction?,
serviceId: Int?,
serviceId: Int?
): ErrorMessage {
return when {
// player exceptions
@@ -193,18 +191,24 @@ class ErrorInfo private constructor(
ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
}
}
cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
getMessage(throwable, action, serviceId)
throwable.type == ExoPlaybackException.TYPE_SOURCE ->
ErrorMessage(R.string.player_stream_failure)
throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
ErrorMessage(R.string.player_recoverable_failure)
else ->
ErrorMessage(R.string.player_unrecoverable_failure)
}
}
throwable is FailedMediaSource.FailedMediaSourceException ->
getMessage(throwable.cause, action, serviceId)
throwable is PlaybackResolver.ResolverException ->
ErrorMessage(R.string.player_stream_failure)
@@ -220,34 +224,46 @@ class ErrorInfo private constructor(
)
}
?: ErrorMessage(R.string.account_terminated)
throwable is AgeRestrictedContentException ->
ErrorMessage(R.string.restricted_video_no_stream)
throwable is GeographicRestrictionException ->
ErrorMessage(R.string.georestricted_content)
throwable is PaidContentException ->
ErrorMessage(R.string.paid_content)
throwable is PrivateContentException ->
ErrorMessage(R.string.private_content)
throwable is SoundCloudGoPlusContentException ->
ErrorMessage(R.string.soundcloud_go_plus_content)
throwable is UnsupportedContentInCountryException ->
ErrorMessage(R.string.unsupported_content_in_country)
throwable is YoutubeMusicPremiumContentException ->
ErrorMessage(R.string.youtube_music_premium_content)
throwable is SignInConfirmNotBotException ->
ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
throwable is ContentNotAvailableException ->
ErrorMessage(R.string.content_not_available)
// other extractor exceptions
throwable is ContentNotSupportedException ->
ErrorMessage(R.string.content_not_supported)
// ReCaptchas will be handled in a special way anyway
throwable is ReCaptchaException ->
ErrorMessage(R.string.recaptcha_request_toast)
// test this at the end as many exceptions could be a subclass of IOException
throwable != null && throwable.isNetworkRelated ->
ErrorMessage(R.string.network_error)
// an extraction exception unrelated to the network
// is likely an issue with parsing the website
throwable is ExtractionException ->
@@ -256,16 +272,22 @@ class ErrorInfo private constructor(
// user actions (in case the exception is null or unrecognizable)
action == UserAction.UI_ERROR ->
ErrorMessage(R.string.app_ui_crash)
action == UserAction.REQUESTED_COMMENTS ->
ErrorMessage(R.string.error_unable_to_load_comments)
action == UserAction.SUBSCRIPTION_CHANGE ->
ErrorMessage(R.string.subscription_change_failed)
action == UserAction.SUBSCRIPTION_UPDATE ->
ErrorMessage(R.string.subscription_update_failed)
action == UserAction.LOAD_IMAGE ->
ErrorMessage(R.string.could_not_load_thumbnails)
action == UserAction.DOWNLOAD_OPEN_DIALOG ->
ErrorMessage(R.string.could_not_setup_download_menu)
else ->
ErrorMessage(R.string.error_snackbar_message)
}
@@ -276,15 +298,19 @@ class ErrorInfo private constructor(
// we don't have an exception, so this is a manually built error, which likely
// indicates that it's important and is thus reportable
null -> true
// the service explicitly said that content is not available (e.g. age restrictions,
// video deleted, etc.), there is no use in letting users report it
is ContentNotAvailableException -> false
// if the service explicitly said that content is not available (e.g. age
// restrictions, video deleted, etc.), there is no use in letting users report it
is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable)
// we know the content is not supported, no need to let the user report it
is ContentNotSupportedException -> false
// happens often when there is no internet connection; we don't use
// `throwable.isNetworkRelated` since any `IOException` would make that function
// return true, but not all `IOException`s are network related
is UnknownHostException -> false
// by default, this is an unexpected exception, which the user could report
else -> true
}
@@ -292,14 +318,39 @@ class ErrorInfo private constructor(
fun isRetryable(throwable: Throwable?): Boolean {
return when (throwable) {
// we know the content is not available, retrying won't help
is ContentNotAvailableException -> false
// if we know the content is surely not available, retrying won't help
is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable)
// we know the content is not supported, retrying won't help
is ContentNotSupportedException -> false
// by default (including if throwable is null), enable retrying (though the retry
// button will be shown only if a way to perform the retry is implemented)
else -> true
}
}
/**
* Unfortunately sometimes [ContentNotAvailableException] may not indicate that the content
* is blocked/deleted/paid, but may just indicate that we could not extract it. This is an
* inconsistency in the exceptions thrown by the extractor, but until it is fixed, this
* function will distinguish between the two types.
* @return `true` if the content is not available because of a limitation imposed by the
* service or the owner, `false` if the extractor could not extract info about it
*/
fun isContentSurelyNotAvailable(e: ContentNotAvailableException): Boolean {
return when (e) {
is AccountTerminatedException,
is AgeRestrictedContentException,
is GeographicRestrictionException,
is PaidContentException,
is PrivateContentException,
is SoundCloudGoPlusContentException,
is UnsupportedContentInCountryException,
is YoutubeMusicPremiumContentException -> true
else -> false
}
}
}
}

View File

@@ -11,16 +11,16 @@ import androidx.fragment.app.Fragment
import com.jakewharton.rxbinding4.view.clicks
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.concurrent.TimeUnit
class ErrorPanelHelper(
private val fragment: Fragment,
rootView: View,
onRetry: Runnable?,
onRetry: Runnable?
) {
private val context: Context = rootView.context!!

View File

@@ -46,7 +46,7 @@ class ErrorUtil {
@JvmStatic
fun openActivity(context: Context, errorInfo: ErrorInfo) {
if (PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
) {
createNotification(context, errorInfo)
} else {

View File

@@ -1,9 +1,14 @@
package org.schabi.newpipe.error;
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.error
/**
* The user actions that can cause an error.
*/
public enum UserAction {
enum class UserAction(val message: String) {
USER_REPORT("user report"),
UI_ERROR("ui error"),
DATABASE_IMPORT_EXPORT("database import or export"),
@@ -35,15 +40,5 @@ public enum UserAction {
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
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;
}
SUBSCRIPTIONS("loading subscriptions")
}

View File

@@ -206,8 +206,6 @@ public final class VideoDetailFragment
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
@State
protected boolean autoPlayEnabled = true;
@State
protected int originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
@Nullable
private StreamInfo currentInfo = null;
@@ -1424,8 +1422,10 @@ public final class VideoDetailFragment
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
// Rebound to the service if it was closed via notification or mini player
playerHolder.setListener(VideoDetailFragment.this);
playerHolder.tryBindIfNeeded(context);
if (!playerHolder.isBound()) {
playerHolder.startService(
false, VideoDetailFragment.this);
}
break;
}
}
@@ -1908,29 +1908,23 @@ public final class VideoDetailFragment
@Override
public void onScreenRotationButtonClicked() {
final Optional<MainPlayerUi> playerUi = player != null
? player.UIs().get(MainPlayerUi.class)
: Optional.empty();
if (playerUi.isEmpty()) {
// On Android TV screen rotation is not supported
// In tablet user experience will be better if screen will not be rotated
// from landscape to portrait every time.
// Just turn on fullscreen mode in landscape orientation
// or portrait & unlocked global orientation
final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
if (DeviceUtils.isTv(activity) || DeviceUtils.isTablet(activity)
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
return;
}
// On tablets and TVs, just toggle fullscreen UI without orientation change.
if (DeviceUtils.isTablet(activity) || DeviceUtils.isTv(activity)) {
playerUi.get().toggleFullscreen();
return;
}
final int newOrientation = isLandscape
? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
if (playerUi.get().isFullscreen()) {
// EXITING FULLSCREEN
playerUi.get().toggleFullscreen();
activity.setRequestedOrientation(originalOrientation);
} else {
// ENTERING FULLSCREEN
originalOrientation = activity.getRequestedOrientation();
playerUi.get().toggleFullscreen();
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
}
activity.setRequestedOrientation(newOrientation);
}
/*

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,29 @@
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.
*/
AUTO,
/**
* Full width list item with thumb on the left and two line title & uploader in right.
*/
LIST,
/**
* Grid mode places two cards per row.
*/
GRID,
/**
* A full width card in phone - portrait.
*/

View File

@@ -2,8 +2,8 @@ package org.schabi.newpipe.info_list
import android.util.Log
import com.xwray.groupie.GroupieAdapter
import org.schabi.newpipe.extractor.stream.StreamInfo
import kotlin.math.max
import org.schabi.newpipe.extractor.stream.StreamInfo
/**
* Custom RecyclerView.Adapter/GroupieAdapter for [StreamSegmentItem] for handling selection state.

View File

@@ -41,7 +41,10 @@ class StreamSegmentItem(
viewHolder.root.findViewById<TextView>(R.id.textViewStartSeconds).text =
Localization.getDurationString(item.startTimeSeconds.toLong())
viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) }
viewHolder.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true }
viewHolder.root.setOnLongClickListener {
onClick.onItemLongClick(this, item.startTimeSeconds)
true
}
viewHolder.root.isSelected = isSelected
}

View File

@@ -41,14 +41,16 @@ fun View.animate(
execOnEnd: Runnable? = null
) {
if (DEBUG) {
val id = try {
resources.getResourceEntryName(id)
} catch (e: Exception) {
id.toString()
}
val id = runCatching { resources.getResourceEntryName(id) }.getOrDefault(id.toString())
val msg = String.format(
"%8s → [%s:%s] [%s %s:%s] execOnEnd=%s", enterOrExit,
javaClass.simpleName, id, animationType, duration, delay, execOnEnd
"%8s → [%s:%s] [%s %s:%s] execOnEnd=%s",
enterOrExit,
javaClass.simpleName,
id,
animationType,
duration,
delay,
execOnEnd
)
Log.d(TAG, "animate(): $msg")
}
@@ -291,5 +293,9 @@ private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnab
}
enum class AnimationType {
ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA
ALPHA,
SCALE_AND_ALPHA,
LIGHT_SCALE_AND_ALPHA,
SLIDE_AND_ALPHA,
LIGHT_SLIDE_AND_ALPHA
}

View File

@@ -28,6 +28,8 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
import java.util.function.Supplier;
/**
* This fragment is design to be used with persistent data such as
* {@link org.schabi.newpipe.database.LocalItem}, and does not cache the data contained
@@ -100,7 +102,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
//////////////////////////////////////////////////////////////////////////*/
@Nullable
protected ViewBinding getListHeader() {
protected Supplier<View> getListHeaderSupplier() {
return null;
}
@@ -131,9 +133,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
itemsList = rootView.findViewById(R.id.items_list);
refreshItemViewMode();
headerRootBinding = getListHeader();
if (headerRootBinding != null) {
itemListAdapter.setHeader(headerRootBinding.getRoot());
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
if (listHeaderSupplier != null) {
itemListAdapter.setHeaderSupplier(listHeaderSupplier);
}
footerRootBinding = getListFooter();
itemListAdapter.setFooter(footerRootBinding.getRoot());
@@ -210,6 +212,8 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
showListFooter(false);
}
@Deprecated(since = "Calling this method with `true` may cause crashes, see "
+ "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115")
@Override
public void showListFooter(final boolean show) {
if (itemsList == null) {

View File

@@ -37,6 +37,7 @@ import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
/*
* Created by Christian Schabesberger on 01.08.16.
@@ -88,7 +89,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private final DateTimeFormatter dateTimeFormatter;
private boolean showFooter = false;
private View header = null;
private Supplier<View> headerSupplier = null;
private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST;
private boolean useItemHandle = false;
@@ -97,6 +98,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
recordManager = new HistoryRecordManager(context);
localItemBuilder = new LocalItemBuilder(context);
localItems = new ArrayList<>();
dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
.withLocale(Localization.getPreferredLocale(context));
}
@@ -124,7 +126,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
if (DEBUG) {
Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + ", "
+ "localItems.size() = " + localItems.size() + ", "
+ "header = " + header + ", footer = " + footer + ", "
+ "header = " + hasHeader() + ", footer = " + footer + ", "
+ "showFooter = " + showFooter);
}
notifyItemRangeInserted(offsetStart, data.size());
@@ -144,7 +146,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
final int index = localItems.indexOf(data);
if (index != -1) {
localItems.remove(index);
notifyItemRemoved(index + (header != null ? 1 : 0));
notifyItemRemoved(index + (hasHeader() ? 1 : 0));
} else {
// this happens when
// 1) removeItem is called on infoItemDuplicate as in showStreamItemDialog of
@@ -189,9 +191,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
this.useItemHandle = useItemHandle;
}
public void setHeader(final View header) {
final boolean changed = header != this.header;
this.header = header;
public void setHeaderSupplier(@Nullable final Supplier<View> headerSupplier) {
final boolean changed = headerSupplier != this.headerSupplier;
this.headerSupplier = headerSupplier;
if (changed) {
notifyDataSetChanged();
}
@@ -201,6 +203,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
this.footer = view;
}
protected boolean hasHeader() {
return this.headerSupplier != null;
}
@Deprecated(since = "Calling this method with `true` may cause crashes, see "
+ "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115")
public void showFooter(final boolean show) {
if (DEBUG) {
Log.d(TAG, "showFooter() called with: show = [" + show + "]");
@@ -211,6 +219,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
showFooter = show;
if (show) {
Log.w(TAG, "Calling LocalItemListAdapter.showFooter(true) may cause crashes, see https"
+ "://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115");
notifyItemInserted(sizeConsideringHeader());
} else {
notifyItemRemoved(sizeConsideringHeader());
@@ -218,11 +228,11 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
}
private int adapterOffsetWithoutHeader(final int offset) {
return offset - (header != null ? 1 : 0);
return offset - (hasHeader() ? 1 : 0);
}
private int sizeConsideringHeader() {
return localItems.size() + (header != null ? 1 : 0);
return localItems.size() + (hasHeader() ? 1 : 0);
}
public ArrayList<LocalItem> getItemsList() {
@@ -232,7 +242,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
@Override
public int getItemCount() {
int count = localItems.size();
if (header != null) {
if (hasHeader()) {
count++;
}
if (footer != null && showFooter) {
@@ -242,7 +252,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
if (DEBUG) {
Log.d(TAG, "getItemCount() called, count = " + count + ", "
+ "localItems.size() = " + localItems.size() + ", "
+ "header = " + header + ", footer = " + footer + ", "
+ "header = " + hasHeader() + ", footer = " + footer + ", "
+ "showFooter = " + showFooter);
}
return count;
@@ -255,9 +265,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
Log.d(TAG, "getItemViewType() called with: position = [" + position + "]");
}
if (header != null && position == 0) {
if (hasHeader() && position == 0) {
return HEADER_TYPE;
} else if (header != null) {
} else if (hasHeader()) {
position--;
}
if (footer != null && position == localItems.size() && showFooter) {
@@ -318,7 +328,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
}
switch (type) {
case HEADER_TYPE:
return new HeaderFooterHolder(header);
return new HeaderFooterHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HeaderFooterHolder(footer);
case LOCAL_PLAYLIST_HOLDER_TYPE:
@@ -366,14 +376,14 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
if (holder instanceof LocalItemHolder) {
// If header isn't null, offset the items by -1
if (header != null) {
if (hasHeader()) {
position--;
}
((LocalItemHolder) holder)
.updateFromItem(localItems.get(position), recordManager, dateTimeFormatter);
} else if (holder instanceof HeaderFooterHolder && position == 0 && header != null) {
((HeaderFooterHolder) holder).view = header;
} else if (holder instanceof HeaderFooterHolder && position == 0 && hasHeader()) {
((HeaderFooterHolder) holder).view = headerSupplier.get();
} else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader()
&& footer != null && showFooter) {
((HeaderFooterHolder) holder).view = footer;
@@ -387,10 +397,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
for (final Object payload : payloads) {
if (payload instanceof StreamStateEntity) {
((LocalItemHolder) holder).updateState(localItems
.get(header == null ? position : position - 1), recordManager);
.get(hasHeader() ? position - 1 : position), recordManager);
} else if (payload instanceof Boolean) {
((LocalItemHolder) holder).updateState(localItems
.get(header == null ? position : position - 1), recordManager);
.get(hasHeader() ? position - 1 : position), recordManager);
}
}
} else {

View File

@@ -7,6 +7,9 @@ import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
@@ -18,9 +21,6 @@ import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
class FeedDatabaseManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context)
@@ -85,14 +85,13 @@ class FeedDatabaseManager(context: Context) {
items: List<StreamInfoItem>,
oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE
) {
val itemsToInsert = ArrayList<StreamInfoItem>()
loop@ for (streamItem in items) {
val uploadDate = streamItem.uploadDate
val itemsToInsert = items.mapNotNull { stream ->
val uploadDate = stream.uploadDate
itemsToInsert += when {
uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem
uploadDate != null && uploadDate.offsetDateTime() >= oldestAllowedDate -> streamItem
else -> continue@loop
when {
uploadDate == null && stream.streamType == StreamType.LIVE_STREAM -> stream
uploadDate != null && uploadDate.offsetDateTime() >= oldestAllowedDate -> stream
else -> null
}
}

View File

@@ -53,6 +53,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.OffsetDateTime
import java.util.function.Consumer
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
@@ -81,8 +83,6 @@ import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime
import java.util.function.Consumer
class FeedFragment : BaseStateFragment<FeedState>() {
private var _feedBinding: FragmentFeedBinding? = null
@@ -91,7 +91,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private val disposables = CompositeDisposable()
private lateinit var viewModel: FeedViewModel
@State @JvmField var listState: Parcelable? = null
@State
@JvmField
var listState: Parcelable? = null
private var groupId = FeedGroupEntity.GROUP_ALL_ID
private var groupName = ""
@@ -149,7 +152,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (newState == RecyclerView.SCROLL_STATE_IDLE &&
!recyclerView.canScrollVertically(-1)
) {
if (tryGetNewItemsLoadedButton()?.isVisible == true) {
hideNewItemsLoaded(true)
}
@@ -387,8 +389,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (item is StreamItem && !isRefreshing) {
val stream = item.streamWithState.stream
NavigationHelper.openVideoDetailFragment(
requireContext(), fm,
stream.serviceId, stream.url, stream.title, null, false
requireContext(),
fm,
stream.serviceId,
stream.url,
stream.title,
null,
false
)
}
}
@@ -500,7 +507,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val isFastFeedModeEnabled = sharedPreferences.getBoolean(
getString(R.string.feed_use_dedicated_fetch_method_key), false
getString(R.string.feed_use_dedicated_fetch_method_key),
false
)
val builder = AlertDialog.Builder(requireContext())
@@ -535,7 +543,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private fun updateRelativeTimeViews() {
updateRefreshViewState()
groupAdapter.notifyItemRangeChanged(
0, groupAdapter.itemCount,
0,
groupAdapter.itemCount,
StreamItem.UPDATE_RELATIVE_TIME
)
}

View File

@@ -1,8 +1,8 @@
package org.schabi.newpipe.local.feed
import androidx.annotation.StringRes
import org.schabi.newpipe.local.feed.item.StreamItem
import java.time.OffsetDateTime
import org.schabi.newpipe.local.feed.item.StreamItem
sealed class FeedState {
data class ProgressState(

View File

@@ -14,6 +14,8 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.functions.Function6
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.App
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
@@ -25,8 +27,6 @@ import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
class FeedViewModel(
private val application: Application,
@@ -64,8 +64,14 @@ class FeedViewModel(
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
t5: Long, t6: List<OffsetDateTime?> ->
Function6 {
t1: FeedEventManager.Event,
t2: Boolean,
t3: Boolean,
t4: Boolean,
t5: Long,
t6: List<OffsetDateTime?>
->
return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
}
)
@@ -73,12 +79,13 @@ class FeedViewModel(
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
val streamItems = if (event is SuccessResultEvent || event is IdleEvent) {
feedDatabaseManager
.getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems)
.blockingGet(arrayListOf())
else
} else {
arrayListOf()
}
CombineResultDataHolder(event, streamItems, notLoadedCount, oldestUpdate)
}
@@ -150,17 +157,14 @@ class FeedViewModel(
fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application)
companion object {
private fun getShowPlayedItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_watched_items_key), true)
private fun getShowPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_watched_items_key), true)
private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true)
private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true)
private fun getShowFutureItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_future_items_key), true)
private fun getShowFutureItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_future_items_key), true)
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
initializer {

View File

@@ -6,6 +6,8 @@ import android.view.View
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.xwray.groupie.viewbinding.BindableItem
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.StreamWithState
@@ -20,8 +22,6 @@ import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.image.PicassoHelper
import java.util.concurrent.TimeUnit
import java.util.function.Consumer
data class StreamItem(
val streamWithState: StreamWithState,
@@ -132,6 +132,7 @@ data class StreamItem(
viewsAndDate.isEmpty() -> uploadDate!!
else -> Localization.concatenateStrings(viewsAndDate, uploadDate)
}
else -> viewsAndDate
}
}

View File

@@ -15,6 +15,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import com.squareup.picasso.Picasso
import com.squareup.picasso.Target
@@ -41,7 +42,9 @@ class NotificationHelper(val context: Context) {
fun displayNewStreamsNotifications(data: FeedUpdateInfo) {
val newStreams = data.newStreams
val summary = context.resources.getQuantityString(
R.plurals.new_streams, newStreams.size, newStreams.size
R.plurals.new_streams,
newStreams.size,
newStreams.size
)
val summaryBuilder = NotificationCompat.Builder(
context,
@@ -181,8 +184,7 @@ class NotificationHelper(val context: Context) {
val manager = context.getSystemService<NotificationManager>()!!
val enabled = manager.areNotificationsEnabled()
val channel = manager.getNotificationChannel(channelId)
val importance = channel?.importance
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
enabled && channel?.importance != NotificationManager.IMPORTANCE_NONE
} else {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
@@ -212,7 +214,7 @@ class NotificationHelper(val context: Context) {
context.startActivity(intent)
} else {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:" + context.packageName)
intent.data = "package:${context.packageName}".toUri()
context.startActivity(intent)
}
}

View File

@@ -16,6 +16,7 @@ import androidx.work.WorkerParameters
import androidx.work.rxjava3.RxWorker
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.App
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
@@ -23,7 +24,6 @@ import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.local.feed.service.FeedLoadManager
import org.schabi.newpipe.local.feed.service.FeedLoadService
import java.util.concurrent.TimeUnit
/*
* Worker which checks for new streams of subscribed channels
@@ -31,7 +31,7 @@ import java.util.concurrent.TimeUnit
*/
class NotificationWorker(
appContext: Context,
workerParams: WorkerParameters,
workerParams: WorkerParameters
) : RxWorker(appContext, workerParams) {
private val notificationHelper by lazy {
@@ -95,9 +95,8 @@ class NotificationWorker(
private val TAG = NotificationWorker::class.java.simpleName
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
private fun areNotificationsEnabled(context: Context) =
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
NotificationHelper.areNotificationsEnabledOnDevice(context)
private fun areNotificationsEnabled(context: Context) = NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
NotificationHelper.areNotificationsEnabledOnDevice(context)
/**
* Schedules a task for the [NotificationWorker]

View File

@@ -2,8 +2,9 @@ package org.schabi.newpipe.local.feed.notifications
import android.content.Context
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.getStringSafe
/**
* Information for the Scheduler which checks for new streams.
@@ -20,11 +21,9 @@ data class ScheduleOptions(
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return ScheduleOptions(
interval = TimeUnit.SECONDS.toMillis(
preferences.getString(
preferences.getStringSafe(
context.getString(R.string.streams_notifications_interval_key),
null
)?.toLongOrNull() ?: context.getString(
R.string.streams_notifications_interval_default
context.getString(R.string.streams_notifications_interval_default)
).toLong()
),
isRequireNonMeteredNetwork = preferences.getString(

View File

@@ -3,8 +3,8 @@ package org.schabi.newpipe.local.feed.service
import androidx.annotation.StringRes
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.processors.BehaviorProcessor
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
import java.util.concurrent.atomic.AtomicBoolean
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
object FeedEventManager {
private var processor: BehaviorProcessor<Event> = BehaviorProcessor.create()

View File

@@ -11,6 +11,10 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode
@@ -27,10 +31,6 @@ import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo
import org.schabi.newpipe.util.ExtractorHelper.getChannelTab
import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class FeedLoadManager(private val context: Context) {
@@ -60,7 +60,7 @@ class FeedLoadManager(private val context: Context) {
*/
fun startLoading(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
ignoreOutdatedThreshold: Boolean = false,
ignoreOutdatedThreshold: Boolean = false
): Single<List<Notification<FeedUpdateInfo>>> {
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val useFeedExtractor = defaultSharedPreferences.getBoolean(
@@ -85,9 +85,12 @@ class FeedLoadManager(private val context: Context) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(
outdatedThreshold
)
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
outdatedThreshold, NotificationMode.ENABLED
outdatedThreshold,
NotificationMode.ENABLED
)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
@@ -186,7 +189,8 @@ class FeedLoadManager(private val context: Context) {
val channelInfo = getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url, true
subscriptionEntity.url,
true
)
.onErrorReturn(storeOriginalErrorAndRethrow)
.blockingGet()
@@ -216,7 +220,8 @@ class FeedLoadManager(private val context: Context) {
) {
val infoItemsPage = getMoreChannelTabItems(
subscriptionEntity.serviceId,
linkHandler, channelTabInfo.nextPage
linkHandler,
channelTabInfo.nextPage
)
.blockingGet()
@@ -234,7 +239,7 @@ class FeedLoadManager(private val context: Context) {
subscriptionEntity,
originalInfo!!,
streams!!,
errors,
errors
)
)
} catch (e: Throwable) {
@@ -305,6 +310,7 @@ class FeedLoadManager(private val context: Context) {
feedDatabaseManager.markAsOutdated(info.uid)
}
}
notification.isOnError -> {
val error = notification.error
feedResultsHolder.addError(error!!)

View File

@@ -36,13 +36,13 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.functions.Function
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import java.util.concurrent.TimeUnit
class FeedLoadService : Service() {
companion object {
@@ -94,7 +94,8 @@ class FeedLoadService : Service() {
.doOnSubscribe {
startForeground(NOTIFICATION_ID, notificationBuilder.build())
}
.subscribe { _, error: Throwable? -> // explicitly mark error as nullable
.subscribe { _, error: Throwable? ->
// explicitly mark error as nullable
if (error != null) {
Log.e(TAG, "Error while storing result", error)
handleError(error)

View File

@@ -3,5 +3,5 @@ package org.schabi.newpipe.local.feed.service
data class FeedLoadState(
val updateDescription: String,
val maxProgress: Int,
val currentProgress: Int,
val currentProgress: Int
)

View File

@@ -25,13 +25,13 @@ data class FeedUpdateInfo(
val description: String?,
val subscriberCount: Long?,
val streams: List<StreamInfoItem>,
val errors: List<Throwable>,
val errors: List<Throwable>
) {
constructor(
subscription: SubscriptionEntity,
info: Info,
streams: List<StreamInfoItem>,
errors: List<Throwable>,
errors: List<Throwable>
) : this(
uid = subscription.uid,
notificationMode = subscription.notificationMode,
@@ -46,7 +46,7 @@ data class FeedUpdateInfo(
description = (info as? ChannelInfo)?.description,
subscriberCount = (info as? ChannelInfo)?.subscriberCount,
streams = streams,
errors = errors,
errors = errors
)
/**

View File

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

View File

@@ -13,7 +13,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewbinding.ViewBinding;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
@@ -45,6 +44,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@@ -126,12 +126,12 @@ public class StatisticsPlaylistFragment
}
@Override
protected ViewBinding getListHeader() {
protected Supplier<View> getListHeaderSupplier() {
headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(),
itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding;
return headerBinding::getRoot;
}
@Override

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: 2025 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.playlist
import android.content.Context
@@ -21,11 +26,7 @@ fun export(
}
}
fun exportWithTitles(
playlist: List<PlaylistStreamEntry>,
context: Context
): String {
private fun exportWithTitles(playlist: List<PlaylistStreamEntry>, context: Context): String {
return playlist.asSequence()
.map { it.streamEntity }
.map { entity ->
@@ -38,18 +39,13 @@ 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 +54,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 +62,6 @@ val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFacto
* @param url YouTube URL
* @return the video id
*/
fun getYouTubeId(url: String): String? {
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
private fun getYouTubeId(url: String): String? {
return runCatching { linkHandler.getId(url) }.getOrNull()
}

View File

@@ -1,5 +1,7 @@
package org.schabi.newpipe.local.playlist;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export;
@@ -22,6 +24,8 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.LinearLayout.LayoutParams;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -29,7 +33,6 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding;
import com.evernote.android.state.State;
import org.reactivestreams.Subscriber;
@@ -55,6 +58,7 @@ import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
@@ -67,6 +71,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -158,14 +163,14 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
@Override
protected ViewBinding getListHeader() {
protected Supplier<View> getListHeaderSupplier() {
headerBinding = LocalPlaylistHeaderBinding.inflate(activity.getLayoutInflater(), itemsList,
false);
false);
playlistControlBinding = headerBinding.playlistControl;
headerBinding.playlistTitleView.setSelected(true);
return headerBinding;
return headerBinding::getRoot;
}
@Override
@@ -365,17 +370,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
if (!isRewritingPlaylist) {
new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning)
.setTitle(R.string.remove_watched_popup_title)
.setPositiveButton(R.string.ok, (d, id) ->
removeWatchedStreams(false))
.setNeutralButton(
R.string.remove_watched_popup_yes_and_partially_watched_videos,
(d, id) -> removeWatchedStreams(true))
.setNegativeButton(R.string.cancel,
(d, id) -> d.cancel())
.show();
openRemoveWatchedConfirmationDialog();
}
} else if (item.getItemId() == R.id.menu_item_remove_duplicates) {
if (!isRewritingPlaylist) {
@@ -447,39 +442,28 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.getIsPlaylistThumbnailPermanent(playlistId);
boolean thumbnailVideoRemoved = false;
if (removePartiallyWatched) {
for (final var playlistItem : playlist) {
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final var streamStates = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet();
if (indexInHistory < 0) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
} else {
final var streamStates = recordManager
.loadLocalStreamStateBatch(playlist).blockingGet();
for (int i = 0; i < playlist.size(); i++) {
final var playlistItem = playlist.get(i);
final var streamStateEntity = streamStates.get(i);
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final long duration = playlistItem.toStreamInfoItem().getDuration();
for (int i = 0; i < playlist.size(); i++) {
final var playlistItem = playlist.get(i);
final var streamStateEntity = streamStates.get(i);
final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId());
final long duration = playlistItem.toStreamInfoItem().getDuration();
if (indexInHistory < 0 || (streamStateEntity != null
&& !streamStateEntity.isFinished(duration))) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
if (indexInHistory < 0 // stream is not in history
// stream is in history but the streamStateEntity is null
// if the stream was played for less than 5 seconds, see
// StreamStateEntity#PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|| streamStateEntity == null
|| (!removePartiallyWatched
&& !streamStateEntity.isFinished(duration))) {
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
== playlistItem.getStreamEntity().getUid()) {
thumbnailVideoRemoved = true;
}
}
@@ -768,11 +752,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;
@@ -898,6 +888,35 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.show();
}
/**
* Opens a confirmation dialog to remove watched streams from the playlist.
* The user can also choose to remove partially watched streams.
*/
private void openRemoveWatchedConfirmationDialog() {
final android.widget.CheckBox removePartiallyWatchedCheckbox =
new android.widget.CheckBox(requireContext());
removePartiallyWatchedCheckbox.setText(
R.string.remove_watched_popup_partially_watched_streams);
// Wrap the checkbox in a container with dialog-like horizontal padding
// so it aligns with the dialog title and message on the start side.
final LinearLayout checkboxContainer = new LinearLayout(requireContext());
checkboxContainer.setOrientation(LinearLayout.VERTICAL);
final int padding = DeviceUtils.dpToPx(20, requireContext());
checkboxContainer.setPadding(padding, padding, padding, 0);
checkboxContainer.addView(removePartiallyWatchedCheckbox,
new LayoutParams(MATCH_PARENT, WRAP_CONTENT));
new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning)
.setTitle(R.string.remove_watched_popup_title)
.setView(checkboxContainer)
.setPositiveButton(R.string.yes, (d, id) ->
removeWatchedStreams(removePartiallyWatchedCheckbox.isChecked()))
.setNegativeButton(R.string.cancel, (d, id) -> d.cancel())
.show();
}
public void setTabsPagerAdapter(
@Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) {
this.tabsPagerAdapter = tabsPagerAdapter;

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,9 @@ import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.GroupieViewHolder
import io.reactivex.rxjava3.disposables.CompositeDisposable
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID
import org.schabi.newpipe.databinding.DialogTitleBinding
@@ -62,9 +65,6 @@ import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private var _binding: FragmentSubscriptionBinding? = null
@@ -276,10 +276,13 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
when (item) {
is FeedGroupCardItem ->
NavigationHelper.openFeedFragment(fm, item.groupId, item.name)
is FeedGroupCardGridItem ->
NavigationHelper.openFeedFragment(fm, item.groupId, item.name)
is FeedGroupAddNewItem ->
FeedGroupDialog.newInstance().show(fm, null)
is FeedGroupAddNewGridItem ->
FeedGroupDialog.newInstance().show(fm, null)
}
@@ -294,6 +297,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
when (item) {
is FeedGroupCardItem ->
FeedGroupDialog.newInstance(item.groupId).show(fm, null)
is FeedGroupCardGridItem ->
FeedGroupDialog.newInstance(item.groupId).show(fm, null)
}
@@ -309,7 +313,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
title = getString(R.string.feed_groups_header_title),
onSortClicked = ::openReorderDialog,
onToggleListViewModeClicked = ::toggleListViewMode,
listViewMode = viewModel.getListViewMode(),
listViewMode = viewModel.getListViewMode()
)
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
@@ -342,9 +346,14 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
val actions = DialogInterface.OnClickListener { _, i ->
when (i) {
0 -> ShareUtils.shareText(
requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails
requireContext(),
selectedItem.name,
selectedItem.url,
selectedItem.thumbnails
)
1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url)
2 -> deleteChannel(selectedItem)
}
}
@@ -374,7 +383,9 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem> {
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(
fm,
selectedItem.serviceId, selectedItem.url, selectedItem.name
selectedItem.serviceId,
selectedItem.url,
selectedItem.name
)
override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
@@ -404,6 +415,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
itemsListState = null
}
}
is SubscriptionState.ErrorState -> {
result.error?.let {
showError(ErrorInfo(result.error, UserAction.SOMETHING_ELSE, "Subscriptions"))

View File

@@ -37,13 +37,16 @@ class SubscriptionManager(context: Context) {
filterQuery.isNotEmpty() -> {
return if (showOnlyUngrouped) {
subscriptionTable.getSubscriptionsOnlyUngroupedFiltered(
currentGroupId, filterQuery
currentGroupId,
filterQuery
)
} else {
subscriptionTable.getSubscriptionsFiltered(filterQuery)
}
}
showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId)
else -> subscriptionTable.getAll()
}
}
@@ -67,19 +70,18 @@ class SubscriptionManager(context: Context) {
return listEntities
}
fun updateChannelInfo(info: ChannelInfo): Completable =
subscriptionTable.getSubscription(info.serviceId, info.url)
.flatMapCompletable {
Completable.fromRunnable {
it.apply {
name = info.name
avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars)
description = info.description
subscriberCount = info.subscriberCount
}
subscriptionTable.update(it)
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
.flatMapCompletable {
Completable.fromRunnable {
it.apply {
name = info.name
avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars)
description = info.description
subscriberCount = info.subscriberCount
}
subscriptionTable.update(it)
}
}
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url)

View File

@@ -9,6 +9,7 @@ import com.xwray.groupie.Group
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.concurrent.TimeUnit
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.item.ChannelItem
@@ -16,7 +17,6 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import java.util.concurrent.TimeUnit
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)

View File

@@ -23,6 +23,7 @@ import com.livefront.bridge.Bridge
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.Section
import java.io.Serializable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.databinding.DialogFeedGroupCreateBinding
@@ -40,7 +41,6 @@ import org.schabi.newpipe.local.subscription.item.PickerIconItem
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.ThemeHelper
import java.io.Serializable
class FeedGroupDialog : DialogFragment(), BackPressable {
private var _feedGroupCreateBinding: DialogFeedGroupCreateBinding? = null
@@ -61,16 +61,41 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
data object DeleteScreen : ScreenState()
}
@State @JvmField var selectedIcon: FeedGroupIcon? = null
@State @JvmField var selectedSubscriptions: HashSet<Long> = HashSet()
@State @JvmField var wasSubscriptionSelectionChanged: Boolean = false
@State @JvmField var currentScreen: ScreenState = InitialScreen
@State
@JvmField
var selectedIcon: FeedGroupIcon? = null
@State @JvmField var subscriptionsListState: Parcelable? = null
@State @JvmField var iconsListState: Parcelable? = null
@State @JvmField var wasSearchSubscriptionsVisible = false
@State @JvmField var subscriptionsCurrentSearchQuery = ""
@State @JvmField var subscriptionsShowOnlyUngrouped = false
@State
@JvmField
var selectedSubscriptions: HashSet<Long> = HashSet()
@State
@JvmField
var wasSubscriptionSelectionChanged: Boolean = false
@State
@JvmField
var currentScreen: ScreenState = InitialScreen
@State
@JvmField
var subscriptionsListState: Parcelable? = null
@State
@JvmField
var iconsListState: Parcelable? = null
@State
@JvmField
var wasSearchSubscriptionsVisible = false
@State
@JvmField
var subscriptionsCurrentSearchQuery = ""
@State
@JvmField
var subscriptionsShowOnlyUngrouped = false
private val subscriptionMainSection = Section()
private val subscriptionEmptyFooter = Section()
@@ -153,8 +178,10 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
itemAnimator = null
adapter = subscriptionGroupAdapter
layoutManager = GridLayoutManager(
requireContext(), subscriptionGroupAdapter.spanCount,
RecyclerView.VERTICAL, false
requireContext(),
subscriptionGroupAdapter.spanCount,
RecyclerView.VERTICAL,
false
).apply {
spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup
}
@@ -362,7 +389,8 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
val selectedCount = this.selectedSubscriptions.size
val selectedCountText = resources.getQuantityString(
R.plurals.feed_group_dialog_selection_count,
selectedCount, selectedCount
selectedCount,
selectedCount
)
feedGroupCreateBinding.selectedSubscriptionCountView.text = selectedCountText
feedGroupCreateBinding.subscriptionsHeaderInfo.text = selectedCountText

View File

@@ -55,7 +55,8 @@ class FeedGroupDialogViewModel(
private var subscriptionsDisposable = Flowable
.combineLatest(
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
subscriptionsFlowable,
feedDatabaseManager.subscriptionIdsForGroup(groupId)
) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
.subscribeOn(Schedulers.io())
.subscribe(mutableSubscriptionsLiveData::postValue)
@@ -125,7 +126,10 @@ class FeedGroupDialogViewModel(
) = viewModelFactory {
initializer {
FeedGroupDialogViewModel(
context.applicationContext, groupId, initialQuery, initialShowOnlyUngrouped
context.applicationContext,
groupId,
initialQuery,
initialShowOnlyUngrouped
)
}
}

View File

@@ -15,6 +15,7 @@ import com.evernote.android.state.State
import com.livefront.bridge.Bridge
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.TouchCallback
import java.util.Collections
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.databinding.DialogFeedGroupReorderBinding
@@ -22,7 +23,6 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewMo
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.SuccessEvent
import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
import org.schabi.newpipe.util.ThemeHelper
import java.util.Collections
class FeedGroupReorderDialog : DialogFragment() {
private var _binding: DialogFeedGroupReorderBinding? = null

View File

@@ -43,7 +43,10 @@ class ChannelItem(
gesturesListener?.run {
viewHolder.root.setOnClickListener { selected(infoItem) }
viewHolder.root.setOnLongClickListener { held(infoItem); true }
viewHolder.root.setOnLongClickListener {
held(infoItem)
true
}
}
}

View File

@@ -10,7 +10,7 @@ import org.schabi.newpipe.local.subscription.FeedGroupIcon
data class FeedGroupCardGridItem(
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
val name: String,
val icon: FeedGroupIcon,
val icon: FeedGroupIcon
) : BindableItem<FeedGroupCardGridItemBinding>() {
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)

View File

@@ -114,6 +114,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
import org.schabi.newpipe.player.ui.BackgroundPlayerUi;
import org.schabi.newpipe.player.ui.MainPlayerUi;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.PlayerUiList;
@@ -271,6 +272,7 @@ public final class Player implements PlaybackListener, Listener {
@NonNull
private final HistoryRecordManager recordManager;
private boolean screenOn = true;
/*//////////////////////////////////////////////////////////////////////////
// Constructor
@@ -574,6 +576,7 @@ public final class Player implements PlaybackListener, Listener {
private void initUIsForCurrentPlayerType() {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
// correct UI already in place
return;
@@ -592,14 +595,17 @@ public final class Player implements PlaybackListener, Listener {
switch (playerType) {
case MAIN:
UIs.destroyAll(PopupPlayerUi.class);
UIs.destroyAll(BackgroundPlayerUi.class);
UIs.addAndPrepare(new MainPlayerUi(this, binding));
break;
case POPUP:
UIs.destroyAll(MainPlayerUi.class);
UIs.destroyAll(BackgroundPlayerUi.class);
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
break;
case AUDIO:
UIs.destroyAll(VideoPlayerUi.class);
UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi
UIs.addAndPrepare(new BackgroundPlayerUi(this));
break;
}
}
@@ -842,6 +848,12 @@ public final class Player implements PlaybackListener, Listener {
case ACTION_SHUFFLE:
toggleShuffleModeEnabled();
break;
case Intent.ACTION_SCREEN_OFF:
screenOn = false;
break;
case Intent.ACTION_SCREEN_ON:
screenOn = true;
break;
case Intent.ACTION_CONFIGURATION_CHANGED:
if (DEBUG) {
Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received");
@@ -2195,12 +2207,12 @@ public final class Player implements PlaybackListener, Listener {
}
}
public void useVideoSource(final boolean videoEnabled) {
if (playQueue == null || audioPlayerSelected()) {
public void useVideoAndSubtitles(final boolean videoAndSubtitlesEnabled) {
if (playQueue == null) {
return;
}
isAudioOnly = !videoEnabled;
isAudioOnly = !videoAndSubtitlesEnabled;
getCurrentStreamInfo().ifPresentOrElse(info -> {
// In case we don't know the source type, fall back to either video-with-audio, or
@@ -2208,27 +2220,28 @@ public final class Player implements PlaybackListener, Listener {
final SourceType sourceType = videoResolver.getStreamSourceType()
.orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
setRecovery(); // making sure to save playback position before reloadPlayQueueManager()
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
reloadPlayQueueManager();
}
setRecovery();
// Disable or enable video and subtitles renderers depending of the videoEnabled value
trackSelector.setParameters(trackSelector.buildUponParameters()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled));
}, () -> {
/*
The current metadata may be null sometimes (for e.g. when using an unstable connection
in livestreams) so we will be not able to execute the block below
in livestreams) so we will be not able to execute the block above
Reload the play queue manager in this case, which is the behavior when we don't know the
index of the video renderer or playQueueManagerReloadingNeeded returns true
*/
setRecovery(); // making sure to save playback position before reloadPlayQueueManager()
reloadPlayQueueManager();
setRecovery();
});
// Disable or enable video and subtitles renderers depending of the
// videoAndSubtitlesEnabled value
trackSelector.setParameters(trackSelector.buildUponParameters()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoAndSubtitlesEnabled)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoAndSubtitlesEnabled));
}
/**
@@ -2461,4 +2474,11 @@ public final class Player implements PlaybackListener, Listener {
.orElse(RENDERER_UNAVAILABLE);
}
//endregion
/**
* @return whether the device screen is turned on.
*/
public boolean isScreenOn() {
return screenOn;
}
}

View File

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

View File

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

View File

@@ -14,10 +14,8 @@ import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;
@@ -661,10 +659,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
}
}
final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);
if (isWebStreamingUrl(requestUrl)
|| isTvHtml5StreamingUrl
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
@@ -685,9 +680,6 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
} else if (isIosStreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getIosUserAgent(null));
} else if (isTvHtml5StreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getTvHtml5UserAgent());
} else {
// non-mobile user agent
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);

View File

@@ -18,7 +18,7 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi
* and provides some abstract methods to make it easier separating the logic from the UI.
*/
abstract class BasePlayerGestureListener(
private val playerUi: VideoPlayerUi,
private val playerUi: VideoPlayerUi
) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener {
protected val player: Player = playerUi.player
@@ -86,8 +86,9 @@ abstract class BasePlayerGestureListener(
// ///////////////////////////////////////////////////////////////////
override fun onDown(e: MotionEvent): Boolean {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "onDown called with e = [$e]")
}
if (isDoubleTapping && isDoubleTapEnabled) {
doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e))
@@ -108,8 +109,9 @@ abstract class BasePlayerGestureListener(
}
override fun onDoubleTap(e: MotionEvent): Boolean {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "onDoubleTap called with e = [$e]")
}
onDoubleTap(e, getDisplayPortion(e))
return true
@@ -136,8 +138,9 @@ abstract class BasePlayerGestureListener(
private fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "startMultiDoubleTap called with e = [$e]")
}
keepInDoubleTapMode()
doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e))
@@ -145,8 +148,9 @@ abstract class BasePlayerGestureListener(
}
fun keepInDoubleTapMode() {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "keepInDoubleTapMode called")
}
isDoubleTapping = true
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
@@ -161,8 +165,9 @@ abstract class BasePlayerGestureListener(
}
fun endMultiDoubleTap() {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "endMultiDoubleTap called")
}
isDoubleTapping = false
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)

View File

@@ -1,5 +1,9 @@
package org.schabi.newpipe.player.gesture
enum class DisplayPortion {
LEFT, MIDDLE, RIGHT, LEFT_HALF, RIGHT_HALF
LEFT,
MIDDLE,
RIGHT,
LEFT_HALF,
RIGHT_HALF
}

View File

@@ -8,6 +8,7 @@ import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.isVisible
import kotlin.math.abs
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.AnimationType
@@ -17,7 +18,6 @@ import org.schabi.newpipe.player.helper.AudioReactor
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx
import kotlin.math.abs
/**
* GestureListener for the player
@@ -42,24 +42,29 @@ class MainPlayerGestureListener(
v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen)
true
}
MotionEvent.ACTION_UP -> {
v.parent?.requestDisallowInterceptTouchEvent(false)
false
}
else -> true
}
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
}
if (isDoubleTapping)
if (isDoubleTapping) {
return true
}
super.onSingleTapConfirmed(e)
if (player.currentState != Player.STATE_BLOCKED)
if (player.currentState != Player.STATE_BLOCKED) {
onSingleTap()
}
return true
}
@@ -195,6 +200,7 @@ class MainPlayerGestureListener(
when (PlayerHelper.getActionForRightGestureSide(player.context)) {
player.context.getString(R.string.volume_control_key) ->
onScrollVolume(distanceY)
player.context.getString(R.string.brightness_control_key) ->
onScrollBrightness(distanceY)
}
@@ -202,6 +208,7 @@ class MainPlayerGestureListener(
when (PlayerHelper.getActionForLeftGestureSide(player.context)) {
player.context.getString(R.string.volume_control_key) ->
onScrollVolume(distanceY)
player.context.getString(R.string.brightness_control_key) ->
onScrollBrightness(distanceY)
}

View File

@@ -5,17 +5,17 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.core.view.isVisible
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.ui.PopupPlayerUi
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.ui.PopupPlayerUi
class PopupPlayerGestureListener(
private val playerUi: PopupPlayerUi,
private val playerUi: PopupPlayerUi
) : BasePlayerGestureListener(playerUi) {
private var isMoving = false
@@ -205,13 +205,16 @@ class PopupPlayerGestureListener(
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
if (DEBUG) {
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
}
if (isDoubleTapping)
if (isDoubleTapping) {
return true
if (player.exoPlayerIsNull())
}
if (player.exoPlayerIsNull()) {
return false
}
onSingleTap()
return true

View File

@@ -129,6 +129,13 @@ public class PlayerDataSource {
getDefaultDashChunkSourceFactory(cachelessDataSourceFactory),
cachelessDataSourceFactory);
}
public DashMediaSource.Factory getLiveYoutubeDashMediaSourceFactory() {
return new DashMediaSource.Factory(
getDefaultDashChunkSourceFactory(cachelessDataSourceFactory),
cachelessDataSourceFactory)
.setManifestParser(new YoutubeDashLiveManifestParser());
}
//endregion

View File

@@ -0,0 +1,68 @@
package org.schabi.newpipe.player.helper;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.source.dash.manifest.DashManifest;
import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser;
import com.google.android.exoplayer2.source.dash.manifest.Period;
import com.google.android.exoplayer2.source.dash.manifest.ProgramInformation;
import com.google.android.exoplayer2.source.dash.manifest.ServiceDescriptionElement;
import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement;
import java.util.List;
/**
* A {@link DashManifestParser} fixing YouTube DASH manifests to allow starting playback from the
* newest period available instead of the earliest one in some cases.
*
* <p>
* It changes the {@code availabilityStartTime} passed to a custom value doing the workaround.
* A better approach to fix the issue should be investigated and used in the future.
* </p>
*/
public class YoutubeDashLiveManifestParser extends DashManifestParser {
// Result of Util.parseXsDateTime("1970-01-01T00:00:00Z")
private static final long AVAILABILITY_START_TIME_TO_USE = 0;
// There is no computation made with the availabilityStartTime value in the
// parseMediaPresentationDescription method itself, so we can just override methods called in
// this method using the workaround value
// Overriding parsePeriod does not seem to be needed
@SuppressWarnings("checkstyle:ParameterNumber")
@NonNull
@Override
protected DashManifest buildMediaPresentationDescription(
final long availabilityStartTime,
final long durationMs,
final long minBufferTimeMs,
final boolean dynamic,
final long minUpdateTimeMs,
final long timeShiftBufferDepthMs,
final long suggestedPresentationDelayMs,
final long publishTimeMs,
@Nullable final ProgramInformation programInformation,
@Nullable final UtcTimingElement utcTiming,
@Nullable final ServiceDescriptionElement serviceDescription,
@Nullable final Uri location,
@NonNull final List<Period> periods) {
return super.buildMediaPresentationDescription(
AVAILABILITY_START_TIME_TO_USE,
durationMs,
minBufferTimeMs,
dynamic,
minUpdateTimeMs,
timeShiftBufferDepthMs,
suggestedPresentationDelayMs,
publishTimeMs,
programInformation,
utcTiming,
serviceDescription,
location,
periods);
}
}

View File

@@ -17,6 +17,7 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.function.Consumer
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
@@ -37,7 +38,6 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.image.ImageStrategy
import java.util.function.Consumer
/**
* This class is used to cleanly separate the Service implementation (in
@@ -47,7 +47,8 @@ import java.util.function.Consumer
*/
class MediaBrowserImpl(
private val context: Context,
notifyChildrenChanged: Consumer<String>, // parentId
// parentId
notifyChildrenChanged: Consumer<String>
) {
private val packageValidator = PackageValidator(context)
private val database = NewPipeDatabase.getInstance(context)
@@ -89,7 +90,8 @@ class MediaBrowserImpl(
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED,
true
)
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
}
@@ -137,7 +139,7 @@ class MediaBrowserImpl(
)
}
when (/*val uriType = */path.removeAt(0)) {
when (path.removeAt(0)) {
ID_BOOKMARKS -> {
if (path.isEmpty()) {
return populateBookmarks()
@@ -204,12 +206,12 @@ class MediaBrowserImpl(
val extras = Bundle()
extras.putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.resources.getString(R.string.tab_bookmarks),
context.resources.getString(R.string.tab_bookmarks)
)
builder.setExtras(extras)
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
}
@@ -266,7 +268,7 @@ class MediaBrowserImpl(
private fun createLocalPlaylistStreamMediaItem(
playlistId: Long,
item: PlaylistStreamEntry,
index: Int,
index: Int
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
@@ -283,7 +285,7 @@ class MediaBrowserImpl(
private fun createRemotePlaylistStreamMediaItem(
playlistId: Long,
item: StreamInfoItem,
index: Int,
index: Int
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
@@ -303,7 +305,7 @@ class MediaBrowserImpl(
private fun createMediaIdForPlaylistIndex(
isRemote: Boolean,
playlistId: Long,
index: Int,
index: Int
): String {
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
.appendPath(index.toString())

View File

@@ -14,6 +14,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.function.BiConsumer
import java.util.function.Consumer
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
@@ -30,8 +32,6 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.NavigationHelper
import java.util.function.BiConsumer
import java.util.function.Consumer
/**
* This class is used to cleanly separate the Service implementation (in
@@ -51,7 +51,7 @@ class MediaBrowserPlaybackPreparer(
private val context: Context,
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
private val clearMediaSessionError: Runnable,
private val onPrepare: Consumer<Boolean>,
private val onPrepare: Consumer<Boolean>
) : PlaybackPreparer {
private val database = NewPipeDatabase.getInstance(context)
private var disposable: Disposable? = null
@@ -146,7 +146,7 @@ class MediaBrowserPlaybackPreparer(
throw parseError(mediaId)
}
return when (/*val uriType = */path.removeAt(0)) {
return when (path.removeAt(0)) {
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
mediaId,
path,
@@ -172,7 +172,7 @@ class MediaBrowserPlaybackPreparer(
private fun extractPlayQueueFromPlaylistMediaId(
mediaId: String,
path: MutableList<String>,
url: String?,
url: String?
): Single<PlayQueue> {
if (path.isEmpty()) {
throw parseError(mediaId)
@@ -185,10 +185,11 @@ class MediaBrowserPlaybackPreparer(
}
val playlistId = path[0].toLong()
val index = path[1].toInt()
return if (playlistType == ID_LOCAL)
return if (playlistType == ID_LOCAL) {
extractLocalPlayQueue(playlistId, index)
else
} else {
extractRemotePlayQueue(playlistId, index)
}
}
ID_URL -> {
@@ -208,7 +209,7 @@ class MediaBrowserPlaybackPreparer(
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromHistoryMediaId(
mediaId: String,
path: List<String>,
path: List<String>
): Single<PlayQueue> {
if (path.size != 1) {
throw parseError(mediaId)
@@ -229,14 +230,14 @@ class MediaBrowserPlaybackPreparer(
private fun extractPlayQueueFromInfoItemMediaId(
mediaId: String,
path: List<String>,
url: String,
url: String
): Single<PlayQueue> {
if (path.size != 2) {
throw parseError(mediaId)
}
val serviceId = path[1].toInt()
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
return when (infoItemTypeFromString(path[0])) {
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
.map { SinglePlayQueue(it) }

View File

@@ -30,9 +30,9 @@ import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.media.MediaBrowserServiceCompat
import org.schabi.newpipe.BuildConfig
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import org.schabi.newpipe.BuildConfig
/**
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
@@ -94,18 +94,22 @@ internal class PackageValidator(context: Context) {
val isCallerKnown = when {
// If it's our own app making the call, allow it.
callingUid == Process.myUid() -> true
// If the system is making the call, allow it.
callingUid == Process.SYSTEM_UID -> true
// If the app was signed by the same certificate as the platform itself, also allow it.
callerSignature == platformSignature -> true
/**
/*
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
* while it isn't required to allow these apps to connect to a
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
* such as Android TV and the Google Assistant.
*/
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
/**
/*
* If the calling app has a notification listener it is able to retrieve notifications
* and can connect to an active [MediaSessionCompat].
*
@@ -169,11 +173,10 @@ internal class PackageValidator(context: Context) {
*/
@Suppress("deprecation")
@SuppressLint("PackageManagerGetSignatures")
private fun getPackageInfo(callingPackage: String): PackageInfo? =
packageManager.getPackageInfo(
callingPackage,
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
)
private fun getPackageInfo(callingPackage: String): PackageInfo? = packageManager.getPackageInfo(
callingPackage,
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
)
/**
* Gets the signature of a given package's [PackageInfo].
@@ -185,23 +188,21 @@ internal class PackageValidator(context: Context) {
* returns `null` as the signature.
*/
@Suppress("deprecation")
private fun getSignature(packageInfo: PackageInfo): String? =
if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
null
} else {
val certificate = packageInfo.signatures!![0].toByteArray()
getSignatureSha256(certificate)
}
private fun getSignature(packageInfo: PackageInfo): String? = if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
null
} else {
val certificate = packageInfo.signatures!![0].toByteArray()
getSignatureSha256(certificate)
}
/**
* Finds the Android platform signing key signature. This key is never null.
*/
private fun getSystemSignature(): String =
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
getSignature(platformInfo)
} ?: throw IllegalStateException("Platform signature not found")
private fun getSystemSignature(): String = getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
getSignature(platformInfo)
} ?: throw IllegalStateException("Platform signature not found")
/**
* Creates a SHA-256 signature given a certificate byte array.

View File

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

View File

@@ -4,15 +4,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.player.playqueue.events.AppendEvent;
import org.schabi.newpipe.player.playqueue.events.ErrorEvent;
import org.schabi.newpipe.player.playqueue.events.InitEvent;
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.events.RecoveryEvent;
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
import org.schabi.newpipe.player.playqueue.events.SelectEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent;
import java.io.Serializable;
import java.util.ArrayList;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More