1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-02-07 10:40:15 +00:00

Compare commits

..

448 Commits

Author SHA1 Message Date
Stypox
d859a5edc8 Merge pull request #13138 from Isira-Seneviratne/Merge-dev-to-refactor 2026-02-02 20:20:28 +01:00
Stypox
f63ea4aa93 Fix ErrorInfoTest after changes to exception classifications 2026-02-02 19:54:55 +01:00
Stypox
abf9a80715 Fix ErrorPanelTest after changes to exception classifications 2026-02-02 19:52:17 +01:00
Isira Seneviratne
60615e6b9e Fix compile errors 2026-02-02 05:48:22 +05:30
Isira Seneviratne
35315d02ef Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/player/Player.java
#	app/src/main/res/values-eu/strings.xml
#	gradle/libs.versions.toml
2026-01-29 19:39:21 +05:30
Tobi
50b9a7b7f6 Merge pull request #13121 from theimpulson/forwardport
Merge dev into refactor
2026-01-28 08:43:36 -08:00
Tobi
4b424bc6bd Fix comment 2026-01-28 16:56:22 +01:00
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
Aayush Gupta
2376a83e0c Fix all ktlint violations and merge issues
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-26 23:08:33 +08:00
Aayush Gupta
394a7f68cd Merge branch 'dev' into refactor 2026-01-26 22:53:17 +08: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
61c25d4589 Merge pull request #13056 from HatakeKakashri/bump_min_sdk_23
Bump minimum SDK version to API 23
2026-01-21 11:32:46 +08:00
Aayush Gupta
b84395dea0 Remove non-required API M version checks
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-19 14:39:33 +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
Hatake Kakashri
0b43dd2a62 Bump minimum SDK version to API 23
- Updated the `minSdk` in `app/build.gradle.kts`.
- Adjusted the api-level for the test-android min sdk CI workflow
  in `.github/workflows/ci.yml`.
- Simplified the `getOsString()` method in `ErrorActivity.java` by removing
  the conditional check for `Build.VERSION_CODES.M`, as API 23 is now the minimum.
2026-01-13 22:12:50 +05:30
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
tobigr
f5245eac91 Merge branch 'dev' into refactor 2026-01-09 14:09:14 +01: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
Mira
3369607b74 Feat: opus metadata encoding (#12974)
Feat: Downloading: Add opus audio metadata tags for title, author, date, and a comment tag with the originating URL

This removes the DownloadManagerService.EXTRA_SOURCE field, which is always inferred from the StreamInfo.
2026-01-03 02:54:07 -08:00
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
Aayush Gupta
2e8e203276 Merge pull request #12963 from dustdfg/misc_refactor
Misc small refactors (mostly replacing old switch syntax with new)
2026-01-01 16:41:25 +08:00
Yevhen Babiichuk (DustDFG)
127064d6fc Misc small refactors (mostly replacing old switch syntax with new) 2026-01-01 10:26:56 +02:00
Aayush Gupta
cd056a7f7f Merge pull request #12969 from TeamNewPipe/refactor-merge
Merge dev into refactor
2025-12-29 22:48:14 +08:00
tobigr
040b4c44ca Merge branch 'dev' into refactor
Conflicts:
VideoDetailFragment (see #12781)
libs.version.toml
2025-12-29 12:15:13 +01:00
Tobi
8ae5a55c4f Merge pull request #12947 from dustdfg/kotlin_refactor4
Convert newpipe/util/KioskTranslator.java to kotlin
2025-12-27 09:54:58 -08:00
Tobi
8b038ef1c8 Merge pull request #12949 from dustdfg/unnecessary_get_resources
Use context.getString() shorthand instead of context.getResources().getString()
2025-12-27 09:19:06 -08:00
Yevhen Babiichuk (DustDFG)
4360c1b873 Use context.getString() shorthand instead of context.getResources().getString() 2025-12-26 14:15:33 +02:00
Yevhen Babiichuk (DustDFG)
4649e46d1e Convert newpipe/util/KioskTranslator.java to kotlin 2025-12-26 11:21:10 +02:00
Tobi
8e32e7a4b4 Merge pull request #12821 from HatakeKakashri/add_to_queue_menu_option
Add enqueue option to router dialog
2025-12-20 11:59:07 -08:00
TobiGr
c596476c06 Only show enqueue option if play queue is not empty in RouterActivity
Make enqueue option avilable for playlists as well
2025-12-20 20:31:30 +01:00
Hatake Kakashri
90c36cb2e8 Add enqueue option to router dialog
- This allows users to enqueue a stream directly to the current player queue when sharing a link with the app, improving the user experience for queue management.
- The 'Enqueue' option is now available in the action selection dialog and can also be set as the preferred open action in the settings.
2025-12-20 20:31:30 +01:00
Tobi
01dee9dd13 Merge pull request #12913 from dhardy92/handle-indymotionFr
add indymotion.fr peertube instance on AndroidManifest.xml
2025-12-19 00:42:24 -08:00
Damien Hardy
bbcf57f93a add indymotion.fr peertube instance on AndroidManifest.xml 2025-12-17 18:52:23 +01:00
Aayush Gupta
0fc2d4c948 Merge branch 'dev' into refactor
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2025-12-13 16:12:32 +08:00
Tobi
1bca5f3d63 Merge pull request #12838 from Stypox/merge-dev-refactor
Merge dev to refactor
2025-11-29 14:04:37 -08:00
Tobi
40caeb465d Fix ktlint: remove unnecessary essary semicolons
Task :app:runKtlint
/home/runner/work/NewPipe/NewPipe/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt:151:85: Unnecessary semicolon (no-semi)
/home/runner/work/NewPipe/NewPipe/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt:152:72: Unnecessary semicolon (no-semi)
2025-11-29 22:24:20 +01:00
Stypox
125fc38ffe Merge branch 'dev' into refactor 2025-11-27 18:13:19 +01:00
Aayush Gupta
6e0b7be15c Merge pull request #12404 from SttApollo/Create_CommentSection_ErrorPanel
Implement Compose-based Error Panel, Error UI Model, and Tests for Comments
2025-10-07 09:14:58 +08:00
Aayush Gupta
4604339583 Merge branch 'dev' into refactor
Conflicts:
	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
	app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
	build.gradle
2025-10-07 08:46:03 +08:00
Su TT
25e96d584a Address reviewer changes - make previews private and remove white space 2025-10-06 11:11:13 -04:00
Su TT
9d3ac1b94f Add Compose UI tests covering ErrorPanel and comment section flows 2025-10-04 17:13:03 -04:00
Clippy
d9ddc07d4b Fix failing recaptcha mapping test 2025-10-01 17:24:02 +01:00
Stypox
8856e97c62 Reorder buttons in error panel and don't allow reporting recaptchas 2025-10-01 16:56:16 +02:00
Stypox
aed4278388 Unify getString for compatibility
(read the method's javadoc for why)
2025-10-01 16:48:52 +02:00
Su TT
3ab1322425 test,ui: move comment error tests to error; remove unused ComposeView 2025-09-29 13:57:34 -04:00
Su TT
fab0d35269 Update ErrorPanel and retest 2025-09-24 16:47:21 -04:00
Su TT
5ba95a2c37 Add ErrorPanelPreview 2025-09-23 10:04:52 -04:00
Su TT
2d3a4b67d7 Convert CommentSectionErrorIntegrationTest to unit test
- Moved from androidTest to test directory
- Removed Android test runner dependencies
- Renamed to CommentSectionErrorTest
- Addresses PR feedback until Compose testing is in place
2025-09-23 10:04:52 -04:00
Stt_lens
9cbcaecb92 Update app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt
Co-authored-by: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>
2025-09-23 10:04:52 -04:00
Stt_lens
848b8868fb Update app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt
Co-authored-by: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>
2025-09-23 10:04:52 -04:00
Stt_lens
f9222a6a68 Update app/src/main/java/org/schabi/newpipe/ui/components/common/ErrorPanel.kt
Co-authored-by: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>
2025-09-23 10:04:52 -04:00
Su TT
8292f729ea Updated ERefactor ErrorPanel to use ErrorInfo directly and remove UI models 2025-09-23 10:04:49 -04:00
Su TT
19fb9899cd Fix CommentSectionErrorTest to use named NetworkException for instrumented test compatibility 2025-09-23 09:41:05 -04:00
Su TT
da4878d264 feat(ui):Add ErrorPanel composable to Comments Section, related UI models, and tests 2025-09-23 09:40:48 -04:00
Stypox
abfde872f1 Merge pull request #12623 from Isira-Seneviratne/Merge-dev-to-refactor 2025-09-10 09:17:40 +02:00
Isira Seneviratne
c98f56bf7b Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/player/Player.java
#	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
#	app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
#	app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
2025-09-10 08:37:57 +05:30
Stypox
759342fa62 Merge pull request #12612 from Profpatsch/rename-fullscreen-buttons 2025-09-07 18:00:47 +02:00
Profpatsch
0382cfd2ae MainPlayerUi: separate enter/exit fullscreen methods
Most usages of `toggleFullscreen` are clear about whether they want to
enter or exit fullscreen, so let’s split the setup into two functions
for easier debugging.

The two remaining uses of `toggleFullscreen` *should* really toggle,
but I’m not 100% sure.

Also rename `onScreenRotationButtonClicked` to
`onFullscreenToggleButtonClicked`, since we renamed the button id
earlier.
2025-09-07 13:23:15 +02:00
Profpatsch
753a3e68d5 player.xml: Rename fullscreen buttons
Their names must have come from a very old version of the app, they
both toggle the fullscreen mode and rotate the screen depending on
circumstances.

So
`fullscreenButton` -> `fullscreenToggleButtonSecondaryMenu` because it
is only visible in the secondary video menu on some configurations.
and
`screenRotationButton` -> `fullscreenToggleButton` because it is the main
fullscreen button next to the video progress bar.
2025-09-07 12:25:56 +02:00
Stypox
b6bd87a4dc Merge pull request #12609 from Stypox/image-vector-app-icon 2025-09-06 18:01:10 +02:00
Stypox
b36201442d Use ImageVector to render NewPipe squircle app icon 2025-09-06 17:59:42 +02:00
Stypox
9d3775f132 Rewrite logo SVGs to make them line-only
Also optimize them with svgo
2025-09-06 17:23:31 +02:00
Stypox
b2d89a41fb Merge pull request #12604 from Isira-Seneviratne/Refactor-EmptyState 2025-09-06 15:33:49 +02:00
Isira Seneviratne
01a8c4e584 Clean up EmptyStateComposable code 2025-09-05 18:22:19 +05:30
Stypox
2ee7cc4344 Merge branch 'dev' into refactor 2025-09-05 13:34:53 +02:00
Stypox
2cb465c89d Merge branch 'dev' into refactor 2025-09-04 15:25:45 +02:00
Isira Seneviratne
ccca89dc8a Merge pull request #12585 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-09-01 05:37:29 +05:30
Isira Seneviratne
9bf23abcd1 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/AndroidManifest.xml
2025-09-01 05:22:51 +05:30
Tobi
10c5a5d02c Merge pull request #12569 from Isira-Seneviratne/Fix-import
Fix database import
2025-08-27 01:55:55 -07:00
Isira Seneviratne
cf4b5e17c5 Fix database import 2025-08-25 14:32:19 +05:30
Stypox
deb5425871 Merge branch 'dev' into refactor 2025-08-17 12:48:30 +02:00
Isira Seneviratne
2915ab6aef Merge pull request #12462 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-07-25 06:23:19 +05:30
Isira Seneviratne
be662a9f1a Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/AndroidManifest.xml
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
2025-07-24 19:28:41 +05:30
Isira Seneviratne
1534c880ef Fix compilation errors 2025-07-21 09:11:24 +05:30
Isira Seneviratne
ded7205588 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/App.java
#	app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
#	app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
#	app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
#	app/src/main/java/org/schabi/newpipe/player/Player.java
#	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
#	app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
2025-07-21 09:05:32 +05:30
Isira Seneviratne
1f2e5799f7 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
#	app/src/main/res/values/strings.xml
2025-07-20 05:57:27 +05:30
Isira Seneviratne
f93806293a Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
#	app/src/main/res/values-ca/strings.xml
2025-07-18 19:14:58 +05:30
Stypox
da36b8a140 Merge pull request #12337 from Profpatsch/video-detail-fragment-kotlin-conversion 2025-07-16 15:56:39 +02:00
Stypox
046ea7301b Apply Kotlin suggestion by @Isira-Seneviratne 2025-07-16 15:53:05 +02:00
Stypox
3998982002 Smaller style fixes 2025-07-16 15:53:05 +02:00
Stypox
a4182b474b More improve Kotlin converted from java in various places 2025-07-16 15:53:05 +02:00
Stypox
317db719db Fix comments in PlayerUiList 2025-07-16 15:47:00 +02:00
Stypox
3f62ec7e53 Improve Kotlin converted from java in various places 2025-07-16 15:46:59 +02:00
Profpatsch
7330541499 PlayerUIList: remove remaining uses of getOpt
mediaSession is now `@NonNull`, so the getter is as well.
2025-07-16 15:42:59 +02:00
Profpatsch
ed0051a3f6 Player: small class comment 2025-07-16 15:42:59 +02:00
Profpatsch
91aed1e240 VideoDetailFragment: replace every getOpt() with get() 2025-07-16 15:42:59 +02:00
Profpatsch
38ed1da79e PlayerHolder: use object class to implement singleton pattern 2025-07-16 15:42:59 +02:00
Profpatsch
cc3ecd4169 VideoDetailFragment: convert to kotlin (mechanical, fixup)
Mostly 1:1, I had to fix a few places where the automatic conversion
did not infer the right kotlin types, and places where it tried to
convert to `double` instead of using `float` like the original.

Everything else is the result of automatic conversion.
2025-07-16 15:42:58 +02:00
Profpatsch
bf72fd1fa5 VideoDetailFragment: convert to kotlin (mechanical, failing)
Just the conversion, errors still there for easier rebasing later.
2025-07-16 15:42:12 +02:00
Profpatsch
86b27cf77d PlayerHolder: kotlinify optional calls 2025-07-16 15:15:28 +02:00
Profpatsch
4fd3ddf392 PlayerHolder: kotlinify setListener 2025-07-16 15:14:50 +02:00
Profpatsch
fc7daa96e9 PlayerHolder: kotlinify getters 2025-07-16 15:14:50 +02:00
Profpatsch
9b22773070 PlayerHolder: convert to kotlin (mechanical) 2025-07-16 15:14:49 +02:00
Profpatsch
86063fda6a PlayQueueActivity: inline getServiceConnection() and bind()
Both are only used once, and it’s easier to see what’s going on if
they are just inlined in `onCreate`.
2025-07-16 15:07:43 +02:00
Profpatsch
c9be4066f2 PlayerService: inline init block & make non-optional 2025-07-16 15:07:43 +02:00
Profpatsch
462f0ad5c0 PlayerUIList: inline init block 2025-07-16 15:07:43 +02:00
Isira Seneviratne
183cc580fc Merge pull request #12415 from Isira-Seneviratne/Zip-migrate-Path
Migrate zip import/export to use Path
2025-07-12 06:04:31 +05:30
Isira Seneviratne
52ff052d6a Merge pull request #12347 from Isira-Seneviratne/PlayQueueItem-equals
Add PlayQueueItem equals and hashCode
2025-07-10 02:18:43 +05:30
Isira Seneviratne
31f8dd05a7 Convert play queue classes to Kotlin 2025-07-09 07:48:08 +05:30
Isira Seneviratne
c2b6c71947 Rename .java to .kt 2025-07-09 07:48:07 +05:30
Isira Seneviratne
bb7873d157 Fix Sonar warning 2025-07-09 07:48:07 +05:30
Isira Seneviratne
e2a02a1f86 Fix some issues 2025-07-09 07:48:07 +05:30
Isira Seneviratne
690af88db9 Add PlayQueueItem equals and hashCode 2025-07-09 07:48:07 +05:30
Isira Seneviratne
4ffadc2057 Inline variable 2025-07-09 07:47:25 +05:30
Isira Seneviratne
225cb91105 Use InputStream#transferTo() 2025-07-09 07:47:25 +05:30
Isira Seneviratne
3e106b5e4f Fix DB import/export issue 2025-07-09 07:47:25 +05:30
Isira Seneviratne
72b67ab5d4 Refactor zip import/export using Path 2025-07-09 07:47:25 +05:30
Isira Seneviratne
840084d2c9 Merge pull request #12419 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-07-09 07:46:11 +05:30
Isira Seneviratne
2acfefd0bc Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
2025-07-09 07:10:18 +05:30
Stypox
d81b571d05 Merge pull request #12348 from Isira-Seneviratne/Merge-dev-to-refactor 2025-06-11 10:13:36 +02:00
Isira Seneviratne
6efb92a38f Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/MainActivity.java
2025-06-11 08:20:00 +05:30
Stypox
f16becc872 Merge pull request #12217 from SttApollo/Update-EmptyStateComposable 2025-06-06 15:49:13 +02:00
Stypox
60ea317e61 NoComments -> NoVideos in related items screen 2025-06-06 15:36:07 +02:00
Su TT
e53f0ff94a Update EmptyStateComposable usages to include modifiers 2025-06-05 12:33:21 -04:00
Profpatsch
006b4c9ae1 Merge pull request #11965 from Profpatsch/player-classes-kotlin-conversion
Some simple refactors & beginning of kotlin conversions of the player classes
2025-06-05 14:13:33 +02:00
Profpatsch
73fef268fc PlayerService/PlayerUIList: some small improvements 2025-06-05 13:52:04 +02:00
Profpatsch
cf8fe95abf PlayerService: runtime-assert that we get passed a service
We directly call the `getService` function after receiving the
argument, so resolving the WeakPointer should never return `null` in
our case.
Of course there could be a race condition in theory, but I feel like
if that happens we have bigger problems?
2025-06-05 13:52:04 +02:00
Profpatsch
36115c3164 PlayerService: remove !! where possible
It’s a bit unwieldy in places, but should improve the safety of the
code in the face of possible race conditions.
2025-06-05 13:52:04 +02:00
Profpatsch
be373dca8d PlayerUIList: make UI list private 2025-06-05 13:52:04 +02:00
Profpatsch
f5a4af2d67 Player: destroy -> saveAndShutdown 2025-06-05 13:52:04 +02:00
Profpatsch
06cf511188 PlayerHolder: improve interface docstrings 2025-06-05 13:52:04 +02:00
Profpatsch
26050d808e VideoPlayerUi: suppress warnings
The `R.id` link from the comment cannot be resolved, so let’s not link
it for now.

We are using some exoplayer2 resources, let’s silence the warning.
2025-06-05 13:52:04 +02:00
Profpatsch
0b32738d42 VideoDetailFragment: remove duplicate code in startLoading 2025-06-05 13:52:04 +02:00
Profpatsch
c37db85b97 VideoDetailFragment: apply more IDE suggestions 2025-06-05 13:52:04 +02:00
Profpatsch
4d6e1a4ecf VideoDetailFragment: apply visibility suggestions
Because the class is final, protected does not make sense (Android
Studio auto-suggestions)
2025-06-05 13:52:04 +02:00
Profpatsch
b5dd49ecd3 PlayerService: simplify nullable calls, getters 2025-06-05 13:52:04 +02:00
Profpatsch
945fbd884b PlayerService: Convert to kotlin (mechanical) 2025-06-05 13:52:04 +02:00
Profpatsch
545c4f078f PlayerUIList: restrict superclasses a little 2025-06-05 13:52:04 +02:00
Stt_lens
d4cd54fd7b Update app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt
Co-authored-by: Stypox <stypox@pm.me>
2025-05-31 12:27:16 -04:00
Stt_lens
15ab3df511 Update app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt
Co-authored-by: Stypox <stypox@pm.me>
2025-05-31 12:07:37 -04:00
Isira Seneviratne
f41b34c090 Merge pull request #11759 from Isira-Seneviratne/Import-export-worker
Rewrite import and export subscriptions functionality using coroutines
2025-05-16 01:16:34 +05:30
Stypox
d1954baf29 Merge pull request #11829 from Profpatsch/PlayerUIList-to-kotlin
Player UI list to kotlin
2025-05-13 23:36:16 +02:00
Profpatsch
a8da9946d1 PlayerUiList: guard list actions with mutex
The new implementation would throw `ConcurrentModificationExceptions`
when destroying the UIs. So let’s play it safe and put the list behind
a mutex.

Adds a helper class `GuardedByMutex` that can be wrapped around a
property to force all use-sites to acquire the lock before doing
anything with the data.
2025-05-11 15:23:03 +02:00
Profpatsch
3d069cdf5b PlayerUIList: rename get to getOpt and make get nullable
In Kotlin, dealing with nulls works better so we don’t need optional.
2025-05-11 15:12:37 +02:00
Profpatsch
eccedc0ab0 PlayerUIList: transform to kotlin
And simplify the code a little
2025-05-11 15:06:52 +02:00
Stypox
7cecda5713 Merge branch 'dev' into refactor
Had to make some adjustments to make https://github.com/TeamNewPipe/NewPipe/pull/12188 work
2025-05-08 15:34:00 +02:00
Isira Seneviratne
b6144d01f3 Merge branch 'refactor' into Import-export-worker 2025-04-29 10:18:13 +05:30
Su TT
df11e53a74 Refactor EmptyStateComposable to remove modifier from EmptyStateSpec and fix modifier usage ... 2025-04-28 17:24:11 -04:00
Isira Seneviratne
1d94fd1582 Merge pull request #12195 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-04-27 08:01:33 +05:30
Isira Seneviratne
c9542ad6fd Update extractor 2025-04-27 07:43:52 +05:30
Isira Seneviratne
cbe598182a Merge branch 'refactor' into Import-export-worker 2025-04-20 07:10:07 +05:30
Isira Seneviratne
7615f79aca Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
2025-04-14 07:29:30 +05:30
Stypox
47299c9184 Merge pull request #12164 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-04-08 10:55:28 +02:00
Isira Seneviratne
6486f2de56 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
#	app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
#	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
#	app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java
#	app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
#	app/src/main/res/values-is/strings.xml
2025-04-08 05:42:31 +05:30
Isira Seneviratne
d805679a5e Use workDataOf 2025-03-08 09:18:30 +05:30
Stypox
c1bdffd917 Merge pull request #11978 from Profpatsch/fix-back-button-on-remaining-stack
MainActivity: Fix onBackPressed handling for open player
2025-02-26 16:56:04 +01:00
Isira Seneviratne
abaf16e12b Merge branch 'refactor' into Import-export-worker
# Conflicts:
#	gradle/libs.versions.toml
2025-02-23 05:10:34 +05:30
Stypox
99aae7eb28 Merge branch 'dev' into refactor 2025-02-05 15:15:41 +01:00
Profpatsch
fd99c5e461 MainActivity: Fix onBackPressed handling for open player
The change
b9dd7078ad
accidentally moved the `return` into the `{}`, so the logic would fall
through to

```
if (fragmentManager.getBackStackEntryCount() == 1) {`
```

and close the app even though there are still items on the
`VideoFragmentDetail` stack.

To reproduce:
Start video, enqueue another video, then start a third video (which
adds one entry to the stack), and press `back` on the expanded video.

This should keep the player open and go back to the first 2-video
queue, but it actually closes the app before this fix.
2025-01-30 19:40:44 +01:00
Stypox
407d2d768d Merge pull request #11539 from Isira-Seneviratne/Compose-theme-improvements
Compose theme improvements
2025-01-28 14:02:50 +01:00
Profpatsch
b109e4d3cc Merge pull request #11867 from Profpatsch/player-holder-refactor
PlayerHolder refactor
2025-01-27 13:29:53 +01:00
Profpatsch
137ade24ff Adjust javadoc format 2025-01-27 12:45:30 +01:00
Isira Seneviratne
48d682016e Rm ViewPager version 2025-01-25 11:21:58 +05:30
Isira Seneviratne
b78e0b2da8 Merge branch 'refactor' into Compose-theme-improvements 2025-01-25 09:41:29 +05:30
Isira Seneviratne
32a88ab890 Truncate existing file in export 2025-01-25 07:57:01 +05:30
Isira Seneviratne
af3ed992e5 Add error handling for import 2025-01-25 05:40:00 +05:30
Isira Seneviratne
a79516dfff Use fragment arguments 2025-01-25 05:30:14 +05:30
Profpatsch
dbd11a6a8d SubscriptionImportWorker: inputs as sealed class 2025-01-22 15:34:04 +01:00
Isira Seneviratne
21973b362a Use Kotlin Pair 2025-01-22 13:03:57 +01:00
Isira Seneviratne
60586c90d6 Improve subscription upsert methods 2025-01-22 13:03:57 +01:00
Isira Seneviratne
095155d35f Only get subscription extractor when needed 2025-01-22 13:03:57 +01:00
Isira Seneviratne
af8e5646a6 Remove LocalBroadcastManager 2025-01-22 13:03:57 +01:00
Isira Seneviratne
c9d155a335 Combine notification and ForegroundInfo creation methods 2025-01-22 13:03:57 +01:00
Isira Seneviratne
4e31ccebf8 Moved Kotlinx Serialization to library catalog 2025-01-22 13:03:57 +01:00
Isira Seneviratne
c0965a42a1 Added success toasts 2025-01-22 13:03:57 +01:00
Isira Seneviratne
fc7f1b0af0 Convert subscription import service to a worker 2025-01-22 13:03:57 +01:00
Isira Seneviratne
dfb035dfa5 Improve import/export tests 2025-01-22 13:03:57 +01:00
Isira Seneviratne
8e9503cfe4 Convert subscription export service to a worker 2025-01-22 13:03:55 +01:00
Isira Seneviratne
82516dd75c Rename .java to .kt 2025-01-22 13:02:36 +01:00
Stypox
3e6e980362 Merge branch 'dev' into refactor 2025-01-22 11:12:51 +01:00
Profpatsch
1890fbb19a Merge pull request #11809 from Isira-Seneviratne/Merge-dev
Merge dev to refactor
2025-01-21 17:56:00 +01:00
Isira Seneviratne
efb3aa530d Merge branch 'dev' into Merge-dev 2025-01-11 18:45:51 +05:30
Profpatsch
ce919215fb PlayerHolder: Separate holder and service event interface
Should make it easier to seperate the two further later, both of them
are only implemented by VideoDetailFragment anyway, which is kind of a
code smell!
2024-12-26 01:31:17 +01:00
Profpatsch
6a4aaba431 PlayerHolder: add some more docstrings 2024-12-26 01:02:59 +01:00
Profpatsch
83d93e16e7 PlayerHolder: move unbind right next to stopService 2024-12-26 00:36:49 +01:00
Profpatsch
8d15a141b1 PlayerHolder: invert isBound 2024-12-26 00:26:59 +01:00
Profpatsch
a78bed700a PlayerHolder: inline bind
Only used once. Now the code looks weird … why is the service started
twice??
2024-12-26 00:26:22 +01:00
Profpatsch
ef3c76645f PlayerHolder/PlayerService: inline & remove duplicate player passing
The player in playerHolder is exactly the player inside the
`PlayerService`, which in turn is exactly passed through the IBinder
interface. Thus we don’t have to pass both.

Instead add `PlayerService.getPlayer()`.

Also inline a few methods of `PlayerHolder` and simplify.
2024-12-25 22:14:22 +01:00
Isira Seneviratne
d4ed18bf08 Merge branch 'dev' into Merge-dev
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/App.java
#	app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
#	app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java
#	app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
#	app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
#	app/src/main/res/values-bg/strings.xml
#	app/src/main/res/values-da/strings.xml
#	app/src/main/res/values-is/strings.xml
#	app/src/main/res/values-lv/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	build.gradle
2024-12-21 07:45:20 +05:30
Isira Seneviratne
fbafdeb2ca Merge pull request #11767 from tsiflimagas/remove_viewpager2
Remove ViewPager2 dependency
2024-12-17 08:49:01 +05:30
Kostas Giapis
781040efaa Remove ViewPager2 dependency 2024-12-01 22:24:39 +02:00
Isira Seneviratne
1547b50b4e Merge branch 'refactor' into Compose-theme-improvements 2024-11-28 06:12:33 +05:30
Stypox
3f7ef49979 NewPipe license is GPLv3-or-later, not -only, in AboutScreen 2024-11-27 22:15:23 +01:00
Stypox
dab0148a78 Merge pull request #11750 from Isira-Seneviratne/Fix-image-loading
Fix image loading
2024-11-27 16:50:38 +01:00
Stypox
c0c08a4f63 Merge pull request #11282 from Isira-Seneviratne/About-Compose
Migrate about activity to Jetpack Compose
2024-11-27 16:42:35 +01:00
Stypox
aaf337421d Merge branch 'refactor' into pr11282 2024-11-27 16:20:49 +01:00
Stypox
79a0edacd7 Merge pull request #11752 from JL0000/sort-dependencies
Sort dependencies in `libs.versions.toml`
2024-11-27 16:10:31 +01:00
Stypox
d56eef6ece Use content padding instead of padding on container 2024-11-27 15:59:20 +01:00
Stypox
72f054a4fa Library should not be clickable if spdx is blank 2024-11-27 15:46:39 +01:00
Jie Li
172c3c92ac gradle script to enforce dependencies order 2024-11-26 18:32:44 +00:00
Isira Seneviratne
137ef3fee4 Fix image loading 2024-11-26 10:08:27 +05:30
Stypox
e49156fb11 Merge pull request #11684 from JL0000/version-catalogs
Migrate build to version catalogs
2024-11-25 19:05:52 +01:00
Jie Li
de5d45849f migrated to version catalogs 2024-11-25 23:12:29 +05:30
Stypox
a25034b898 Fix toolbar colors in light theme 2024-11-25 04:43:43 +01:00
Stypox
ae9e82b2c1 Implement showing libraries and licenses 2024-11-25 04:43:43 +01:00
Stypox
a70b38a8e5 Minor updates to some libraries 2024-11-25 03:56:13 +01:00
Isira Seneviratne
08f3dba42c Merge branch 'refactor' into Compose-theme-improvements
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/ui/components/common/NoItemsMessage.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt
2024-11-25 07:22:03 +05:30
Stypox
0cff3a6ecd Improve AboutTab spacing 2024-11-24 16:06:21 +01:00
Profpatsch
9b78e49e45 Merge pull request #11725 from Profpatsch/lwj.compose_migrate_empty_state_view
Migrate empty_state_view xml/view to Jetpack Compose
2024-11-22 11:49:22 +01:00
Isira Seneviratne
e6eea8f851 Merge branch 'refactor' into Compose-theme-improvements 2024-11-21 21:26:03 +05:30
Isira Seneviratne
4e55f1bee6 Merge branch 'refactor' into About-Compose 2024-11-21 21:11:52 +05:30
Stypox
cff3834fde Fix setEmptyStateComposable dark theme 2024-11-21 13:17:33 +01:00
Stypox
c8b01a06b0 Use empty state view in compose 2024-11-21 13:14:39 +01:00
Stypox
414b1a8344 Remove unused methods in EmptyStateUtil 2024-11-21 13:14:19 +01:00
Stypox
404d9f3fac Use empty state view in a few more places 2024-11-21 12:42:58 +01:00
Stypox
55e4014036 Use custom EmptyStateSpec for bookmark fragment 2024-11-21 12:24:11 +01:00
Stypox
1cd5563b27 All empty states now have the same style 2024-11-21 12:14:40 +01:00
Stypox
1abced992b Use normal colors for empty state view 2024-11-21 12:07:03 +01:00
Stypox
46b9243661 Remove unneeded empty state changes in ChannelFragment 2024-11-21 11:53:48 +01:00
toliuweijing
ad72b2cb31 boost error hint color 2024-11-21 11:52:42 +01:00
toliuweijing
8b79d0ee29 Migrate empty_state_view to Jetpack Compose 2024-11-21 11:52:42 +01:00
Stypox
295f719b77 Merge pull request #11723 from Isira-Seneviratne/Coil-3
Migrate to Coil 3
2024-11-21 10:56:07 +01:00
Stypox
b584353f4d Small fixes to code style 2024-11-21 10:52:15 +01:00
Isira Seneviratne
d73314b4dd Make App instance variable immutable outside class 2024-11-21 08:09:57 +05:30
Isira Seneviratne
9f4a33c7a8 Fix lint 2024-11-21 06:56:10 +05:30
Isira Seneviratne
3a9540b042 Update app/src/main/java/org/schabi/newpipe/App.kt
Co-authored-by: Tobi <TobiGr@users.noreply.github.com>
2024-11-20 16:04:39 +05:30
Isira Seneviratne
ca855cbca0 Migrate to Coil 3 2024-11-20 09:28:20 +05:30
Isira Seneviratne
6a98b1dac7 Rename .java to .kt 2024-11-20 08:44:16 +05:30
Isira Seneviratne
7d4a2836fc Use existing scrollbar theme method 2024-11-16 16:45:35 +05:30
Isira Seneviratne
6ea715a18d Clean up unnecessary manual color specification in Compose code 2024-11-16 16:09:10 +05:30
Isira Seneviratne
a56debfce6 Merge branch 'refs/heads/refactor' into Compose-theme-improvements
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt
2024-11-16 15:50:48 +05:30
Isira Seneviratne
226b6de34f Merge branch 'refs/heads/refactor' into About-Compose
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
#	build.gradle
2024-11-16 15:41:50 +05:30
Stypox
13585ca0be Avoid drawing surface background twice for comments fragment 2024-11-11 16:15:36 +01:00
Stypox
62ab9bd740 Merge pull request #11060 from Isira-Seneviratne/Comments-Compose
Migrate comment fragments to Jetpack Compose
2024-11-11 16:12:53 +01:00
Stypox
fdf36cbad6 Deduplicate and improve Scrollbar theme 2024-11-11 15:20:38 +01:00
Stypox
aea2b7c7f3 Show correct reply count in dialog 2024-11-11 14:58:54 +01:00
Stypox
37d1c784fa Create utilities to copy to clipboard in Compose code 2024-11-11 14:58:54 +01:00
Stypox
cea149f852 Add .kotlin/ to gitignore 2024-11-11 14:26:01 +01:00
Stypox
a92a28517e Use Icons.Default.* instead of vector assets 2024-11-11 14:25:28 +01:00
Stypox
800961c3d7 Unexpand bottom sheet dialog when clicking on a channel 2024-11-11 13:51:24 +01:00
Stypox
9d8a79b0bd Slightly improve comment replies header spacing 2024-11-11 13:34:18 +01:00
Stypox
ef56dea817 Fix content color in comment replies fragment 2024-11-11 00:29:29 +01:00
Stypox
23b3835af0 Fix PagingSource for comments
The previous implementation was skipping the first page of comments
2024-11-11 00:16:32 +01:00
Stypox
412e1d602a Better handle unknown values for comment & like count 2024-11-10 23:45:10 +01:00
Stypox
802a094154 Improve comment replies dialog layout 2024-11-10 23:28:59 +01:00
Stypox
e6b1341246 Improve Comment layout 2024-11-10 23:09:29 +01:00
Stypox
36ede243e3 Update compose bom and fix navigation compose without version 2024-11-10 20:53:49 +01:00
Stypox
bac9f7eebf Merge branch 'refactor' into pr11060 2024-11-10 16:50:46 +01:00
litetex
8ada566bf1 Replaced `Icepick with Bridge and Android-State`
* IcePick fails on Java 21 (default in Android Studio 2024.2)
* Bridge is the most modern alternative that is currently available. It is backed by ``Android-State`` and can be configured with various frameworks
* In the long term this should be replaced with something better
2024-11-10 16:42:42 +01:00
litetex
5bd4ed77df Fix Android Gradle plugin warning 2024-11-10 16:42:42 +01:00
litetex
97652ac015 Update Gradle to latest version 2024-11-10 16:42:41 +01:00
litetex
6dd24033a4 Replace symlink with original
Co-Authored-By: Thompson3142 <115718208+thompson3142@users.noreply.github.com>
2024-11-10 16:42:41 +01:00
litetex
4de3ef20be Delete symlink 2024-11-10 16:42:41 +01:00
litetex
702f74291d Use working Extractor version
The tag can't be resolved by Jitpack so use the commit-hash instead
2024-11-10 16:42:41 +01:00
litetex
d8759993a9 CI: Use Java 21 2024-11-10 16:42:41 +01:00
litetex
7787eafd3a Fix build failing locally due to outdated kotlin version 2024-11-10 16:42:41 +01:00
Isira Seneviratne
4f4136c6e9 Merge branch 'refs/heads/refactor' into About-Compose
# Conflicts:
#	app/build.gradle
#	build.gradle
2024-10-22 20:15:07 +05:30
Siddhesh Naik
b399030e19 Settings redesign debug page (#10876)
Initial Work for Settings Page with Jetpack Compose

- Implemented a new settings page using Jetpack Compose.
- Added a new settings option to enable the redesigned settings page.
- This option allows for gradual integration and testing of the new
  settings page, minimizing disruptions to current functionality.

Plan for Settings Items:
- Jetpack Compose does not have a direct equivalent to the
  Preference/settings library.
- We could consider using third-party libraries that offer preference
  items as composables.
- However, these libraries may be incomplete or lack active development.
- Given our specific needs for only a subset of preference types,
  creating custom composables would be beneficial.
- This approach allows for fine-tuning the components to our specific
  use case.
2024-10-22 00:47:26 +05:30
Isira Seneviratne
0991461d04 Merge branch 'refs/heads/refactor' into About-Compose 2024-09-26 07:01:03 +05:30
Stypox
49bcf2c41b Merge branch 'dev' into refactor 2024-09-24 14:45:59 +02:00
Isira Seneviratne
c00c6c460c Add scaffold preview, use container color in about screen and scaffold 2024-09-17 04:26:36 +05:30
Isira Seneviratne
4c4fe3f511 Add scrollbar color scheme 2024-09-16 16:28:49 +05:30
Isira Seneviratne
db485c3d77 Remove unnecessary annotation 2024-09-16 16:15:37 +05:30
Isira Seneviratne
c0388d948b Add colors for Compose scrollbars 2024-09-16 15:33:41 +05:30
Isira Seneviratne
43bbddcc26 Add theme generated from the Material Theme Builder 2024-09-16 15:27:21 +05:30
Isira Seneviratne
6471b64ab6 Update dependencies 2024-09-16 12:53:16 +05:30
Isira Seneviratne
b9fcf0dff8 Enable edge-to-edge display 2024-09-16 12:45:03 +05:30
Isira Seneviratne
3177ca6e8a Avoid issues if context is a ContextWrapper 2024-09-11 21:57:51 +05:30
Isira Seneviratne
5017f4f05a Update dependencies 2024-09-05 09:23:00 +05:30
Isira Seneviratne
823d4a041f Improve loading indicator positioning 2024-08-30 16:59:15 +05:30
Isira Seneviratne
62d4044d6c Make lazy column scrollbars red 2024-08-30 09:02:56 +05:30
Isira Seneviratne
3785404618 Display number of comments 2024-08-30 08:46:02 +05:30
Isira Seneviratne
c98ad62163 Implement black theme in Compose 2024-08-29 08:06:56 +05:30
Isira Seneviratne
4cac111b66 Reduce preview count 2024-08-29 07:46:37 +05:30
Isira Seneviratne
941b8eb194 Implement copy on long click 2024-08-29 07:24:03 +05:30
Isira Seneviratne
b1add13bfd Address code review comments 2024-08-28 18:15:11 +05:30
Isira Seneviratne
5fffee2c7d Fix text color in bottom sheet 2024-08-28 17:59:38 +05:30
Isira Seneviratne
f9340ae604 Improve compose function organisation 2024-08-27 08:19:37 +05:30
Isira Seneviratne
d3a6991fd4 Use Fragment.content extension, improve comment composables 2024-08-26 19:29:46 +05:30
Isira Seneviratne
b0bfd4a807 Merge branch 'refs/heads/refactor' into About-Compose
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
#	build.gradle
2024-08-23 20:16:19 +05:30
Isira Seneviratne
3641698379 Merge branch 'refs/heads/refactor' into Comments-Compose
# Conflicts:
#	app/build.gradle
2024-08-23 20:13:03 +05:30
Isira Seneviratne
2836191fb3 Migrate related items fragment to Jetpack Compose (#11383)
* Rename .java to .kt

* Migrate related items fragment to Jetpack Compose

* Specify mode parameter explicitly

* Rm unused class

* Fix list item size

* Added stream progress bar, separate stream and playlist thumbnails

* Display message if no related streams are available

* Dispose of related items when closing the video player

* Add modifiers for no items message function

* Implement remaining stream menu items

* Improved stream composables

* Use view model lifecycle scope

* Make live color solid red

* Use nested scroll modifier

* Simplify determineItemViewMode()
2024-08-23 19:51:32 +05:30
Isira Seneviratne
0df6c7fc2c Remove unused assets 2024-08-23 14:48:41 +05:30
Isira Seneviratne
b1ebd3ecd9 Update Compose BOM 2024-08-23 14:22:45 +05:30
Isira Seneviratne
4758244cf5 Use AboutLibraries to display library information 2024-08-23 14:05:50 +05:30
Isira Seneviratne
294b9cf347 Rm unused declaration 2024-08-17 08:25:39 +05:30
Isira Seneviratne
6d05af484e Use int array 2024-08-11 09:31:09 +05:30
Isira Seneviratne
e082bca5e0 Use nested scroll modifier 2024-08-11 08:23:13 +05:30
Isira Seneviratne
f9dae9078e Always show comment thumbnails, even if placeholders 2024-08-11 08:23:13 +05:30
Isira Seneviratne
e955beeef1 Update Kotlin to 2.0, update dependencies and fix issues 2024-08-11 08:23:10 +05:30
Isira Seneviratne
eaac7f3f85 Improved component organisation 2024-08-11 08:21:53 +05:30
Isira Seneviratne
ea414f57d4 Added DescriptionText composable 2024-08-11 08:21:53 +05:30
Isira Seneviratne
f984b26626 Fix some modifiers 2024-08-11 08:21:53 +05:30
Isira Seneviratne
edab9a6a1f Fix alignment of comment message 2024-08-11 08:21:53 +05:30
Isira Seneviratne
4740e3be86 Make parsed links clickable, visible 2024-08-11 08:21:53 +05:30
Isira Seneviratne
e639b02fed Animate comment expand/collapse 2024-08-11 08:21:53 +05:30
Isira Seneviratne
ac1ca1412d Improve comment loading smoothness 2024-08-11 08:21:52 +05:30
Isira Seneviratne
d131d3399a Rm unused method 2024-08-11 08:21:52 +05:30
Isira Seneviratne
1009dc4d4e Added loading indicator 2024-08-11 08:21:52 +05:30
Isira Seneviratne
42cb914616 Replace padding modifier with verticalArrangement in comment header 2024-08-11 08:21:52 +05:30
Isira Seneviratne
e72da94eb1 Rm extra padding in header 2024-08-11 08:21:52 +05:30
Isira Seneviratne
c5d94a5b60 Add comment view model 2024-08-11 08:21:52 +05:30
Isira Seneviratne
02c5f2607a Cache paging data using the cachedIn() extension 2024-08-11 08:21:52 +05:30
Isira Seneviratne
369a46f8fe Improve code organization 2024-08-11 08:21:52 +05:30
Isira Seneviratne
909d214002 Rm redundant Surface 2024-08-11 08:21:52 +05:30
Isira Seneviratne
5e7e14ee4d Handle no comments and comments disabled scenarios 2024-08-11 08:21:52 +05:30
Isira Seneviratne
b092fe2c76 Replace Spacers with the horizontalArrangement parameter 2024-08-11 08:21:52 +05:30
Isira Seneviratne
b9dd7078ad Replace CommentRepliesFragment with bottom sheet composable, improve previews 2024-08-11 08:21:52 +05:30
Isira Seneviratne
93310955f2 Added scrollbar to comment section 2024-08-11 08:21:52 +05:30
Isira Seneviratne
9c52e039ee Migrate comments fragment to Jetpack Compose 2024-08-11 08:21:52 +05:30
Isira Seneviratne
be037e0756 Rename .java to .kt 2024-08-11 08:21:52 +05:30
Isira Seneviratne
5bfb0449cf Fixed fragment title 2024-08-11 08:21:52 +05:30
Isira Seneviratne
0ec81c9e52 Fixed like count display 2024-08-11 08:21:52 +05:30
Isira Seneviratne
5841eaa6d7 Set view strategy 2024-08-11 08:21:52 +05:30
Isira Seneviratne
e92ba8f5d1 Add replies button 2024-08-11 08:21:52 +05:30
Isira Seneviratne
1908e18dc4 Use AnnotatedString to handle HTML parsing 2024-08-11 08:21:52 +05:30
Isira Seneviratne
e30d5e4305 Fixed some comment issues 2024-08-11 08:21:52 +05:30
Isira Seneviratne
11bb2495ba Improve previews, display date of comment 2024-08-11 08:21:52 +05:30
Isira Seneviratne
341cc37ce7 Update replies fragment to use the comment composable as well 2024-08-11 08:21:52 +05:30
Isira Seneviratne
1620668966 Add comment ellipsis 2024-08-11 08:21:51 +05:30
Isira Seneviratne
56c80ce6dd Added missing comment features, fixed theming 2024-08-11 08:21:51 +05:30
Isira Seneviratne
8ce9a7e43c Added like count 2024-08-11 08:21:51 +05:30
Isira Seneviratne
e05d97732e Use reply header composable in fragment 2024-08-11 08:21:51 +05:30
Isira Seneviratne
644a345b55 Rename .java to .kt 2024-08-11 08:21:51 +05:30
Isira Seneviratne
bda961a04c Convert comment replies views to Jetpack Compose 2024-08-11 08:21:51 +05:30
Isira Seneviratne
ba2efded76 Replace Picasso with Coil in about 2024-08-11 08:13:21 +05:30
Isira Seneviratne
b05b98ca61 Improved component organisation 2024-08-11 08:13:21 +05:30
Isira Seneviratne
7a7f81ac7f Fix tab text color 2024-08-11 08:13:21 +05:30
Isira Seneviratne
6e6c171dd7 Added new icon 2024-08-11 08:13:21 +05:30
Isira Seneviratne
8a41c8cf66 Added buttons to alert dialog 2024-08-11 08:13:21 +05:30
Isira Seneviratne
05271d95a9 Migrate about activity to Jetpack Compose 2024-08-11 08:13:21 +05:30
Isira Seneviratne
9d04a73c85 Merge dev to refactor (#11427)
* add NP icon for Android Studio's NewUI

* Fix NPE in MediaSessionPlayerUi while destroying player

* Update NewPipeExtractor to v0.24.1

* Add changelogs for hotfix release v0.27.1 (998)

* Hotfix release v0.27.1 (998)

* Update README.pt_BR.md (#11275)

* Update Matrix room link, and prioritise it (#11350)

* Update Matrix room link, and prioritise it

* Update Matrix room link in CONTRIBUTING.md

* Prioritise Matrix in contribution doc too

* Update NewPipeExtractor to v0.24.2

* Hotfix release v0.27.2 (999)

* Add changelogs for hotfix release v0.27.2 (999)

* Don't warn about rhino class in proguard

Likely related to 01a7b20655 but I am not completely sure.
I tested the app and it works well, so I think that org.mozilla.javascript.JavaToJSONConverters is not used really.

This is the full list of errors:
Missing class java.beans.BeanDescriptor (referenced from: java.lang.Object org.mozilla.javascript.JavaToJSONConverters.lambda$static$4(java.lang.Object))
Missing class java.beans.BeanInfo (referenced from: java.lang.Object org.mozilla.javascript.JavaToJSONConverters.lambda$static$4(java.lang.Object))
Missing class java.beans.IntrospectionException (referenced from: java.lang.Object org.mozilla.javascript.JavaToJSONConverters.lambda$static$4(java.lang.Object))
Missing class java.beans.Introspector (referenced from: java.lang.Object org.mozilla.javascript.JavaToJSONConverters.lambda$static$4(java.lang.Object))
Missing class java.beans.PropertyDescriptor (referenced from: java.lang.Object org.mozilla.javascript.JavaToJSONConverters.lambda$static$4(java.lang.Object))

* Remove code committed accidentally

---------

Co-authored-by: Christian Schabesberger <chris.schabesberger@mailbox.org>
Co-authored-by: Tobi <TobiGr@users.noreply.github.com>
Co-authored-by: Stypox <stypox@pm.me>
Co-authored-by: #27 <68751594+tag27@users.noreply.github.com>
Co-authored-by: opusforlife2 <53176348+opusforlife2@users.noreply.github.com>
2024-08-11 08:11:50 +05:30
Stypox
d336f4cef2 Merge pull request #11238 from Isira-Seneviratne/Coil
Migrate image loading from Picasso to Coil
2024-08-07 18:45:16 +02:00
Isira Seneviratne
4ec7532126 Addressed code review comments 2024-07-23 05:25:55 +05:30
Isira Seneviratne
da83646303 Update Coil 2024-07-22 08:12:37 +05:30
Isira Seneviratne
5062d38b65 Merge pull request #11237 from TeamNewPipe/revert-11201-Coil
Revert "Migrate image loading from Picasso to Coil"
2024-07-05 08:40:34 +05:30
Isira Seneviratne
82b492c050 Revert "Migrate image loading from Picasso to Coil (#11201)"
This reverts commit 73e3a69aaf.
2024-07-05 08:29:21 +05:30
Isira Seneviratne
73e3a69aaf Migrate image loading from Picasso to Coil (#11201)
* Load notification icons using Coil

* Migrate to Coil from Picasso

* Clean up Picasso leftovers

* Enable RGB-565 for low-end devices

* Added Coil helper method

* Add annotation

* Simplify newImageLoader implementation

* Use Coil's default disk and memory cache config

* Enable crossfade animation

* Correct method name

* Fix thumbnail not being displayed in media notification
2024-07-03 18:53:04 +05:30
Isira Seneviratne
348a79f91d Fix thumbnail not being displayed in media notification 2024-07-03 14:41:47 +05:30
Isira Seneviratne
c4ada7ff6e Correct method name 2024-07-03 09:30:47 +05:30
Isira Seneviratne
39d0691c7e Enable crossfade animation 2024-07-03 09:10:57 +05:30
Isira Seneviratne
71361de8ee Use Coil's default disk and memory cache config 2024-07-03 09:10:54 +05:30
Isira Seneviratne
8aa2590fd3 Simplify newImageLoader implementation 2024-07-03 09:10:52 +05:30
Isira Seneviratne
e3b7bf467e Add annotation 2024-07-03 09:10:49 +05:30
Isira Seneviratne
f74402bc94 Added Coil helper method 2024-07-03 09:10:46 +05:30
Isira Seneviratne
4d3b4a7b20 Enable RGB-565 for low-end devices 2024-07-03 09:10:44 +05:30
Isira Seneviratne
e6302cc868 Clean up Picasso leftovers 2024-07-03 09:10:40 +05:30
Isira Seneviratne
844b4edf48 Migrate to Coil from Picasso 2024-07-03 09:10:37 +05:30
Isira Seneviratne
92a7f22d3c Load notification icons using Coil 2024-07-03 09:10:34 +05:30
Isira Seneviratne
03167a1e9c Merge pull request #11234 from TeamNewPipe/dev
Merge dev to refactor
2024-07-03 09:05:32 +05:30
Stypox
d479f29e9b Merge pull request #10875 from snaik20/intro-jetpack-compose
Introducing Jetpack Compose in NewPipe
2024-05-13 21:17:11 +02:00
Siddhesh Naik
1af798b04b Introducing Jetpack Compose in NewPipe
This pull request integrates Jetpack Compose into NewPipe by:
- Adding the necessary dependencies and setup.
- This is part of the NewPipe rewrite and fulfils the requirement for
  the planned settings page redesign.
- Introducing a Toolbar composable with theming that aligns with
  NewPipe's design.

Note:
- Theme colors are generated using the Material Theme builder (https://m3.material.io/styles/color/overview).
2024-05-13 03:53:35 +05:30
495 changed files with 13477 additions and 11607 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

@@ -69,7 +69,7 @@ jobs:
strategy:
matrix:
include:
- api-level: 21
- api-level: 23
target: default
arch: x86
- api-level: 35

4
.gitignore vendored
View File

@@ -11,6 +11,7 @@ captures/
*.class
app/debug/
app/release/
.kotlin/
# vscode / eclipse files
*.classpath
@@ -19,3 +20,6 @@ app/release/
bin/
.vscode/
*.code-workspace
# logs
*.log

View File

@@ -2,14 +2,19 @@
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.jetbrains.kotlin.compose)
alias(libs.plugins.jetbrains.kotlin.kapt)
alias(libs.plugins.google.ksp)
alias(libs.plugins.jetbrains.kotlin.parcelize)
alias(libs.plugins.jetbrains.kotlinx.serialization)
alias(libs.plugins.google.ksp)
alias(libs.plugins.sonarqube)
alias(libs.plugins.hilt)
alias(libs.plugins.about.libraries)
checkstyle
}
@@ -39,12 +44,12 @@ android {
defaultConfig {
applicationId = "org.schabi.newpipe"
resValue("string", "app_name", "NewPipe")
minSdk = 21
minSdk = 23
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"
@@ -110,6 +115,7 @@ android {
buildFeatures {
viewBinding = true
compose = true
buildConfig = true
}
@@ -134,6 +140,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
@@ -201,6 +214,13 @@ sonar {
}
}
aboutLibraries {
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
// harmful for reproducible builds
offlineMode = true
duplicationMode = DuplicateMode.MERGE
}
dependencies {
/** Desugaring **/
coreLibraryDesugaring(libs.android.desugar)
@@ -214,16 +234,18 @@ dependencies {
checkstyle(libs.puppycrawl.checkstyle)
ktlint(libs.pinterest.ktlint)
/** Kotlin **/
implementation(libs.kotlin.stdlib)
/** AndroidX **/
implementation(libs.androidx.appcompat)
implementation(libs.androidx.cardview)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.media)
implementation(libs.androidx.preference)
implementation(libs.androidx.recyclerview)
@@ -231,13 +253,44 @@ dependencies {
implementation(libs.androidx.room.rxjava3)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.work.rxjava3)
implementation(libs.google.android.material)
implementation(libs.androidx.webkit)
/** Compose & other modern patterns **/
// Jetpack Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.adaptive)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
implementation(libs.androidx.compose.material.icons.extended)
// Jetpack Compose related dependencies
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.navigation.compose)
// Coroutines interop
implementation(libs.kotlinx.coroutines.rx3)
// Library loading for About screen
implementation(libs.about.libraries.compose.m3)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Scroll
implementation(libs.lazy.column.scrollbar)
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
/** Third-party libraries **/
// Instance state boilerplate elimination
implementation(libs.livefront.bridge)
implementation(libs.evernote.statesaver.core)
kapt(libs.evernote.statesaver.compiler)
@@ -263,7 +316,8 @@ dependencies {
implementation(libs.lisawray.groupie.viewbinding)
// Image loading
implementation(libs.squareup.picasso)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// Markdown library for Android
implementation(libs.noties.markwon.core)
@@ -295,6 +349,9 @@ dependencies {
debugImplementation(libs.facebook.stetho.core)
debugImplementation(libs.facebook.stetho.okhttp3)
// Jetpack Compose
debugImplementation(libs.androidx.compose.ui.tooling)
/** Testing **/
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
@@ -303,4 +360,7 @@ dependencies {
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.assertj.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

View File

@@ -0,0 +1,48 @@
tasks.register('checkDependenciesOrder') {
group = 'verification'
description = 'Checks that each section in libs.versions.toml is sorted alphabetically'
def tomlFile = file('../gradle/libs.versions.toml')
doLast {
if (!tomlFile.exists()) {
throw new GradleException('TOML file not found')
}
def lines = tomlFile.readLines()
def nonSortedBlocks = []
def currentBlock = []
def prevLine = ''
def prevIndex = 0
lines.eachWithIndex { line, lineIndex ->
if (line.trim() && !line.startsWith('#')) {
if (line.startsWith('[')) {
prevLine = ''
} else {
def currIndex = lineIndex + 1
if (prevLine > line) {
if (currentBlock && currentBlock[-1] == "${prevIndex}: ${prevLine}") {
currentBlock.add("${currIndex}: ${line}")
} else {
if (!currentBlock.isEmpty()) {
nonSortedBlocks.add(currentBlock)
currentBlock = []
}
currentBlock.add("${prevIndex}: ${prevLine}")
currentBlock.add("${currIndex}: ${line}")
}
}
prevLine = line
prevIndex = lineIndex + 1
}
}
}
if (!currentBlock.isEmpty()) {
nonSortedBlocks.add(currentBlock)
throw new GradleException("The following lines were not sorted:\n" +
nonSortedBlocks.collect { it.join("\n") }.join("\n\n"))
}
}
}

View File

@@ -39,3 +39,18 @@
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
-keep class org.schabi.newpipe.settings.notifications.** { *; }
## Keep Kotlinx Serialization classes
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; }
-keepclassmembers class org.schabi.newpipe.** {
*** Companion;
}
-keepclasseswithmembers class org.schabi.newpipe.** {
kotlinx.serialization.KSerializer serializer(...);
}

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,62 +0,0 @@
package org.schabi.newpipe.error;
import android.os.Parcel;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import java.util.Arrays;
import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* Instrumented tests for {@link ErrorInfo}.
*/
@RunWith(AndroidJUnit4.class)
@LargeTest
public class ErrorInfoTest {
/**
* @param errorInfo the error info to access
* @return the private field errorInfo.message.stringRes using reflection
*/
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
throws NoSuchFieldException, IllegalAccessException {
final var message = ErrorInfo.class.getDeclaredField("message");
message.setAccessible(true);
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
stringRes.setAccessible(true);
return (int) Objects.requireNonNull(stringRes.get(messageValue));
}
@Test
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
// Obtain a Parcel object and write the parcelable object to it:
final Parcel parcel = Parcel.obtain();
info.writeToParcel(parcel, 0);
parcel.setDataPosition(0);
final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel);
assertTrue(Arrays.toString(infoFromParcel.getStackTraces())
.contains(ErrorInfoTest.class.getSimpleName()));
assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction());
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
parcel.recycle();
}
}

View File

@@ -0,0 +1,127 @@
package org.schabi.newpipe.error
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import java.io.IOException
import java.net.SocketTimeoutException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.exceptions.ParsingException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
/**
* Instrumented tests for {@link ErrorInfo}.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class ErrorInfoTest {
private val context: Context by lazy { ApplicationProvider.getApplicationContext() }
/**
* @param errorInfo the error info to access
* @return the private field errorInfo.message.stringRes using reflection
*/
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int {
val message = ErrorInfo::class.java.getDeclaredField("message")
message.isAccessible = true
val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage
val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes")
stringRes.isAccessible = true
return stringRes.get(messageValue) as Int
}
@Test
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
fun errorInfoTestParcelable() {
val info = ErrorInfo(
ParsingException("Hello"),
UserAction.USER_REPORT,
"request",
ServiceList.YouTube.serviceId
)
// Obtain a Parcel object and write the parcelable object to it:
val parcel = Parcel.obtain()
info.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR")
val creator = creatorField.get(null)
check(creator is Parcelable.Creator<*>)
val infoFromParcel = requireNotNull(
creator.createFromParcel(parcel) as? ErrorInfo
)
assertTrue(
infoFromParcel.stackTraces.contentToString()
.contains(ErrorInfoTest::class.java.simpleName)
)
assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction)
assertEquals(
ServiceList.YouTube.serviceInfo.name,
infoFromParcel.getServiceName()
)
assertEquals("request", infoFromParcel.request)
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel))
parcel.recycle()
}
/**
* Test: Network error on initial load (Resource.Error)
*/
@Test
fun testInitialCommentNetworkError() {
val errorInfo = ErrorInfo(
throwable = SocketTimeoutException("Connection timeout"),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
assertNull(errorInfo.recaptchaUrl)
}
/**
* Test: Network error on paging (LoadState.Error)
*/
@Test
fun testPagingNetworkError() {
val errorInfo = ErrorInfo(
throwable = IOException("Paging failed"),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
assertNull(errorInfo.recaptchaUrl)
}
/**
* Test: ReCaptcha during comments load
*/
@Test
fun testReCaptchaDuringComments() {
val url = "https://www.google.com/recaptcha/api/fallback?k=test"
val errorInfo = ErrorInfo(
throwable = ReCaptchaException("ReCaptcha needed", url),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context))
assertEquals(url, errorInfo.recaptchaUrl)
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
}
}

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

@@ -0,0 +1,126 @@
package org.schabi.newpipe.ui.components.common
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.net.UnknownHostException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
import org.schabi.newpipe.ui.theme.AppTheme
@RunWith(AndroidJUnit4::class)
class ErrorPanelTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) {
composeRule.setContent {
AppTheme {
ErrorPanel(errorInfo = errorInfo, onRetry = onRetry)
}
}
}
private fun text(@StringRes id: Int) = composeRule.activity.getString(id)
/**
* Test Network Error
*/
@Test
fun testNetworkErrorShowsRetryWithoutReportButton() {
val networkErrorInfo = ErrorInfo(
throwable = UnknownHostException("offline"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=foo"
)
setErrorPanel(networkErrorInfo, onRetry = {})
composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
.assertDoesNotExist()
}
/**
* Test Unexpected Error, Shows Report and Retry buttons
*/
@Test
fun unexpectedErrorShowsReportAndRetryButtons() {
val unexpectedErrorInfo = ErrorInfo(
throwable = RuntimeException("Unexpected error"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=bar"
)
setErrorPanel(unexpectedErrorInfo, onRetry = {})
composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertIsDisplayed()
}
/**
* Test Recaptcha Error shows all buttons: solve, retry, open in browser, report
*/
@Test
fun recaptchaErrorShowsAllButtons() {
var retryClicked = false
val recaptchaErrorInfo = ErrorInfo(
throwable = ReCaptchaException(
"Recaptcha required",
"https://example.com/captcha"
),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=baz",
openInBrowserUrl = "https://example.com/watch?v=baz"
)
setErrorPanel(
errorInfo = recaptchaErrorInfo,
onRetry = { retryClicked = true }
)
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
.assertIsDisplayed()
.performClick()
composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertIsDisplayed()
assert(retryClicked) { "onRetry callback should have been invoked" }
}
/**
* Test Content Not Available Error hides retry button
*/
@Test
fun testNonRetryableErrorHidesRetryAndReportButtons() {
val contentNotAvailable = ErrorInfo(
throwable = UnsupportedContentInCountryException("Not available here"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=qux"
)
setErrorPanel(contentNotAvailable)
composeRule.onNodeWithText(text(R.string.unsupported_content_in_country))
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertDoesNotExist()
}
}

View File

@@ -0,0 +1,364 @@
package org.schabi.newpipe.ui.components.video.comment
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.LoadStates
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import java.net.UnknownHostException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.ui.components.common.ErrorPanel
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.LoadingIndicator
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.viewmodels.util.Resource
class CommentSectionInstrumentedTest {
@get:Rule
val composeRule = createAndroidComposeRule<androidx.activity.ComponentActivity>()
private val uiStateFlow = MutableStateFlow<Resource<CommentInfo>>(Resource.Loading)
private val pagingFlow = MutableStateFlow(PagingData.empty<CommentsInfoItem>())
private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId)
@Before
fun setUp() {
composeRule.setContent {
AppTheme {
TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow)
}
}
}
private fun successState(commentCount: Int) = Resource.Success(
CommentInfo(
serviceId = 0,
url = "",
comments = emptyList(),
nextPage = null,
commentCount = commentCount,
isCommentsDisabled = false
)
)
@Test
fun commentListLoadsAndScrolls() {
val comments = (1..25).map { index ->
CommentsInfoItem(
commentText = Description("Comment $index", Description.PLAIN_TEXT),
uploaderName = "Uploader $index",
replies = Page(""),
replyCount = 0
)
}
uiStateFlow.value = successState(comments.size)
pagingFlow.value = PagingData.from(comments)
composeRule.waitForIdle()
composeRule.onNodeWithText("Comment 1").assertIsDisplayed()
composeRule.onNodeWithTag("comment_list")
.performScrollToNode(hasText("Comment 25"))
composeRule.onNodeWithText("Comment 25").assertIsDisplayed()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun pagingErrorShowsErrorPanelAndAllowsRetry() {
uiStateFlow.value = successState(10)
pagingFlow.value = PagingData.from(
data = emptyList(),
sourceLoadStates = LoadStates(
refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true)
.and(hasClickAction())
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val recoveredComment = CommentsInfoItem(
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(recoveredComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("Recovered comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
composeRule.onNode(solveMatcher).assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun resourceErrorShowsErrorPanelAndRetry() {
uiStateFlow.value = Resource.Error(UnknownHostException("offline"))
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed()
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val recoveredComment = CommentsInfoItem(
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(recoveredComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("Recovered comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.network_error))
.assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun retryAfterErrorRecoversList() {
uiStateFlow.value = Resource.Error(RuntimeException("boom"))
composeRule.waitForIdle()
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val firstComment = CommentsInfoItem(
commentText = Description("First comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(firstComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("First comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("First comment").assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.network_error))
.assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
}
@Composable
private fun TestCommentSection(
uiStateFlow: StateFlow<Resource<CommentInfo>>,
commentsFlow: Flow<PagingData<CommentsInfoItem>>
) {
val uiState by uiStateFlow.collectAsState()
val comments = commentsFlow.collectAsLazyPagingItems()
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val listState = rememberLazyListState()
val COMMENT_LIST_TAG = "comment_list"
LazyColumnThemedScrollbar(state = listState) {
LazyColumn(
modifier = Modifier
.testTag(COMMENT_LIST_TAG)
.nestedScroll(nestedScrollInterop),
state = listState
) {
when (uiState) {
is Resource.Loading -> item {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
}
is Resource.Success -> {
val commentInfo = (uiState as Resource.Success<CommentInfo>).data
val count = commentInfo.commentCount
when {
commentInfo.isCommentsDisabled -> item {
EmptyStateComposable(
spec = EmptyStateSpec.DisabledComments,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
count == 0 -> item {
EmptyStateComposable(
spec = EmptyStateSpec.NoComments,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
else -> {
if (count >= 0) {
item {
Text(
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
text = pluralStringResource(R.plurals.comments, count, count),
maxLines = 1,
style = MaterialTheme.typography.titleMedium
)
}
}
when (val refresh = comments.loadState.refresh) {
is LoadState.Loading -> item {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
}
is LoadState.Error -> item {
Box(
modifier = Modifier.fillMaxWidth()
) {
ErrorPanel(
errorInfo = ErrorInfo(
throwable = refresh.error,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
),
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> items(comments.itemCount) { index ->
Comment(comment = comments[index]!!) {}
}
}
}
}
}
is Resource.Error -> item {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ErrorPanel(
errorInfo = ErrorInfo(
throwable = (uiState as Resource.Error).throwable,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
),
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
}

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

@@ -91,28 +91,25 @@
android:exported="false"
android:label="@string/settings" />
<activity
android:name=".settings.SettingsV2Activity"
android:exported="true"
android:label="@string/settings" />
<activity
android:name=".about.AboutActivity"
android:exported="false"
android:label="@string/title_activity_about" />
<service
android:name=".local.subscription.services.SubscriptionsImportService"
android:foregroundServiceType="dataSync" />
<service
android:name=".local.subscription.services.SubscriptionsExportService"
android:foregroundServiceType="dataSync" />
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name=".local.feed.service.FeedLoadService"
android:foregroundServiceType="dataSync" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<activity
android:name=".PanicResponderActivity"
android:exported="true"
@@ -143,7 +140,8 @@
android:label="@string/app_name"
android:launchMode="singleTask" />
<service android:name="us.shandian.giga.service.DownloadManagerService"
<service
android:name="us.shandian.giga.service.DownloadManagerService"
android:foregroundServiceType="dataSync" />
<activity

View File

@@ -1,285 +0,0 @@
package org.schabi.newpipe;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationChannelCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.preference.PreferenceManager;
import com.jakewharton.processphoenix.ProcessPhoenix;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.exceptions.CompositeException;
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
private boolean notificationsRequested = false;
private static App app;
@NonNull
public static App getApp() {
return app;
}
public boolean getNotificationsRequested() {
return notificationsRequested;
}
public void setNotificationsRequested() {
notificationsRequested = true;
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
initACRA();
}
@Override
public void onCreate() {
super.onCreate();
app = this;
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, "This is a phoenix process! "
+ "Aborting initialization of App[onCreate]");
return;
}
// check if the last used preference version is set
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1);
isFirstRun = lastUsedPrefVersion == -1;
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime());
BridgeStateSaverInitializer.init(this);
StateSaver.init(this);
initNotificationChannels();
ServiceHelper.initServices(this);
// Initialize image loader
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
PicassoHelper.init(this);
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
prefs.getString(getString(R.string.image_quality_key),
getString(R.string.image_quality_default))));
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler();
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
}
@Override
public void onTerminate() {
super.onTerminate();
PicassoHelper.terminate();
}
protected Downloader getDownloader() {
final DownloaderImpl downloader = DownloaderImpl.init(null);
setCookiesToDownloader(downloader);
return downloader;
}
protected void setCookiesToDownloader(final DownloaderImpl downloader) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext());
final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null));
downloader.updateYoutubeRestrictedModeCookies(getApplicationContext());
}
private void configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(new Consumer<Throwable>() {
@Override
public void accept(@NonNull final Throwable throwable) {
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : "
+ "throwable = [" + throwable.getClass().getName() + "]");
final Throwable actualThrowable;
if (throwable instanceof UndeliverableException) {
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
actualThrowable = Objects.requireNonNull(throwable.getCause());
} else {
actualThrowable = throwable;
}
final List<Throwable> errors;
if (actualThrowable instanceof CompositeException) {
errors = ((CompositeException) actualThrowable).getExceptions();
} else {
errors = List.of(actualThrowable);
}
for (final Throwable error : errors) {
if (isThrowableIgnored(error)) {
return;
}
if (isThrowableCritical(error)) {
reportException(error);
return;
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable);
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable);
}
}
private boolean isThrowableIgnored(@NonNull final Throwable throwable) {
// Don't crash the application over a simple network problem
return ExceptionUtils.hasAssignableCause(throwable,
// network api cancellation
IOException.class, SocketException.class,
// blocking code disposed
InterruptedException.class, InterruptedIOException.class);
}
private boolean isThrowableCritical(@NonNull final Throwable throwable) {
// Though these exceptions cannot be ignored
return ExceptionUtils.hasAssignableCause(throwable,
NullPointerException.class, IllegalArgumentException.class, // bug in app
OnErrorNotImplementedException.class, MissingBackpressureException.class,
IllegalStateException.class); // bug in operator
}
private void reportException(@NonNull final Throwable throwable) {
// Throw uncaught exception that will trigger the report system
Thread.currentThread().getUncaughtExceptionHandler()
.uncaughtException(Thread.currentThread(), throwable);
}
});
}
/**
* Called in {@link #attachBaseContext(Context)} after calling the {@code super} method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected void initACRA() {
if (ACRA.isACRASenderServiceProcess()) {
return;
}
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig.class);
ACRA.init(this, acraConfig);
}
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(
getString(R.string.app_update_notification_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(
getString(R.string.streams_notification_channel_description))
.build()
);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
}
protected boolean isDisposedRxExceptionsReported() {
return false;
}
public boolean isFirstRun() {
return isFirstRun;
}
}

View File

@@ -0,0 +1,295 @@
package org.schabi.newpipe
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.jakewharton.processphoenix.ProcessPhoenix
import dagger.hilt.android.HiltAndroidApp
import io.reactivex.rxjava3.exceptions.CompositeException
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
import io.reactivex.rxjava3.exceptions.UndeliverableException
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketException
import org.acra.ACRA.init
import org.acra.ACRA.isACRASenderServiceProcess
import org.acra.config.CoreConfigurationBuilder
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor
import org.schabi.newpipe.ktx.hasAssignableCause
import org.schabi.newpipe.settings.NewPipeSettings
import org.schabi.newpipe.util.BridgeStateSaverInitializer
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.StateSaver
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.util.image.PreferredImageQuality
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.kt is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
@HiltAndroidApp
open class App :
Application(),
SingletonImageLoader.Factory {
var isFirstRun = false
private set
var notificationsRequested = false
private set
fun setNotificationsRequested() {
notificationsRequested = true
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
initACRA()
}
override fun onCreate() {
super.onCreate()
instance = this
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]")
return
}
// check if the last used preference version is set
// to determine whether this is the first app run
val lastUsedPrefVersion =
PreferenceManager
.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1)
isFirstRun = lastUsedPrefVersion == -1
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this)
NewPipe.init(
getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this)
)
Localization.initPrettyTime(Localization.resolvePrettyTime())
BridgeStateSaverInitializer.init(this)
StateSaver.init(this)
initNotificationChannels()
ServiceHelper.initServices(this)
// Initialize image loader
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
ImageStrategy.setPreferredImageQuality(
PreferredImageQuality.fromPreferenceKey(
this,
prefs.getString(
getString(R.string.image_quality_key),
getString(R.string.image_quality_default)
)
)
)
configureRxJavaErrorHandler()
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl)
}
override fun newImageLoader(context: Context): ImageLoader = ImageLoader
.Builder(this)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(getSystemService<ActivityManager>()!!.isLowRamDevice)
.crossfade(true)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = DownloaderImpl.getInstance().client))
}.build()
protected open fun getDownloader(): Downloader {
val downloader = DownloaderImpl.init(null)
setCookiesToDownloader(downloader)
return downloader
}
protected fun setCookiesToDownloader(downloader: DownloaderImpl) {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val key = getString(R.string.recaptcha_cookies_key)
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null))
downloader.updateYoutubeRestrictedModeCookies(this)
}
private fun configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(
object : Consumer<Throwable> {
override fun accept(throwable: Throwable) {
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]")
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable
val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable)
for (error in errors) {
if (isThrowableIgnored(error)) {
return
}
if (isThrowableCritical(error)) {
reportException(error)
return
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable)
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable)
}
}
fun isThrowableIgnored(throwable: Throwable): Boolean {
// Don't crash the application over a simple network problem
return throwable // network api cancellation
.hasAssignableCause(
IOException::class.java,
SocketException::class.java, // blocking code disposed
InterruptedException::class.java,
InterruptedIOException::class.java
)
}
fun isThrowableCritical(throwable: Throwable): Boolean {
// Though these exceptions cannot be ignored
return throwable
.hasAssignableCause(
// bug in app
NullPointerException::class.java,
IllegalArgumentException::class.java,
OnErrorNotImplementedException::class.java,
MissingBackpressureException::class.java,
// bug in operator
IllegalStateException::class.java
)
}
fun reportException(throwable: Throwable) {
// Throw uncaught exception that will trigger the report system
Thread
.currentThread()
.uncaughtExceptionHandler
.uncaughtException(Thread.currentThread(), throwable)
}
}
)
}
/**
* Called in [.attachBaseContext] after calling the `super` method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected fun initACRA() {
if (isACRASenderServiceProcess()) {
return
}
val acraConfig =
CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig::class.java)
init(this, acraConfig)
}
private fun initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
val mainChannel =
NotificationChannelCompat
.Builder(
getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW
).setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build()
val appUpdateChannel =
NotificationChannelCompat
.Builder(
getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW
).setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description))
.build()
val hashChannel =
NotificationChannelCompat
.Builder(
getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH
).setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build()
val errorReportChannel =
NotificationChannelCompat
.Builder(
getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW
).setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build()
val newStreamChannel =
NotificationChannelCompat
.Builder(
getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT
).setName(getString(R.string.streams_notification_channel_name))
.setDescription(getString(R.string.streams_notification_channel_description))
.build()
val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel)
NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels)
}
protected open fun isDisposedRxExceptionsReported(): Boolean = false
companion object {
const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID
private val TAG = App::class.java.toString()
@JvmStatic
lateinit var instance: App
private set
}
}

View File

@@ -0,0 +1,22 @@
package org.schabi.newpipe
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
@Singleton
fun providesSharedPreference(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
}

View File

@@ -48,6 +48,11 @@ public final class DownloaderImpl extends Downloader {
this.mCookies = new HashMap<>();
}
@NonNull
public OkHttpClient getClient() {
return client;
}
/**
* It's recommended to call exactly once in the entire lifetime of the application.
*
@@ -161,9 +166,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

@@ -43,7 +43,6 @@ import android.widget.FrameLayout;
import android.widget.Spinner;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
@@ -52,7 +51,6 @@ import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
@@ -66,13 +64,11 @@ import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player;
@@ -191,7 +187,7 @@ public class MainActivity extends AppCompatActivity {
NotificationWorker.initialize(this);
}
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
&& !App.getApp().isFirstRun()
&& !App.getInstance().isFirstRun()
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
@@ -203,7 +199,7 @@ public class MainActivity extends AppCompatActivity {
protected void onPostCreate(final Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
final App app = App.getApp();
final App app = App.getInstance();
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
&& sharedPreferences
@@ -603,39 +599,27 @@ public class MainActivity extends AppCompatActivity {
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
// interacts with a fragment inside fragment_holder so all back presses should be
// handled by it
if (bottomSheetHiddenOrCollapsed()) {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragment instanceof BackPressable) {
if (((BackPressable) fragment).onBackPressed()) {
return;
}
} else if (fragment instanceof CommentRepliesFragment) {
// expand DetailsFragment if CommentRepliesFragment was opened
// to show the top level comments again
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, false);
}
final var fragmentManager = getSupportFragmentManager();
} else {
final Fragment fragmentPlayer = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
if (bottomSheetHiddenOrCollapsed()) {
final var fragment = fragmentManager.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragmentPlayer instanceof BackPressable) {
if (!((BackPressable) fragmentPlayer).onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) {
return;
}
} else {
final var player = fragmentManager.findFragmentById(R.id.fragment_player_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
return;
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
if (fragmentManager.getBackStackEntryCount() == 1) {
finish();
} else {
super.onBackPressed();
@@ -694,15 +678,9 @@ public class MainActivity extends AppCompatActivity {
* </pre>
*/
private void onHomeButtonPressed() {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
final var fm = getSupportFragmentManager();
if (fragment instanceof CommentRepliesFragment) {
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, true);
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
if (!NavigationHelper.tryGotoSearchFragment(fm)) {
// If search fragment wasn't found in the backstack go to the main fragment
NavigationHelper.gotoMainFragment(fm);
}
@@ -876,7 +854,7 @@ public class MainActivity extends AppCompatActivity {
return;
}
if (PlayerHolder.getInstance().isPlayerOpen()) {
if (PlayerHolder.INSTANCE.isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing();
} else {
@@ -886,7 +864,7 @@ public class MainActivity extends AppCompatActivity {
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)
&& PlayerHolder.getInstance().isPlayerOpen()) {
&& PlayerHolder.INSTANCE.isPlayerOpen()) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
@@ -902,72 +880,10 @@ public class MainActivity extends AppCompatActivity {
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
PlayerHolder.getInstance().tryBindIfNeeded(this);
PlayerHolder.INSTANCE.tryBindIfNeeded(this);
}
}
private void openDetailFragmentFromCommentReplies(
@NonNull final FragmentManager fm,
final boolean popBackStack
) {
// obtain the name of the fragment under the replies fragment that's going to be popped
@Nullable final String fragmentUnderEntryName;
if (fm.getBackStackEntryCount() < 2) {
fragmentUnderEntryName = null;
} else {
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
.getName();
}
// the root comment is the comment for which the user opened the replies page
@Nullable final CommentRepliesFragment repliesFragment =
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
@Nullable final CommentsInfoItem rootComment =
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
// sometimes this function pops the backstack, other times it's handled by the system
if (popBackStack) {
fm.popBackStackImmediate();
}
// only expand the bottom sheet back if there are no more nested comment replies fragments
// stacked under the one that is currently being popped
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
return;
}
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
.from(mainBinding.fragmentPlayerHolder);
// do not return to the comment if the details fragment was closed
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
return;
}
// scroll to the root comment once the bottom sheet expansion animation is finished
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull final View bottomSheet,
final int newState) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
final Fragment detailFragment = fm.findFragmentById(
R.id.fragment_player_holder);
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
// should always be the case
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
}
behavior.removeBottomSheetCallback(this);
}
}
@Override
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
// not needed, listener is removed once the sheet is expanded
}
});
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}
private boolean bottomSheetHiddenOrCollapsed() {
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);

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

@@ -361,15 +361,9 @@ public class RouterActivity extends AppCompatActivity {
// Default / Ask always
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
switch (availableChoices.size()) {
case 1:
handleChoice(availableChoices.get(0).key);
break;
case 0:
handleChoice(getString(R.string.show_info_key));
break;
default:
showDialog(availableChoices);
break;
case 1 -> handleChoice(availableChoices.get(0).key);
case 0 -> handleChoice(getString(R.string.show_info_key));
default -> showDialog(availableChoices);
}
}
@@ -543,7 +537,7 @@ public class RouterActivity extends AppCompatActivity {
// Enqueue is only shown if the current queue is not empty.
// However, if the playqueue or the player is cleared after this item was chosen and
// while the item is extracted, it will automatically fall back to background player.
if (PlayerHolder.getInstance().getQueueSize() > 0) {
if (PlayerHolder.INSTANCE.getQueueSize() > 0) {
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
getString(R.string.enqueue_stream), R.drawable.ic_add));
}
@@ -686,7 +680,7 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
final PlayerType playerType = PlayerHolder.getInstance().getType();
final PlayerType playerType = PlayerHolder.INSTANCE.getType();
return playerType == null || playerType == PlayerType.MAIN;
}

View File

@@ -1,201 +1,29 @@
package org.schabi.newpipe.about
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.StringRes
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import org.schabi.newpipe.BuildConfig
import androidx.compose.ui.res.stringResource
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityAboutBinding
import org.schabi.newpipe.databinding.FragmentAboutBinding
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.screens.AboutScreen
import org.schabi.newpipe.ui.theme.AppTheme
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ThemeHelper.setTheme(this)
title = getString(R.string.title_activity_about)
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(aboutBinding.root)
setSupportActionBar(aboutBinding.aboutToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Create the adapter that will return a fragment for each of the three
// primary sections of the activity.
val mAboutStateAdapter = AboutStateAdapter(this)
// Set up the ViewPager with the sections adapter.
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
TabLayoutMediator(
aboutBinding.aboutTabLayout,
aboutBinding.aboutViewPager2
) { tab, position ->
tab.setText(mAboutStateAdapter.getPageTitle(position))
}.attach()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
/**
* A placeholder fragment containing a simple view.
*/
class AboutFragment : Fragment() {
private fun Button.openLink(@StringRes url: Int) {
setOnClickListener {
ShareUtils.openUrlInApp(context, requireContext().getString(url))
setContent {
AppTheme {
ScaffoldWithToolbar(
title = stringResource(R.string.title_activity_about),
onBackClick = { onBackPressedDispatcher.onBackPressed() }
) { padding ->
AboutScreen(padding)
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
FragmentAboutBinding.inflate(inflater, container, false).apply {
aboutAppVersion.text = BuildConfig.VERSION_NAME
aboutGithubLink.openLink(R.string.github_url)
aboutDonationLink.openLink(R.string.donation_url)
aboutWebsiteLink.openLink(R.string.website_url)
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
faqLink.openLink(R.string.faq_url)
return root
}
}
}
/**
* A [FragmentStateAdapter] that returns a fragment corresponding to
* one of the sections/tabs/pages.
*/
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
private val posAbout = 0
private val posLicense = 1
private val totalCount = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
posAbout -> AboutFragment()
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}
override fun getItemCount(): Int {
// Show 2 total pages.
return totalCount
}
fun getPageTitle(position: Int): Int {
return when (position) {
posAbout -> R.string.tab_about
posLicense -> R.string.tab_licenses
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}
}
companion object {
/**
* List of all software components.
*/
private val SOFTWARE_COMPONENTS = arrayListOf(
SoftwareComponent(
"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
),
SoftwareComponent(
"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
),
SoftwareComponent(
"Groupie", "2016", "Lisa Wray",
"https://github.com/lisawray/groupie", StandardLicenses.MIT
),
SoftwareComponent(
"Android-State", "2018", "Evernote",
"https://github.com/Evernote/android-state", StandardLicenses.EPL1
),
SoftwareComponent(
"Bridge", "2021", "Livefront",
"https://github.com/livefront/bridge", StandardLicenses.APACHE2
),
SoftwareComponent(
"Jsoup", "2009 - 2020", "Jonathan Hedley",
"https://github.com/jhy/jsoup", StandardLicenses.MIT
),
SoftwareComponent(
"Markwon", "2019", "Dimitry Ivanov",
"https://github.com/noties/Markwon", StandardLicenses.APACHE2
),
SoftwareComponent(
"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
),
SoftwareComponent(
"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
),
SoftwareComponent(
"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
),
SoftwareComponent(
"ProcessPhoenix", "2015", "Jake Wharton",
"https://github.com/JakeWharton/ProcessPhoenix", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxAndroid", "2015", "The RxAndroid authors",
"https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxBinding", "2015", "Jake Wharton",
"https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxJava", "2016 - 2020", "RxJava Contributors",
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
),
SoftwareComponent(
"SearchPreference", "2018", "ByteHamster",
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
),
)
}
}

View File

@@ -1,11 +0,0 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
/**
* Class for storing information about a software license.
*/
@Parcelize
class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable

View File

@@ -1,141 +0,0 @@
package org.schabi.newpipe.about
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
import org.schabi.newpipe.ktx.parcelableArrayList
import org.schabi.newpipe.util.external_communication.ShareUtils
/**
* Fragment containing the software licenses.
*/
class LicenseFragment : Fragment() {
private lateinit var softwareComponents: List<SoftwareComponent>
private var activeSoftwareComponent: SoftwareComponent? = null
private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
.sortedBy { it.name } // Sort components by name
activeSoftwareComponent = savedInstanceState?.let {
BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java)
}
}
override fun onDestroy() {
compositeDisposable.dispose()
super.onDestroy()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentLicensesBinding.inflate(inflater, container, false)
binding.licensesAppReadLicense.setOnClickListener {
compositeDisposable.add(
showLicense(NEWPIPE_SOFTWARE_COMPONENT)
)
}
for (component in softwareComponents) {
val componentBinding = ItemSoftwareComponentBinding
.inflate(inflater, container, false)
componentBinding.name.text = component.name
componentBinding.copyright.text = getString(
R.string.copyright,
component.years,
component.copyrightOwner,
component.license.abbreviation
)
val root: View = componentBinding.root
root.tag = component
root.setOnClickListener {
compositeDisposable.add(
showLicense(component)
)
}
binding.licensesSoftwareComponents.addView(root)
registerForContextMenu(root)
}
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
return binding.root
}
override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState)
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
}
private fun showLicense(
softwareComponent: SoftwareComponent
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
val context = requireContext()
activeSoftwareComponent = softwareComponent
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
formattedLicense.toByteArray(), Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
val builder = AlertDialog.Builder(requireContext())
.setTitle(softwareComponent.name)
.setView(webView)
.setOnCancelListener { activeSoftwareComponent = null }
.setOnDismissListener { activeSoftwareComponent = null }
.setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
builder.setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
}
}
builder.show()
}
}
}
companion object {
private const val ARG_COMPONENTS = "components"
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
"NewPipe",
"2014-2023",
"Team NewPipe",
"https://newpipe.net/",
StandardLicenses.GPL3,
BuildConfig.VERSION_NAME
)
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
val fragment = LicenseFragment()
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
return fragment
}
}
}

View File

@@ -1,52 +0,0 @@
package org.schabi.newpipe.about
import android.content.Context
import org.schabi.newpipe.R
import org.schabi.newpipe.util.ThemeHelper
import java.io.IOException
/**
* @param context the context to use
* @param license the license
* @return String which contains a HTML formatted license page
* styled according to the context's theme
*/
fun getFormattedLicense(context: Context, license: License): String {
try {
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
// split the HTML file and insert the stylesheet into the HEAD of the file
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
} catch (e: IOException) {
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
}
}
/**
* @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme
*/
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
)
val licenseTextColor = getHexRGBColor(
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
)
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
}
/**
* Cast R.color to a hexadecimal color value.
*
* @param context the context to use
* @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values
*/
fun getHexRGBColor(context: Context, color: Int): String {
return context.getString(color).substring(3)
}

View File

@@ -1,17 +0,0 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
@Parcelize
class SoftwareComponent
@JvmOverloads
constructor(
val name: String,
val years: String,
val copyrightOwner: String,
val link: String,
val license: License,
val version: String? = null
) : Parcelable, Serializable

View File

@@ -1,21 +0,0 @@
package org.schabi.newpipe.about
/**
* Class containing information about standard software licenses.
*/
object StandardLicenses {
@JvmField
val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html")
@JvmField
val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html")
@JvmField
val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html")
@JvmField
val MIT = License("MIT License", "MIT", "mit.html")
@JvmField
val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html")
}

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,13 @@ import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
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> {
@@ -27,7 +28,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
@@ -91,7 +92,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

@@ -12,6 +12,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@@ -29,12 +30,12 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
}
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
fun getState(streamId: Long): Maybe<StreamStateEntity>
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun deleteState(streamId: Long): Int
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun silentInsertInternal(streamState: StreamStateEntity)
@Transaction

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

@@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
@Transaction
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
open fun upsertAll(entities: List<SubscriptionEntity>) {
val insertUidList = silentInsertAllInternal(entities)
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
@@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
update(entity)
}
}
return entities
}
}

View File

@@ -9,7 +9,6 @@ import com.google.auto.service.AutoService;
import org.acra.config.CoreConfiguration;
import org.acra.sender.ReportSender;
import org.acra.sender.ReportSenderFactory;
import org.schabi.newpipe.App;
/*
* Created by Christian Schabesberger on 13.09.16.

View File

@@ -307,8 +307,7 @@ public class ErrorActivity extends AppCompatActivity {
}
private String getOsString() {
final String osBase = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
? Build.VERSION.BASE_OS : "Android";
final String osBase = Build.VERSION.BASE_OS;
return System.getProperty("os.name")
+ " " + (osBase.isEmpty() ? "Android" : osBase)
+ " " + Build.VERSION.RELEASE

View File

@@ -3,10 +3,10 @@ package org.schabi.newpipe.error
import android.content.Context
import android.os.Parcelable
import androidx.annotation.StringRes
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 +28,7 @@ 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
import org.schabi.newpipe.util.Localization
/**
* 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,37 +144,33 @@ 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 {
// use Localization.compatGetString() just in case context is not AppCompatActivity
return if (formatArgs.isEmpty()) {
// use ContextCompat.getString() just in case context is not AppCompatActivity
ContextCompat.getString(context, stringRes)
Localization.compatGetString(context, stringRes)
} else {
// ContextCompat.getString() with formatArgs does not exist, so we just
// replicate its source code but with formatArgs
ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
Localization.compatGetString(context, stringRes, *formatArgs)
}
}
}
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
?: 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 +189,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 +222,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 +270,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 +296,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 +316,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

@@ -6,9 +6,11 @@ import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
public class EmptyFragment extends BaseFragment {
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
@@ -26,8 +28,10 @@ public class EmptyFragment extends BaseFragment {
final Bundle savedInstanceState) {
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
view.findViewById(R.id.empty_state_view).setVisibility(
showMessage ? View.VISIBLE : View.GONE);
final ComposeView composeView = view.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(composeView);
composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE);
return view;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
import android.content.Context;
import android.content.SharedPreferences;
@@ -10,7 +11,6 @@ import android.graphics.Color;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -45,6 +45,7 @@ import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -53,13 +54,14 @@ import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import coil3.util.CoilUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@@ -73,7 +75,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
implements StateSaver.WriteRead {
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
@State
protected int serviceId = Constants.NO_SERVICE_ID;
@@ -199,6 +200,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
tabAdapter = new TabAdapter(getChildFragmentManager());
binding.viewPager.setAdapter(tabAdapter);
binding.tabLayout.setupWithViewPager(binding.viewPager);
@@ -583,7 +586,9 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
@Override
public void showLoading() {
super.showLoading();
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
CoilUtils.dispose(binding.channelAvatarView);
CoilUtils.dispose(binding.channelBannerImage);
CoilUtils.dispose(binding.subChannelAvatarView);
animate(binding.channelSubscribeButton, false, 100);
}
@@ -594,17 +599,15 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) {
PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG)
.into(binding.channelBannerImage);
CoilHelper.INSTANCE.loadBanner(binding.channelBannerImage, result.getBanners());
} else {
// do not waste space for the banner, if the user disabled images or there is not one
binding.channelBannerImage.setImageDrawable(null);
}
PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG)
.into(binding.channelAvatarView);
PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG)
.into(binding.subChannelAvatarView);
CoilHelper.INSTANCE.loadAvatar(binding.channelAvatarView, result.getAvatars());
CoilHelper.INSTANCE.loadAvatar(binding.subChannelAvatarView,
result.getParentChannelAvatars());
binding.channelTitleView.setText(result.getName());
binding.channelSubscriberView.setVisibility(View.VISIBLE);
@@ -652,8 +655,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return;
}
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
binding.channelKaomoji.setText("(︶︹︺)");
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
binding.emptyStateView.setVisibility(View.VISIBLE);
}
}

View File

@@ -26,6 +26,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
@@ -79,6 +80,12 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
EmptyStateUtil.setEmptyStateComposable(rootView.findViewById(R.id.empty_state_view));
}
@Override
public void onDestroyView() {
super.onDestroyView();

View File

@@ -1,172 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.text.HtmlCompat;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextLinkifier;
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
import java.util.Queue;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public final class CommentRepliesFragment
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
@State
CommentsInfoItem commentsInfoItem; // the comment to show replies of
private final CompositeDisposable disposables = new CompositeDisposable();
/*//////////////////////////////////////////////////////////////////////////
// Constructors and lifecycle
//////////////////////////////////////////////////////////////////////////*/
// only called by the Android framework, after which readFrom is called and restores all data
public CommentRepliesFragment() {
super(UserAction.REQUESTED_COMMENT_REPLIES);
}
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
this();
this.commentsInfoItem = commentsInfoItem;
// setting "" as title since the title will be properly set right after
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
}
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comments, container, false);
}
@Override
public void onDestroyView() {
disposables.clear();
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
return () -> {
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final CommentsInfoItem item = commentsInfoItem;
// load the author avatar
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar);
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
? View.VISIBLE : View.GONE);
// setup author name and comment date
binding.authorName.setText(item.getUploaderName());
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
binding.authorTouchArea.setOnClickListener(
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
// setup like count, hearted and pinned
binding.thumbsUpCount.setText(
Localization.likeCount(requireContext(), item.getLikeCount()));
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
// not to use a different margin only when both the next two views are gone
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
.setMarginEnd(DeviceUtils.dpToPx(
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
requireContext()));
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
// setup comment content
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
item.getUrl(), disposables, null);
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
return binding.getRoot();
};
}
/*//////////////////////////////////////////////////////////////////////////
// State saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public void writeTo(final Queue<Object> objectsToSave) {
super.writeTo(objectsToSave);
objectsToSave.add(commentsInfoItem);
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects);
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
}
/*//////////////////////////////////////////////////////////////////////////
// Data loading
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<CommentRepliesInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem,
// the reply count string will be shown as the activity title
Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount())));
}
@Override
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
// commentsInfoItem.getUrl() should contain the url of the original
// ListInfo<CommentsInfoItem>, which should be the stream url
return ExtractorHelper.getMoreCommentItems(
serviceId, commentsInfoItem.getUrl(), currentNextPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
/**
* @return the comment to which the replies are shown
*/
public CommentsInfoItem getCommentsInfoItem() {
return commentsInfoItem;
}
}

View File

@@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import java.util.Collections;
public final class CommentRepliesInfo extends ListInfo<CommentsInfoItem> {
/**
* This class is used to wrap the comment replies page into a ListInfo object.
*
* @param comment the comment from which to get replies
* @param name will be shown as the fragment title
*/
public CommentRepliesInfo(final CommentsInfoItem comment, final String name) {
super(comment.getServiceId(),
new ListLinkHandler("", "", "", Collections.emptyList(), null), name);
setNextPage(comment.getReplies());
setRelatedItems(Collections.emptyList()); // since it must be non-null
}
}

View File

@@ -1,123 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
private final CompositeDisposable disposables = new CompositeDisposable();
private TextView emptyStateDesc;
public static CommentsFragment getInstance(final int serviceId, final String url,
final String name) {
final CommentsFragment instance = new CommentsFragment();
instance.setInitialData(serviceId, url, name);
return instance;
}
public CommentsFragment() {
super(UserAction.REQUESTED_COMMENTS);
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
emptyStateDesc = rootView.findViewById(R.id.empty_state_desc);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comments, container, false);
}
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
/*//////////////////////////////////////////////////////////////////////////
// Load and handle
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
}
@Override
protected Single<CommentsInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
public void handleResult(@NonNull final CommentsInfo result) {
super.handleResult(result);
emptyStateDesc.setText(
result.isCommentsDisabled()
? R.string.comments_are_disabled
: R.string.no_comments);
ViewUtils.slideUp(requireView(), 120, 150, 0.06f);
disposables.clear();
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) { }
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) { }
@Override
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
public boolean scrollToComment(final CommentsInfoItem comment) {
final int position = infoListAdapter.getItemsList().indexOf(comment);
if (position < 0) {
return false;
}
itemsList.scrollToPosition(position);
return true;
}
}

View File

@@ -0,0 +1,34 @@
package org.schabi.newpipe.fragments.list.comments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.ui.components.video.comment.CommentSection
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_SERVICE_ID
import org.schabi.newpipe.util.KEY_URL
class CommentsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
CommentSection()
}
}
}
companion object {
@JvmStatic
fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply {
arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url)
}
}
}

View File

@@ -53,7 +53,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
import java.util.ArrayList;
@@ -62,6 +62,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import coil3.util.CoilUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
@@ -71,8 +72,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo>
implements PlaylistControlViewHolder {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
private CompositeDisposable disposables;
private Subscription bookmarkReactor;
private AtomicBoolean isBookmarkButtonReady;
@@ -276,7 +275,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
animate(headerBinding.getRoot(), false, 200);
animateHideRecyclerViewAllowingScrolling(itemsList);
PicassoHelper.cancelTag(PICASSO_PLAYLIST_TAG);
CoilUtils.dispose(headerBinding.uploaderAvatarView);
animate(headerBinding.uploaderLayout, false, 200);
}
@@ -327,8 +326,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
R.drawable.ic_radio)
);
} else {
PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG)
.into(headerBinding.uploaderAvatarView);
CoilHelper.INSTANCE.loadAvatar(headerBinding.uploaderAvatarView,
result.getUploaderAvatars());
}
streamCount = result.getStreamCount();

View File

@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.search;
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static java.util.Arrays.asList;
@@ -65,6 +66,7 @@ import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -355,6 +357,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
setEmptyStateComposable(searchBinding.emptyStateView, EmptyStateSpec.NoSearchResult);
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
// animations are just strange and useless, since the suggestions keep changing too much
searchBinding.suggestionsList.setItemAnimator(null);

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,202 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.ktx.ViewUtils;
import java.io.Serializable;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key";
private RelatedItemsInfo relatedItemsInfo;
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
private RelatedItemsHeaderBinding headerBinding;
public static RelatedItemsFragment getInstance(final StreamInfo info) {
final RelatedItemsFragment instance = new RelatedItemsFragment();
instance.setInitialData(info);
return instance;
}
public RelatedItemsFragment() {
super(UserAction.REQUESTED_STREAM);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_related_items, container, false);
}
@Override
public void onDestroyView() {
headerBinding = null;
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
return null;
}
headerBinding = RelatedItemsHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final SharedPreferences pref = PreferenceManager
.getDefaultSharedPreferences(requireContext());
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
headerBinding.autoplaySwitch.setChecked(autoplay);
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
.putBoolean(getString(R.string.auto_queue_key), b).apply());
return headerBinding::getRoot;
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> relatedItemsInfo);
}
@Override
public void showLoading() {
super.showLoading();
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.INVISIBLE);
}
}
@Override
public void handleResult(@NonNull final RelatedItemsInfo result) {
super.handleResult(result);
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.VISIBLE);
}
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
// Nothing to do - override parent
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
// Nothing to do - override parent
}
private void setInitialData(final StreamInfo info) {
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
if (this.relatedItemsInfo == null) {
this.relatedItemsInfo = new RelatedItemsInfo(info);
}
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(INFO_KEY, relatedItemsInfo);
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
super.onRestoreInstanceState(savedState);
final Serializable serializable = savedState.getSerializable(INFO_KEY);
if (serializable instanceof RelatedItemsInfo) {
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
}
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
}
}
@Override
protected ItemViewMode getItemViewMode() {
ItemViewMode mode = super.getItemViewMode();
// Only list mode is supported. Either List or card will be used.
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
mode = ItemViewMode.LIST;
}
return mode;
}
@Override
protected void showInfoItemDialog(final StreamInfoItem item) {
// Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment
// so that its context is not lost when the RelatedItemsFragment is reinitialized,
// e.g. when a new stream is loaded in a parent VideoDetailFragment.
final Fragment parentFragment = getParentFragment();
if (parentFragment != null) {
try {
new InfoItemDialog.Builder(
parentFragment.getActivity(),
parentFragment.getContext(),
parentFragment,
item
).create().show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
}
} else {
super.showInfoItemDialog(item);
}
}
}

View File

@@ -0,0 +1,35 @@
package org.schabi.newpipe.fragments.list.videos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.ktx.serializable
import org.schabi.newpipe.ui.components.video.RelatedItems
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_INFO
class RelatedItemsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}
}
companion object {
@JvmStatic
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
arguments = bundleOf(KEY_INFO to info)
}
}
}

View File

@@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import java.util.ArrayList;
import java.util.Collections;
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
/**
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
*
* @param info the stream info from which to get related items
*/
public RelatedItemsInfo(final StreamInfo info) {
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
info.getId(), Collections.emptyList(), null), info.getName());
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
}
}

View File

@@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
@@ -75,21 +74,16 @@ public class InfoItemBuilder {
private InfoItemHolder holderFromInfoType(@NonNull final ViewGroup parent,
@NonNull final InfoItem.InfoType infoType,
final boolean useMiniVariant) {
switch (infoType) {
case STREAM:
return useMiniVariant ? new StreamMiniInfoItemHolder(this, parent)
: new StreamInfoItemHolder(this, parent);
case CHANNEL:
return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent)
: new ChannelInfoItemHolder(this, parent);
case PLAYLIST:
return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
: new PlaylistInfoItemHolder(this, parent);
case COMMENT:
return new CommentInfoItemHolder(this, parent);
default:
throw new RuntimeException("InfoType not expected = " + infoType.name());
}
return switch (infoType) {
case STREAM -> useMiniVariant ? new StreamMiniInfoItemHolder(this, parent)
: new StreamInfoItemHolder(this, parent);
case CHANNEL -> useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent)
: new ChannelInfoItemHolder(this, parent);
case PLAYLIST -> useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
: new PlaylistInfoItemHolder(this, parent);
case COMMENT ->
throw new IllegalArgumentException("Comments should be rendered using Compose");
};
}
public Context getContext() {

View File

@@ -21,7 +21,6 @@ import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
@@ -283,46 +282,32 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
return switch (type) {
// #4475 and #3368
// Always create a new instance otherwise the same instance
// is sometimes reused which causes a crash
case HEADER_TYPE:
return new HFHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE:
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE:
return new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE:
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE:
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE:
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE:
return new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE:
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE:
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE:
return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE:
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE:
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE:
return new CommentInfoItemHolder(infoItemBuilder, parent);
default:
return new FallbackViewHolder(new View(parent.getContext()));
}
case HEADER_TYPE -> new HFHolder(headerSupplier.get());
case FOOTER_TYPE -> new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE ->
new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE ->
new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE ->
new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
default -> new FallbackViewHolder(new View(parent.getContext()));
};
}
@Override

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

@@ -1,19 +1,18 @@
package org.schabi.newpipe.info_list
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.xwray.groupie.GroupieViewHolder
import com.xwray.groupie.Item
import com.xwray.groupie.viewbinding.BindableItem
import com.xwray.groupie.viewbinding.GroupieViewHolder
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ItemStreamSegmentBinding
import org.schabi.newpipe.extractor.stream.StreamSegment
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.PicassoHelper
import org.schabi.newpipe.util.image.CoilHelper
class StreamSegmentItem(
private val item: StreamSegment,
private val onClick: StreamSegmentAdapter.StreamSegmentListener
) : Item<GroupieViewHolder>() {
) : BindableItem<ItemStreamSegmentBinding>() {
companion object {
const val PAYLOAD_SELECT = 1
@@ -21,31 +20,35 @@ class StreamSegmentItem(
var isSelected = false
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
item.previewUrl?.let {
PicassoHelper.loadThumbnail(it)
.into(viewHolder.root.findViewById<ImageView>(R.id.previewImage))
}
viewHolder.root.findViewById<TextView>(R.id.textViewTitle).text = item.title
override fun bind(viewBinding: ItemStreamSegmentBinding, position: Int) {
CoilHelper.loadThumbnail(viewBinding.previewImage, item.previewUrl)
viewBinding.textViewTitle.text = item.title
if (item.channelName == null) {
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).visibility = View.GONE
viewBinding.textViewChannel.visibility = View.GONE
// When the channel name is displayed there is less space
// and thus the segment title needs to be only one line height.
// But when there is no channel name displayed, the title can be two lines long.
// The default maxLines value is set to 1 to display all elements in the AS preview,
viewHolder.root.findViewById<TextView>(R.id.textViewTitle).maxLines = 2
viewBinding.textViewTitle.maxLines = 2
} else {
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).text = item.channelName
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).visibility = View.VISIBLE
viewBinding.textViewChannel.text = item.channelName
viewBinding.textViewChannel.visibility = View.VISIBLE
}
viewHolder.root.findViewById<TextView>(R.id.textViewStartSeconds).text =
viewBinding.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.isSelected = isSelected
viewBinding.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) }
viewBinding.root.setOnLongClickListener {
onClick.onItemLongClick(this, item.startTimeSeconds)
true
}
viewBinding.root.isSelected = isSelected
}
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
override fun bind(
viewHolder: GroupieViewHolder<ItemStreamSegmentBinding>,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.contains(PAYLOAD_SELECT)) {
viewHolder.root.isSelected = isSelected
return
@@ -54,4 +57,6 @@ class StreamSegmentItem(
}
override fun getLayout() = R.layout.item_stream_segment
override fun initializeViewBinding(view: View) = ItemStreamSegmentBinding.bind(view)
}

View File

@@ -252,7 +252,7 @@ public final class InfoItemDialog {
* @return the current {@link Builder} instance
*/
public Builder addEnqueueEntriesIfNeeded() {
final PlayerHolder holder = PlayerHolder.getInstance();
final PlayerHolder holder = PlayerHolder.INSTANCE;
if (holder.isPlayQueueReady()) {
addEntry(StreamDialogDefaultEntry.ENQUEUE);
@@ -346,7 +346,7 @@ public final class InfoItemDialog {
public static void reportErrorDuringInitialization(final Throwable throwable,
final InfoItem item) {
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo(
throwable,
UserAction.OPEN_INFO_ITEM_DIALOG,
"none",

View File

@@ -44,10 +44,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
* </p>
*/
public enum StreamDialogDefaultEntry {
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
),
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
final var activity = fragment.requireActivity();
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
}),
/**
* Enqueues the stream automatically to the current PlayerType.

View File

@@ -13,8 +13,8 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.CoilHelper;
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
private final ImageView itemThumbnailView;
@@ -56,7 +56,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemAdditionalDetailView.setText(getDetailLine(item));
}
PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView);
CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getThumbnails());
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnChannelSelectedListener() != null) {

View File

@@ -1,212 +0,0 @@
package org.schabi.newpipe.info_list.holder;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
public class CommentInfoItemHolder extends InfoItemHolder {
private static final int COMMENT_DEFAULT_LINES = 2;
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final ImageView itemThumbsUpView;
private final TextView itemLikesCountView;
private final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
private final Button repliesButton;
@NonNull
private final TextEllipsizer textEllipsizer;
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_comment_item, parent);
itemRoot = itemView.findViewById(R.id.itemRoot);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
repliesButton = itemView.findViewById(R.id.replies_button);
commentHorizontalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
textEllipsizer.setStateChangeListener(isEllipsized -> {
if (Boolean.TRUE.equals(isEllipsized)) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
if (!(infoItem instanceof CommentsInfoItem)) {
return;
}
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
// load the author avatar
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView);
if (ImageStrategy.shouldLoadImages()) {
itemThumbnailView.setVisibility(View.VISIBLE);
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
commentVerticalPadding, commentVerticalPadding);
} else {
itemThumbnailView.setVisibility(View.GONE);
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
commentHorizontalPadding, commentVerticalPadding);
}
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
// setup the top row, with pinned icon, author name and comment date
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
itemTitleView.setText(Localization.concatenateStrings(
uploaderName,
Localization.relativeTimeOrTextual(
itemBuilder.getContext(),
item.getUploadDate(),
item.getTextualUploadDate())));
// setup bottom row, with likes, heart and replies button
itemLikesCountView.setText(
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
final boolean hasReplies = item.getReplies() != null;
repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null);
repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE);
repliesButton.setText(hasReplies
? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : "");
((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin =
hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext());
// setup comment content and click listeners to expand/ellipsize it
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
textEllipsizer.setStreamUrl(item.getUrl());
textEllipsizer.setContent(item.getCommentText());
textEllipsizer.ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener((v, event) -> {
final CharSequence text = itemContentView.getText();
if (text instanceof Spanned buffer) {
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(itemContentView, event);
final var links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(itemContentView);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
});
itemView.setOnClickListener(view -> {
textEllipsizer.toggle();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
});
itemView.setOnLongClickListener(view -> {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
final CharSequence text = itemContentView.getText();
if (text != null) {
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
}
}
return true;
});
}
private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
item);
}
private void openCommentReplies(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
item);
}
private void allowLinkFocus() {
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
}
private void denyLinkFocus() {
itemContentView.setMovementMethod(null);
}
private boolean shouldFocusLinks() {
if (itemView.isInTouchMode()) {
return false;
}
final URLSpan[] urls = itemContentView.getUrls();
return urls != null && urls.length != 0;
}
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
denyLinkFocus();
}
}
}

View File

@@ -9,8 +9,8 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.CoilHelper;
public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
public final ImageView itemThumbnailView;
@@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
itemUploaderView.setText(item.getUploaderName());
PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView);
CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnails());
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnPlaylistSelectedListener() != null) {

View File

@@ -16,8 +16,8 @@ import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import java.util.concurrent.TimeUnit;
@@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
StreamStateEntity state2 = null;
if (DependentPreferenceHelper
.getPositionsInListsEnabled(itemProgressView.getContext())) {
state2 = historyRecordManager.loadStreamState(infoItem)
.blockingGet()[0];
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
}
if (state2 != null) {
itemProgressView.setVisibility(View.VISIBLE);
@@ -87,7 +86,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
}
// Default thumbnail is shown on error, while loading and if the url is empty
PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView);
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getThumbnails());
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnStreamSelectedListener() != null) {
@@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
state = historyRecordManager
.loadStreamState(infoItem)
.blockingGet()[0];
.blockingGet();
}
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {

View File

@@ -0,0 +1,13 @@
package org.schabi.newpipe.ktx
import android.graphics.Bitmap
import android.graphics.Rect
import androidx.core.graphics.BitmapCompat
@Suppress("NOTHING_TO_INLINE")
inline fun Bitmap.scale(
width: Int,
height: Int,
srcRect: Rect? = null,
scaleInLinearSpace: Boolean = true
) = BitmapCompat.createScaledBitmap(this, width, height, srcRect, scaleInLinearSpace)

View File

@@ -1,11 +1,11 @@
package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import java.io.Serializable
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return BundleCompat.getSerializable(this, key, T::class.java)
}
fun Bundle?.toDebugString(): String {

View File

@@ -0,0 +1,13 @@
package org.schabi.newpipe.ktx
import android.content.Context
import android.content.ContextWrapper
import androidx.fragment.app.FragmentActivity
tailrec fun Context.findFragmentActivity(): FragmentActivity {
return when (this) {
is FragmentActivity -> this
is ContextWrapper -> baseContext.findFragmentActivity()
else -> throw IllegalStateException("Unable to find FragmentActivity")
}
}

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

@@ -16,6 +16,7 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@@ -39,6 +40,8 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.debounce.DebounceSavable;
@@ -124,6 +127,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
super.initViews(rootView, savedInstanceState);
itemListAdapter.setUseItemHandle(true);
final ComposeView emptyView = rootView.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
}
@Override

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
@@ -74,6 +76,7 @@ import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
@@ -81,8 +84,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 +92,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 = ""
@@ -132,6 +136,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
// super.onViewCreated() calls initListeners() which require the binding to be initialized
_feedBinding = FragmentFeedBinding.bind(rootView)
feedBinding.emptyStateView.setEmptyStateComposable()
super.onViewCreated(rootView, savedInstanceState)
val factory = FeedViewModel.getFactory(requireContext(), groupId)
@@ -149,7 +154,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (newState == RecyclerView.SCROLL_STATE_IDLE &&
!recyclerView.canScrollVertically(-1)
) {
if (tryGetNewItemsLoadedButton()?.isVisible == true) {
hideNewItemsLoaded(true)
}
@@ -202,6 +206,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// Menu
// /////////////////////////////////////////////////////////////////////////
@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
@@ -212,6 +217,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
inflater.inflate(R.menu.menu_feed_fragment, menu)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@@ -253,7 +259,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
viewModel.getShowFutureItemsFromPreferences()
)
AlertDialog.Builder(context!!)
AlertDialog.Builder(requireContext())
.setTitle(R.string.feed_hide_streams_title)
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
checkedDialogItems[which] = isChecked
@@ -267,6 +273,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
.show()
}
@Deprecated("Deprecated in Java")
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
if (
@@ -387,8 +394,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 +512,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 +548,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,22 +157,19 @@ 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 {
FeedViewModel(
App.getApp(),
App.instance,
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext),

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
@@ -19,9 +21,7 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
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
import org.schabi.newpipe.util.image.CoilHelper
data class StreamItem(
val streamWithState: StreamWithState,
@@ -101,7 +101,7 @@ data class StreamItem(
viewBinding.itemProgressView.visibility = View.GONE
}
PicassoHelper.loadThumbnail(stream.thumbnailUrl).into(viewBinding.itemThumbnailView)
CoilHelper.loadThumbnail(viewBinding.itemThumbnailView, stream.thumbnailUrl)
if (itemVersion != ItemVersion.MINI) {
viewBinding.itemAdditionalDetails.text =
@@ -132,6 +132,7 @@ data class StreamItem(
viewsAndDate.isEmpty() -> uploadDate!!
else -> Localization.concatenateStrings(viewsAndDate, uploadDate)
}
else -> viewsAndDate
}
}

View File

@@ -6,7 +6,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.provider.Settings
@@ -15,21 +14,19 @@ 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
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.image.PicassoHelper
import org.schabi.newpipe.util.image.CoilHelper
/**
* Helper for everything related to show notifications about new streams to the user.
*/
class NotificationHelper(val context: Context) {
private val manager = NotificationManagerCompat.from(context)
private val iconLoadingTargets = ArrayList<Target>()
/**
* Show notifications for new streams from a single channel. The individual notifications are
@@ -41,7 +38,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,
@@ -68,61 +67,31 @@ class NotificationHelper(val context: Context) {
summaryBuilder.setStyle(style)
// open the channel page when clicking on the summary notification
val intent = NavigationHelper
.getChannelIntent(context, data.serviceId, data.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
summaryBuilder.setContentIntent(
PendingIntentCompat.getActivity(
context,
data.pseudoId,
NavigationHelper
.getChannelIntent(context, data.serviceId, data.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
0,
false
)
PendingIntentCompat.getActivity(context, data.pseudoId, intent, 0, false)
)
// a Target is like a listener for image loading events
val target = object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
// set channel icon only if there is actually one (for Android versions < 7.0)
summaryBuilder.setLargeIcon(bitmap)
val avatarIcon =
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
// Show individual stream notifications, set channel icon only if there is actually
// one
showStreamNotifications(newStreams, data.serviceId, data.url, bitmap)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
summaryBuilder.setLargeIcon(avatarIcon)
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
// Show individual stream notifications
showStreamNotifications(newStreams, data.serviceId, data.url, null)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) {
// Nothing to do
}
}
// add the target to the list to hold a strong reference and prevent it from being garbage
// collected, since Picasso only holds weak references to targets
iconLoadingTargets.add(target)
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
// Show individual stream notifications, set channel icon only if there is actually one
showStreamNotifications(newStreams, data.serviceId, avatarIcon)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
}
private fun showStreamNotifications(
newStreams: List<StreamInfoItem>,
serviceId: Int,
channelUrl: String,
channelIcon: Bitmap?
) {
for (stream in newStreams) {
val notification = createStreamNotification(stream, serviceId, channelUrl, channelIcon)
val notification = createStreamNotification(stream, serviceId, channelIcon)
manager.notify(stream.url.hashCode(), notification)
}
}
@@ -130,7 +99,6 @@ class NotificationHelper(val context: Context) {
private fun createStreamNotification(
item: StreamInfoItem,
serviceId: Int,
channelUrl: String,
channelIcon: Bitmap?
): Notification {
return NotificationCompat.Builder(
@@ -141,7 +109,7 @@ class NotificationHelper(val context: Context) {
.setLargeIcon(channelIcon)
.setContentTitle(item.name)
.setContentText(item.uploaderName)
.setGroup(channelUrl)
.setGroup(item.uploaderUrl)
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)
@@ -181,8 +149,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 +179,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()

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