1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-03 09:49:21 +02:00

Compare commits

...

307 commits
v0.27.5 ... dev

Author SHA1 Message Date
Stypox
965eea2124
Merge pull request #12676 from thonsi/dev 2025-10-02 16:22:14 +02:00
Thonsi
59dfdda95e remove isUsingDSP 2025-10-01 15:56:08 +00:00
Stypox
3a2d427a46
Merge pull request #12642 from Stypox/fireos-SAF 2025-10-01 16:28:12 +02:00
Tobi
c25f83da6c
Merge pull request #12671 from TransZAllen/SRT_numbering
Fix initial numbering of frames in TTML to SRT converter
2025-10-01 02:42:33 -07:00
Stypox
e2026dc378
Merge pull request #12606 from Stypox/do-not-startService-randomly 2025-09-30 17:47:08 +02:00
Stypox
00f6203904
Merge pull request #12605 from TeamNewPipe/open-in-browser 2025-09-30 17:45:29 +02:00
TransZAllen
980e8f3951 [YouTube] *.srt numbering start at 1 instead of 0. (issue: https://github.com/TeamNewPipe/NewPipe/issues/12670)
- The SubRip (.srt) specification requires subtitle numbering to begin from 1.
- Please refer to https://en.wikipedia.org/wiki/SubRip
- Previously numbering started from 0, which is accepted by most
  players (tested on mpv, VLC, MPlayer, Totem) but not strictly compliant.
2025-09-29 18:04:35 +08:00
Stypox
4e9a480fdd
Enforce using SAF on FireOS TVs with Android 10+
Even if SAF is bugged there, there is no other way to open a file dialog, since NewPipe does not have permissions, see #10643
2025-09-17 12:24:18 +02:00
Stypox
aa2b4821e2
Post dummy notification then close player service on invalid intent
This should solve "Context.startForegroundService() did not then call Service.startForeground()" according to https://github.com/TeamNewPipe/NewPipe/issues/12489#issuecomment-3290318112
2025-09-17 11:50:46 +02:00
Stypox
92a07a3445
Use tryBindIfNeeded(), send player started only if player!=null
This commit fixes one way ghost notifications could be produced (although I don't know if there are other ways). This is the call chain that would lead to ghost notifications being created:
1. the system starts `PlayerService` to query information from it, without providing `SHOULD_START_FOREGROUND_EXTRA=true`, so NewPipe does not start the player nor show any notification, as expected
2. the `PlayerHolder::serviceConnection.onServiceConnected()` gets called by the system to inform `PlayerHolder` that the player started
3. `PlayerHolder`  notifies `MainActivity` that the player has started (although in fact only the service has started), by sending a `ACTION_PLAYER_STARTED` broadcast
4. `MainActivity` receives the `ACTION_PLAYER_STARTED` broadcast and brings up the mini-player, but then also tries to make `PlayerHolder` bind to `PlayerService` just in case it was not bound yet, but does so using `PlayerHolder::startService()` instead of the more passive `PlayerHolder::tryBindIfNeeded()`
5. `PlayerHolder::startService()` sends an intent to the `PlayerService` again, this time with `startForegroundService` and with `SHOULD_START_FOREGROUND_EXTRA=true`
6. the `PlayerService` receives the intent and due to `SHOULD_START_FOREGROUND_EXTRA=true` decides to start up the player and show a dummy notification

Steps 3 and 4 are wrong, and this commit fixes them:
3. `PlayerHolder` will now broadcast `ACTION_PLAYER_STARTED` when the service connects, only if the player is not-null
4. `PlayerHolder::tryBindIfNeeded()` is now used to passively try to bind, instead of `PlayerHolder::startService()`
2025-09-17 11:49:16 +02:00
Tobi
eed09f8a1d
Merge pull request #12550 from whistlingwoods/fix-downloads-lost-progress
Try to recover pending download missions when possible
2025-09-16 23:26:56 -07:00
Stypox
fd3f030d0b
Merge pull request #12616 from Isira-Seneviratne/Bump-AGP 2025-09-10 09:18:32 +02:00
Profpatsch
45c22c0db8
Merge pull request #12615 from Isira-Seneviratne/Player-intent-refactor
Refactor player intent logic
2025-09-09 10:24:42 +02:00
Isira Seneviratne
2b7c72eb69 Update AGP to 8.13.0 2025-09-08 08:08:07 +05:30
Isira Seneviratne
89c4eb5237 Refactor player intent logic 2025-09-08 07:56:13 +05:30
Stypox
803aba4935
Merge pull request #12254 from TeamNewPipe/timestamp-keep-current-player 2025-09-06 17:51:36 +02:00
Profpatsch
1723bf0e8a Player/handleIntent: keep current player when clicking timestamp
This was always a bit weird, that clicking a timestamp would
unconditionally switch to the popup player.

With the new enum, it’s trivial to change it to always stay at the
selected player now ;)
2025-09-06 17:40:18 +02:00
whistlingwoods
21e24c9e34 Apply review suggestions 2025-09-06 19:14:15 +05:30
Profpatsch
eb277fe14b Player/handleIntent: call handleIntentPost unconditionally
We always need to handleIntentPost otherwise the VideoDetailFragment
is not setup correctly.
2025-09-06 15:31:14 +02:00
Profpatsch
d77771a60c Player/handleIntent: fix enqueue if player not running
In 063dcd41e5 I falsely claimed that the
fallthrough case is always degenerate, but it kinda somehow still
worked because if you long-click on e.g. the popup button, it would
call enqueue, but if nothing was running yet it would fallthrough to
the very last case and start the player with the video.

So let’s return to that and add a TODO for further refactoring in the
future.
2025-09-06 15:09:11 +02:00
Profpatsch
01f9a3de33 Fix Checkstyle & remove unused fields 2025-09-06 15:09:11 +02:00
Profpatsch
150649aea9 Player/handleIntent: Don’t delete queue when clicking on timestamp
Fixes https://github.com/TeamNewPipe/NewPipe/issues/11013

We finally are at the point where we can have good logic around
clicking on timestamps.

This is pretty straightforward:

1) if we are already playing the stream (usual case), we skip to the
   correct second directly
2) If we don’t have a queue yet, create a trivial one with the stream
3) If we have a queue, we insert the video as next item and start
  playing it.

The skipping logic in 1) is similar to the one further down in the old
optimization block, but will always correctly fire for timestamps now.
I copied it because it’s not quite the same code, and moving into a
separate method at this stage would complicate the code too much.
2025-09-06 15:09:11 +02:00
Profpatsch
3803d49489 Player/handleIntent: separate out the timestamp request into enum
Instead of implicitely reconstructing whether the intent was
intended (lol) to be a timestamp change, we create a new kind of
intent that *only* sets the data we need to switch to a new timestamp.

This means that the logic of what to do (opening a popup player) gets
moved from `InternalUrlsHandler.playOnPopup` to the
`Player.handleIntent` method, we only pass that we want to jump to a
new timestamp. Thus, the stream is now loaded *after* sending the
intent instead of before sending.

This is somewhat messy right now and still does not fix the issue of
queue deletion, but from now on the queue logic should get more
straightforward to implement.

In the end, everything should be a giant switch. Thus we don’t
fall-through anymore, but run the post-setup code manually by calling
`handeIntentPost` and then returning.
2025-09-06 15:06:53 +02:00
Profpatsch
25a4a9a253 Player/handleIntent: move prefs parameters into initPlayback
They are just read from the player preferences and don’t influence the
branching, no need to read them in the intent parsing logic.
2025-09-06 15:04:06 +02:00
Profpatsch
d534946550 Player: inline repeat mode cycling 2025-09-06 15:04:06 +02:00
Profpatsch
8fb3e90fe1 Player: remove unused REPEAT_MODE intent key 2025-09-06 15:04:06 +02:00
Profpatsch
5750ef6aa8 Player/handleIntent: start converting intent data to enum
The goal here is to convert all player intents to use a single enum
with extra data for each case. The queue ones are pretty easy, they
don’t carry any extra data. We fall through for everything else for
now.
2025-09-06 15:04:06 +02:00
Profpatsch
ab7d1377e5 Player/handleIntent: always early return on ENQUEUE an ENQUEUE_NEXT
We can do this, because:

1. if `playQueue` is not null, we return early
2. if `playQueue` is null and we need to enqueue:
  - the only “proper” case that could be triggered is
    the `RESUME_PLAYBACK` case, which is never `true` for the queuing
    intents, see the comment in `NavigationHelper.enqueueOnPlayer`
  - the generic `else` case is degenerate, because it would crash on
  `playQueue` being `null`.

This makes some sense, because there is no way to trigger the
enqueueing logic via the UI currently if there is no video playing
yet, in which case `playQueue` is not `null`.

So we need to transform this whole if desaster into a big switch.
2025-09-06 15:04:06 +02:00
Profpatsch
fd24c08529 Player/handleIntent: de morgan samePlayQueue
Okay, so this is the … only? branch in this if-chain that will
conditionally fire if `playQueue` *is* `null`, sometimes.

This is why the unconditional `initPlayback` in `else` is not passed a
`null` in many cases … because `RESUME_PLAYBACK` is `true` and
`playQueue` is `null`.

It’s gonna be hard to figure out which parts of that are intentional,
I say.
2025-09-06 15:04:06 +02:00
Profpatsch
e14ec3a4f9 NavigationHelper: inline trivial getPlayerIntent use 2025-09-06 15:04:06 +02:00
Profpatsch
b592403a66 NavigationHelper: push out resumePlayback one layer 2025-09-06 15:04:06 +02:00
Profpatsch
90e1ac56ef NavigationHelper: inline getPlayerEnqueueIntent
Funnily enough, I’m pretty sure that whole comment will be not
necessary, because we never check `resumePlayback` on handling the
intent anyway.
2025-09-06 15:04:06 +02:00
Profpatsch
32eb3afe16 Player/handleIntent: a few comments 2025-09-06 15:04:06 +02:00
Fynn Godau
83a0abddcc Fix and simplify openUrlInBrowser
The code was not previously working in case no default browser is set[1]
AND NewPipe is set as default handler for the link in question.

We improve it by telling the system to choose the target app as if the
URI was `http://`, which works even if the user has not set a default
browser.

[1]: also the case if platform refuses to tell an app what the user's
default browser is, which I observed on CalyxOS.
2025-09-05 17:49:58 +02:00
Profpatsch
35c7f2f5d1 Player: Remove unused IS_MUTED intent key
The only use of the key was removed in commit
2a2c82e73b
but the handling logic stayed around. So let’s get rid of it.
2025-09-05 16:57:27 +02:00
Stypox
8afb00d2f0
Merge pull request #12603 from Stypox/better-error-panel 2025-09-05 13:31:39 +02:00
Stypox
f27ec53c08
Even more centralized error handling in ErrorInfo 2025-09-05 12:39:16 +02:00
Stypox
a3ddd616f9
Merge pull request #12578 from Stypox/better-error-messages 2025-09-04 13:18:40 +02:00
Stypox
79980e2078
Address PR reviews 2025-09-04 13:17:45 +02:00
Isira Seneviratne
b204fad9d5
Merge pull request #12471 from Isira-Seneviratne/Fix-notifications
Fix foreground service issues
2025-09-01 05:05:47 +05:30
Isira Seneviratne
08f51abefb Added comments 2025-08-31 22:25:12 +05:30
Stypox
204df4c45a
Fix test 2025-08-30 14:58:08 +02:00
Stypox
989c0cfd28
Fix REPORT in snackbar not opening ErrorActivity if MainActivity not shown
Bug caused by https://github.com/TeamNewPipe/NewPipe/pull/11789
2025-08-30 14:39:23 +02:00
Stypox
a369deeef4
Allow ErrorInfo messages with formatArgs
- ErrorInfo.getMessage() now returns an ErrorMessage instance that can be formatted into a string using a context (this allows the construction of an ErrorInfo to remain independent of a Context)
- now the service ID is used in ErrorInfo.getMessage() to customize some messages based on the currently selected service
- player HTTP invalid statuses are now included in the message
- building a custom error message for AccountTerminatedException was moved from ErrorPanelHelper to ErrorInfo
2025-08-30 14:36:27 +02:00
Stypox
1bde2dcd9f
Fix ordering of error messages conditions 2025-08-28 17:06:10 +02:00
Stypox
29a3ca83b5
Show better information about player errors 2025-08-28 17:06:09 +02:00
Stypox
38064be702
Add more specific error messages and deduplicate their handling 2025-08-28 17:05:52 +02:00
Tobi
d17eae9bad
Merge pull request #12253 from Profpatsch/popup-overlay-alert-dialog
Overlay Permission: display explanatory dialog for Android > R
2025-08-27 02:50:45 -07:00
TobiGr
74562db965 Use androidx compat alert dialog 2025-08-27 11:45:31 +02:00
Profpatsch
386d5197d8 Permission: display explanatory dialog for Android > R
On Android > R, ACTION_MANAGE_OVERLAY_PERMISSION always brings the
user to the app selection screen.

https://developer.android.com/about/versions/11/privacy/permissions#manage_overlay

This is highly confusing behaviour from the system, so let’s add an
instruction before navigating to the settings menu.
2025-08-27 11:38:25 +02:00
Tobi
ccd76dea1f
Merge pull request #12544 from Stypox/download-options
Add option to delete a download without also deleting file
2025-08-27 02:31:14 -07:00
whistlingwoods
9282cce6a8 fix: unfinished downloads disappear from the downloads list after app gets killed
Author: InfinityLoop1308
Adapted for NewPipe from a fork's this commit 1cf059ce5e
2025-08-22 01:14:24 +05:30
Stypox
7644066c5a
Add option to delete a download without also deleting file 2025-08-16 16:50:01 +02:00
Stypox
9bc8139b8c
Merge pull request #12483 from TeamNewPipe/ignore-picasso-update 2025-08-11 17:48:30 +02:00
Tobi
ff3526b28d
Merge pull request #12460 from Isira-Seneviratne/Short-count-refactor
Fix short count formatting for Android versions below 7.0
2025-08-01 10:56:41 -07:00
TobiGr
d6c0dc32d1 Correctly ignore new version check for picasso 2025-08-01 10:50:54 +02:00
Stypox
124ab56c5f
Merge branch 'master' into dev 2025-07-31 23:52:01 +02:00
Stypox
95a0e0ca39
Merge pull request #12435 from TeamNewPipe/release-0.28.0 2025-07-31 23:51:10 +02:00
Stypox
4d97a7653d
Merge pull request #12450 from TeamNewPipe/yt-trending-migration 2025-07-31 23:48:53 +02:00
Hosted Weblate
5aefa4aff2
Translated using Weblate (Tigrinya)
Currently translated at 12.7% (95 of 748 strings)

Co-authored-by: fool <thing-sauna-cussed@duck.com>
2025-07-31 23:43:24 +02:00
Stypox
b846746119
Update NewPipeExtractor to v0.24.8 2025-07-31 23:43:19 +02:00
Stypox
b7b836e941
Update the names of YT kiosks 2025-07-31 23:43:19 +02:00
Stypox
d96c0aebb1
Show tabs above kiosks in drawer 2025-07-31 23:43:19 +02:00
Stypox
8400a9ae8e
Remove DEBUG statements and don't replace yt trending with live
You can use this command to test instead:

adb shell run-as org.schabi.newpipe.debug.pr12450 'sed -i '"'"'s#<int name="last_used_preferences_version" value="8" />#<int name="last_used_preferences_version" value="6" />#'"'"' shared_prefs/org.schabi.newpipe.debug.pr12450_preferences.xml' && adb shell run-as org.schabi.newpipe.debug.pr12450 'sed -i '"'"'s#\]}</string>#,{\&quot;tab_id\&quot;:5,\&quot;service_id\&quot;:0,\&quot;kiosk_id\&quot;:\&quot;Trending\&quot;},{\&quot;tab_id\&quot;:5,\&quot;service_id\&quot;:1,\&quot;kiosk_id\&quot;:\&quot;Top 50\&quot;}]}</string>#'"'"' shared_prefs/org.schabi.newpipe.debug.pr12450_preferences.xml'
2025-07-31 23:43:19 +02:00
Stypox
7cecd11f72
[YouTube] Add icons and strings for new trending pages 2025-07-31 23:43:19 +02:00
TobiGr
ed93603815
WIP: Add SettingsMigration to change YouTube trending kiosk tab 2025-07-31 23:43:19 +02:00
Stypox
56f79fac13
Merge branch 'release-0.28.0' into dev 2025-07-30 11:42:06 +02:00
Stypox
86efde5996
Merge pull request #12476 from TeamNewPipe/weblate 2025-07-29 20:23:08 +02:00
Stypox
ca9fc14c2a
Fix name of nepali language (there was a leftover N) 2025-07-29 20:19:31 +02:00
tobigr
7130adb4ec
Clean strings 2025-07-29 20:19:31 +02:00
tobigr
e08d2d8726
Add new locals to the in-app language chooser 2025-07-29 20:19:31 +02:00
Isira Seneviratne
ef29c318b0 Remove NewApi suppression 2025-07-29 06:18:27 +05:30
Hosted Weblate
6516fb96fd Translated using Weblate (Romanian)
Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (French)

Currently translated at 100.0% (748 of 748 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (745 of 749 strings)

Translated using Weblate (Estonian)

Currently translated at 18.6% (16 of 86 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 99.5% (746 of 749 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.7% (747 of 749 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Hebrew)

Currently translated at 99.7% (747 of 749 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Slovak)

Currently translated at 99.7% (747 of 749 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.5% (746 of 749 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (German)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (German)

Currently translated at 100.0% (749 of 749 strings)

Translated using Weblate (Tamazight (Central Atlas))

Currently translated at 19.2% (144 of 749 strings)

Translated using Weblate (Macedonian)

Currently translated at 79.3% (594 of 749 strings)

Translated using Weblate (Slovenian)

Currently translated at 54.6% (409 of 749 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.7% (95 of 747 strings)

Translated using Weblate (Tigrinya)

Currently translated at 3.4% (3 of 86 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (743 of 747 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 90.6% (78 of 86 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (French)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Polish)

Currently translated at 58.1% (50 of 86 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (German)

Currently translated at 100.0% (86 of 86 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (German)

Currently translated at 100.0% (747 of 747 strings)

Co-authored-by: 439JBYL80IGQTF25UXNR0X1BG <439JBYL80IGQTF25UXNR0X1BG@users.noreply.hosted.weblate.org>
Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Drugi Sapog <dindrugi@users.noreply.hosted.weblate.org>
Co-authored-by: Dual Natan <dvapatinatan@gmail.com>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Hakim Oubouali <hakim.oubouali.skr@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Igor Rückert <igorruckert@yahoo.com.br>
Co-authored-by: Igor Sorocean <sorocean.igor@gmail.com>
Co-authored-by: Jeff Huang <s8321414@gmail.com>
Co-authored-by: Marian Hanzel <marulinko@gmail.com>
Co-authored-by: Matej U <mateju@src.gnome.org>
Co-authored-by: Michael Moroni <michaelmoroni@disroot.org>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: NTFSynergy <ntfsynergy@gmail.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Stypox <stypox@pm.me>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: Vasilis K <skyhirules@gmail.com>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Yassin Amir <y6b5@proton.me>
Co-authored-by: erti <erti@users.noreply.hosted.weblate.org>
Co-authored-by: ikanakova <ikanakova@users.noreply.hosted.weblate.org>
Co-authored-by: nautilusx <translate@disroot.org>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Co-authored-by: zmni <zmni@outlook.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim5@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: 赖诚俊 <cosmic.universe.glitch@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ar/
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/et/
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/hu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/it/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pa/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pl/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ru/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ti/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/uk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hans/
Translation: NewPipe/Metadata
2025-07-28 23:05:05 +02:00
Stypox
e9922fe162
Merge pull request #12470 from litetex/cleanup-PlayerHelper-localization 2025-07-28 15:30:07 +02:00
Stypox
eea2b7417e
Fix player formatters resetting too early on language change
formatters() is called again by the player before the user has a chance to click on the language in the language chooser.

So the correct solution would probably be to attach to https://developer.android.com/reference/android/content/Intent#ACTION_LOCALE_CHANGED, but let's keep it simple. I added `PlayerHelper.resetFormat();` in `ContentSettingsFragment.onDestroy()` and it works. It will mean the player formatters will be reset every time the user exits content settings, but whatever.
2025-07-28 15:29:06 +02:00
litetex
893a1cb699
Encapsulate Formatters in PlayerHelper
and reset them when the language is changed/changing.
This way they will be re-initialized on the next call.

Also Remove a bunch of outdated/non-thread safe code (STRING_FORMATTER)
2025-07-28 15:11:27 +02:00
litetex
ebd5e1a318
Remove unused method 2025-07-28 15:11:27 +02:00
litetex
70841db92f
Cleanup `Localization` formatting 2025-07-28 15:11:27 +02:00
litetex
859555e129
Use regions 2025-07-28 15:11:27 +02:00
Stypox
c1cef19b33
Merge pull request #12455 from TobiGr/nextPage-nullable 2025-07-28 14:52:08 +02:00
Stypox
9ba30887f9
Improve null checking further in SearchFragment.handleNextItems 2025-07-28 14:43:46 +02:00
Stypox
0ef38e3a4d
Merge pull request #12472 from TeamNewPipe/user-agent-140 2025-07-28 14:03:42 +02:00
Isira Seneviratne
9f11db8e06 Improve scale display 2025-07-28 09:02:52 +05:30
Isira Seneviratne
fece0741e5 Suppress NewApi 2025-07-27 15:47:06 +05:30
TobiGr
a9ce2e9605 Update USER_AGENT to Firefox 140 ESR 2025-07-27 09:39:53 +02:00
Isira Seneviratne
b9b47fc520 Update manifest, startForeground call 2025-07-27 11:58:01 +05:30
Isira Seneviratne
59db955493 Fix new streams notification issue 2025-07-27 11:31:23 +05:30
Isira Seneviratne
22a709d53b
Merge pull request #12388 from mikooomich/sdk35
Target SDK 35
2025-07-24 08:18:32 +05:30
Michael Zh
329d76c857 Bump emulator target 33 -> 35 2025-07-23 22:30:34 -04:00
Isira Seneviratne
9f526e8e8f Fix short count formatting for Android versions below 7.0 2025-07-24 07:56:44 +05:30
Michael Zh
50caba6606 Fix compile
Co-Authored-By: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>
2025-07-23 18:49:28 -04:00
Michael Zh
26443f9f14 WIP: Fix compile 2025-07-23 18:45:30 -04:00
Michael Zh
366129eee2 Fix error toast crash 2025-07-23 18:45:30 -04:00
Michael Zh
4c8d44b6ba Bump compileSdk to 36 and targetSdk to 35
* Sdk 36 requires edge to edge, so use 35 so we can opt out for now
2025-07-23 18:45:30 -04:00
Michael Zh
14cd562ebd Update manifest for sdk34 FGS changes 2025-07-23 18:45:30 -04:00
Michael Zh
04ef608f7a Specify RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTED for sdk34 2025-07-23 18:45:30 -04:00
Stypox
71fcc5ebce
Release v0.28.0 (1005) 2025-07-23 14:22:07 +02:00
TobiGr
30e33d59e8 Use correct fix for nextPage being null while creating error report in SearchFragment.handleNextItems() 2025-07-22 16:12:02 +02:00
Kouki Badr
a4bd82be8a
fix: handle nullable nextPage behavior when searching albums #12401 (#12408)
* fix: handle nullable nextPage behavior when searching albums #12401

* feat: add nullable annotation to newPage attribute in SearchFragment

* Updated more usages of InfoItemsPage#getNextPage. Nullability is already handled in these areas so no other changes needed

---------

Co-authored-by: Siddhesh Naik <siddheshnaik20@protonmail.com>
2025-07-22 08:58:56 +05:30
litetex
45589dbf26
Merge pull request #12444 from Isira-Seneviratne/Per-app-language
Enable per-app language preferences for Android < 13
2025-07-20 22:20:12 +02:00
litetex
99ae3fdd4e
Removed no longer needed translation key 2025-07-20 22:05:05 +02:00
litetex
f48e73eb2a
Cleaned up some code related to app language
* Use build constants when possible
* Inline variables
* Don't use var for normal-sized types (that way it's easier to review)
* Split code into methods
2025-07-20 21:52:07 +02:00
Isira Seneviratne
99003bab07 Clean up imports 2025-07-20 16:43:37 +05:30
Isira Seneviratne
9e14f93186 Properly handle when system language is selected 2025-07-20 16:27:07 +05:30
Isira Seneviratne
abd9aade87 Update AppCompat 2025-07-20 05:24:56 +05:30
Isira Seneviratne
b8f9c125cd Add link for future reference 2025-07-20 05:03:20 +05:30
Isira Seneviratne
893a227ab1 Enable per-app language preferences for Android < 13 2025-07-20 04:50:49 +05:30
Stypox
0db859e225
Merge pull request #12438 from TeamNewPipe/soundcloud/top_50 2025-07-19 20:53:44 +02:00
Stypox
e61f98bd47
Merge pull request #12434 from TeamNewPipe/fix-new-badge-links-on-readme 2025-07-19 20:44:03 +02:00
Stypox
991d9ea3df
Fix state not saved 2025-07-19 20:39:55 +02:00
Stypox
f94892166d
Improve comment 2025-07-19 20:34:09 +02:00
Stypox
9697112db6
Show error panel in EmptyFragment 2025-07-19 19:41:13 +02:00
litetex
f64dba0107 Fix new badge links on Readme being rendered incorrectly
For all non default Readmes
2025-07-19 22:45:32 +05:30
litetex
9bf01e1241 Fix new badge links on Readme being rendered incorrectly 2025-07-19 22:45:32 +05:30
Stypox
474efbebc1
Merge pull request #12437 from TeamNewPipe/localization-main-page-content 2025-07-19 19:04:34 +02:00
tobigr
fe58ec85ed Fix error detection when loading main page tabs
Do not crash if something unexpected happens.
2025-07-19 13:37:54 +02:00
tobigr
941f85781b Display dialog informing the user about the removal of the Top 50 kiosk 2025-07-19 13:37:54 +02:00
tobigr
7e0ee4eb7a Update Extractor and add migration to remove SoundCloud Top 50 kiosk 2025-07-18 18:59:28 +02:00
tobigr
4a41214df4 Do not capitalize "page" for main page content options 2025-07-18 10:28:59 +02:00
Stypox
938265d127
Update NewPipeExtractor 2025-07-17 23:57:03 +02:00
Stypox
ba4e7a3c7f
Add changelog for v0.28.0 (1005) 2025-07-17 10:18:10 +02:00
Hosted Weblate
58b5ccb66f
Translated using Weblate (Czech)
Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (745 of 745 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (745 of 745 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (745 of 745 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (745 of 745 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Serbian)

Currently translated at 99.7% (745 of 747 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (French)

Currently translated at 100.0% (747 of 747 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (Tigrinya)

Currently translated at 12.6% (94 of 744 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 8.2% (7 of 85 strings)

Translated using Weblate (Serbian)

Currently translated at 16.4% (14 of 85 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Azerbaijani (Southern))

Currently translated at 1.1% (1 of 85 strings)

Added translation using Weblate (Azerbaijani (Southern))

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Serbian)

Currently translated at 16.4% (14 of 85 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Danish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Romanian)

Currently translated at 99.7% (742 of 744 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (French)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (German)

Currently translated at 100.0% (744 of 744 strings)

Translated using Weblate (Persian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 97.6% (83 of 85 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Kabyle)

Currently translated at 29.0% (215 of 741 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (741 of 741 strings)

Added translation using Weblate (Luri (Bakhtiari))

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Persian)

Currently translated at 94.3% (699 of 741 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Persian)

Currently translated at 94.1% (698 of 741 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Finnish)

Currently translated at 98.5% (730 of 741 strings)

Translated using Weblate (French)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (French)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (German)

Currently translated at 100.0% (85 of 85 strings)

Translated using Weblate (Sardinian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (German)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.4% (59 of 85 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Latvian)

Currently translated at 21.4% (18 of 84 strings)

Translated using Weblate (Latvian)

Currently translated at 99.7% (739 of 741 strings)

Translated using Weblate (Latvian)

Currently translated at 20.2% (17 of 84 strings)

Translated using Weblate (Latvian)

Currently translated at 16.6% (14 of 84 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (French)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Greek)

Currently translated at 32.1% (27 of 84 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Urdu)

Currently translated at 69.2% (513 of 741 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Catalan)

Currently translated at 90.2% (669 of 741 strings)

Translated using Weblate (Estonian)

Currently translated at 17.8% (15 of 84 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 76.7% (569 of 741 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 76.2% (565 of 741 strings)

Translated using Weblate (Lithuanian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Danish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Kabyle)

Currently translated at 28.8% (214 of 741 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (740 of 741 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 67.8% (57 of 84 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (737 of 741 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (French)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (German)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 13.6% (101 of 741 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (741 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 12.1% (90 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 7.6% (57 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 7.4% (55 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 7.4% (55 of 741 strings)

Translated using Weblate (Breton)

Currently translated at 7.4% (55 of 741 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.8% (740 of 741 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (741 of 741 strings)

Added translation using Weblate (Breton)

Translated using Weblate (Romanian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Danish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Finnish)

Currently translated at 98.5% (729 of 740 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (740 of 740 strings)

Co-authored-by: 439JBYL80IGQTF25UXNR0X1BG <439JBYL80IGQTF25UXNR0X1BG@users.noreply.hosted.weblate.org>
Co-authored-by: AP <kubanto@users.noreply.hosted.weblate.org>
Co-authored-by: Abu Sarim Hindi <sarfaraz.ahmed78@gmail.com>
Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Ajeje Brazorf <lmelonimamo@yahoo.it>
Co-authored-by: Alex25820 <alexs25820@gmail.com>
Co-authored-by: Balázs Meskó <meskobalazs@mailbox.org>
Co-authored-by: Bastian <basti.anderl774@gmail.com>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: Danial Behzadi <dani.behzi@ubuntu.com>
Co-authored-by: Darth23G (DarthGamer23) <fref2329@gmail.com>
Co-authored-by: Deleted User <noreply+48943@weblate.org>
Co-authored-by: Dream X <nodem49316@daupload.com>
Co-authored-by: Drugi Sapog <dindrugi@users.noreply.hosted.weblate.org>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Fareedar Islami <fareedar.islami@gmail.com>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <git@alius.cz>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: GiannosOB <giannos2105@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Igor Rückert <igorruckert@yahoo.com.br>
Co-authored-by: Igor Sorocean <sorocean.igor@gmail.com>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Jay Tromp <jaytromp@pm.me>
Co-authored-by: Jeff Huang <s8321414@gmail.com>
Co-authored-by: Jordi Cambrells <cambrells@users.noreply.hosted.weblate.org>
Co-authored-by: Jordi Cambrells <hanta.hrabal@gmail.com>
Co-authored-by: Juzé <dedakir923@exoular.com>
Co-authored-by: KaGaster <mohamed.kooli@medtech.tn>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: MS-PC <MSPCtranslator@gmail.com>
Co-authored-by: Mandeep <mandeeps708@gmail.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Michael Moroni <michaelmoroni@disroot.org>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: Mohammed al-Qubati <mhraqeeb@gmail.com>
Co-authored-by: Mücteba <muctebanesiri@gmail.com>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Nick Wick <NickWick@users.noreply.hosted.weblate.org>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Priit Jõerüüt <jrthwlate@users.noreply.hosted.weblate.org>
Co-authored-by: Q. Boii <sf1hks@marketmail.info>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: THANOS SIOURDAKIS <siourdakisthanos@gmail.com>
Co-authored-by: Trunars <trunars@abv.bg>
Co-authored-by: Vasilis K <skyhirules@gmail.com>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Yasser Althuwaini <ymth2000@outlook.com>
Co-authored-by: Yauhen <bugomol@users.noreply.hosted.weblate.org>
Co-authored-by: ab_09 <ab_09@users.noreply.hosted.weblate.org>
Co-authored-by: cat <catsnote@proton.me>
Co-authored-by: dekiw39846 <dekiw39846@bariswc.com>
Co-authored-by: elid <shopisrael12@gmail.com>
Co-authored-by: gfbdrgn <erfvvgtyhbnjhyuu@wireconnected.com>
Co-authored-by: late <late@users.noreply.hosted.weblate.org>
Co-authored-by: moton03 <moton.cat@outlook.com>
Co-authored-by: rehork <cooky@e.email>
Co-authored-by: rimasx <riks_12@hot.ee>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: Максим Горпиніч <gorpinicmaksim5@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic4@gmail.com>
Co-authored-by: Саша Петровић <salepetronije@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Co-authored-by: ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝) <coool@mail.lv>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: 李恩霆 <timothylee0802@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ar/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/azb/
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/el/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/en_GB/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/et/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/lv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt_PT/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ru/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ta/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hant/
Translation: NewPipe/Metadata
2025-07-17 09:16:38 +02:00
Stypox
4e94b2602d
Merge pull request #12258 from Profpatsch/show-service-name-in-search 2025-07-16 15:02:43 +02:00
Stypox
4ddc0648ef
Merge pull request #12412 from Stypox/fix-ghost-notifications 2025-07-14 21:56:22 +02:00
Tobi
4c920a4406
Merge pull request #12367 from watermelon42/3783_Import_Soundcloud_likes
Support Soundcloud likes in channel and feed
2025-07-14 01:01:02 -07:00
watermelon42
1c0eabf75c Updated extractor version to latest commit 2025-07-13 16:21:42 +02:00
watermelon42
f119a368d8 Added support for importing Soundcloud likes as a new tab before About in a user's channel.
The likes are also retrieved in the feed if the user is subscribed to.
2025-07-11 09:50:33 +02:00
Stypox
f3c20d43be
Merge pull request #12410 from Stypox/fix-android-auto-thumbnails 2025-07-08 11:42:20 +02:00
Tobi
c9559fa801
Merge pull request #12416 from Stypox/fix-fullscreen-clear-queue-prompt
Fix fullscreen eliciting "clear queue" prompt
2025-07-07 11:47:27 -07:00
Stypox
8ab79488e9
Merge pull request #12409 from Stypox/readme-badge-size-fix 2025-07-07 16:26:20 +02:00
Stypox
f0b26e208b
Update notice about rewrite in the README 2025-07-07 16:25:38 +02:00
Stypox
79084568f2
Fix fullscreen eliciting "clear queue" prompt 2025-07-07 15:07:46 +02:00
Stypox
705b5e5580
Fix ghost notifications on Android 10
Fixes #12400, see there for explanation. Citing from there:

So apparently the problem is onGetRoot always returning a BrowserRoot instance. Making it return null solved the issue (but again, breaks Android Auto compatibility). It turns out (see https://stackoverflow.com/q/63818988/) that onGetRoot is also used for media resumption https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation, which causes a new notification to pop up (in this case a useless notification because our onGetRoot does not return something that can be used for resumption). So what needs to be done is to check if rootHints?.getBoolean(EXTRA_RECENT) == true and if that's the case not return anything (as EXTRA_RECENT is used by the system for resumption).

The PackageValidator file is taken from 329a21b63c/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt .
2025-07-07 01:06:59 +02:00
Stypox
a4d457b2b2
Use Kotlin's .toUri() instead of Uri.parse() 2025-07-06 15:05:30 +02:00
Stypox
834c93f22a
Fix thumbnails appearing on Android Auto even if disabled 2025-07-06 14:49:09 +02:00
Stypox
a0adeb0099
Fix "Get it on F-Droid" appearing giant in README 2025-07-06 13:51:59 +02:00
Isira Seneviratne
2dd11f70a3
Merge pull request #12356 from dev-victoria/fix-json-importing-oldandroid
check if the JSON MimeType is supported
2025-07-04 07:06:34 +05:30
Stypox
d048bca8b4
Temporarily disable sonarcloud CI step 2025-06-28 15:17:18 +02:00
Diana Victoria Furrer
0c9f5ddcaf change according to Isira-Seneviratne suggestion 2025-06-17 15:42:01 +02:00
Diana Victoria Furrer
aa75a1449f use MimeTypeMap from android webkit to check if the json MimeType is unsupported 2025-06-15 02:19:56 +02:00
Profpatsch
16e32dfc96 SearchFragment: show filter in brackets behind service name
This is still not perfect, but it will show the selected search filter
in addition to the service name, like: “Search YouTube (Playlists)”.

It will not distinguish between a YouTube Music and Youtube filter, so
it will display the same thing. Could be improved, but then the text
gets too long! :(
2025-06-05 14:30:04 +02:00
Stypox
8c4a789f78
Merge pull request #12302 from davidasunmo/update-readmes 2025-06-04 11:59:56 +02:00
Stypox
769e98acd0
Show search filter in search bar hint 2025-06-04 11:54:31 +02:00
Stypox
8e036b5e69
Merge pull request #12325 from dev-victoria/FeedGroupTab 2025-06-04 11:24:32 +02:00
Stypox
571b7bc74b
Improve layout of select_feed_group_item 2025-06-04 11:18:04 +02:00
Audric V.
033cc08c26
Merge pull request #12322 from dev-victoria/tiny-code-fixes
Fix equality comparison in Tab class
2025-05-31 16:56:37 +02:00
Diana Victoria Furrer
205d18f4c4 Use GroupName for the Settings Text.
The Tabname displays the default Feed title.
2025-05-31 14:11:26 +02:00
Diana Victoria Furrer
712724211c added FeedGroup to Tab Settings UnitTest 2025-05-31 01:41:06 +02:00
Diana Victoria Furrer
fd09e6147f # Fixed Feed Group Titlebar
- use default fragment_feed_title for TabName
- only clear FeedFragment bar subtitle when it matches the groupName to clear.
2025-05-31 01:30:49 +02:00
Diana Victoria Furrer
279caac915 # Change
Layout select_feed_group_item (FeedGroup Picker in the Settings)
Remove rounded style from the icons
2025-05-30 21:00:37 +02:00
Diana Victoria Furrer
f8ed8e575e # Change
Added FEEDGROUP Tab Code to
 - ChooseTabsFragment
 - Tab

Added strings:
- feed_group_page_summary
2025-05-30 20:47:37 +02:00
Diana Victoria Furrer
436626fa83 # Change
Adjusted select_feed_group_fragment Layout
 - reference select_feed_group_item layout
 - use new Strings

Added strings:
- select_a_feed_group
- no_feed_group_created_yet
2025-05-30 17:54:49 +02:00
Diana Victoria Furrer
7c3989ff93 # Change
Adjusted the new Class SelectFeedGroupFragment for its Role
- Renamed Variables
- adjusted Imports
- adjusted Interface with FeedGroupEntity Values
2025-05-30 17:45:51 +02:00
Diana Victoria Furrer
e6c4690e7d # Copied Layouts
Copied select_channel_fragment to select_feed_group_fragment

Copied select_channel_item to select_feed_group_item

# Change
Replaced the Layout references in the new Class SelectFeedGroupFragment
2025-05-30 17:07:19 +02:00
Diana Victoria Furrer
86869f0a14 Copied SelectFeedGroupFragment from SelectChannelFragment 2025-05-30 16:55:07 +02:00
Diana Victoria Furrer
aa0b45c05f ChannelTab.equals fix comparison 2025-05-30 13:21:45 +02:00
David Asunmo
55bf74b4a7 Fix CI status badge 2025-05-24 02:15:45 +01:00
David Asunmo
de3d11568d Add nightly builds to all readmes
Add matrix to .ru
2025-05-22 03:29:12 +01:00
David Asunmo
16077dee80 Add matrix chat link to all READMEs 2025-05-22 03:29:12 +01:00
Stypox
c9155f7834
Merge pull request #12298 from davidasunmo/add-dev-refactor-nightly-badges
Add dev and refactor nightly build badges
2025-05-20 11:13:38 +02:00
David
7dd1abdf9c
Add dev and refactor nightly build badges
bottom text
2025-05-20 02:22:47 +01:00
Profpatsch
f3858e70a3
Merge pull request #11789 from Thompson3142/fix_background_crash_focus
Fix background crash focus
2025-05-09 23:41:38 +02:00
Thompson3142
76202e6b4b Remove no longer needed dependency 2025-05-09 22:29:05 +02:00
Thompson3142
90e2f234e7 Initial commit for better handling of background crashes
Fix crashing behaviour with entry in SharedPreferences

A few minor improvements

Added docs for isInBackground

Some more minor changes

Overwrite methods in MainActivity instead of creating a new class
2025-05-09 22:29:00 +02:00
Profpatsch
42a52b7118
Merge pull request #12259 from Profpatsch/put-@-on-right-side-of-rtl-usernames
Comments: Put @ on the right side of right-to-left usernames
2025-05-08 21:46:00 +02:00
Stypox
d9dccfa8af
Merge branch 'master' into dev 2025-05-08 15:04:06 +02:00
Profpatsch
e554c77f2e Comments: Put @ on the right side of right-to-left usernames
From the discussion in
https://github.com/TeamNewPipe/NewPipe/pull/12188 it reads more
natural for RTL readers.
2025-05-07 14:20:44 +02:00
Stypox
81b4e3f970
Hotfix release v0.27.7 (1004) 2025-05-07 12:52:43 +02:00
TobiGr
ef068e1eca
Update NewPipe Extractor and add new proguard rules
New rules are required since Rhino and Rhino Engine 1.8.0
2025-05-07 12:50:37 +02:00
Stypox
8407b5aefd
Add translated changelogs for v0.27.7
Copied from 985.txt
2025-05-07 12:49:31 +02:00
Stypox
b6aa07545a
Add changelog for v0.26.7 (1004) 2025-05-07 12:48:59 +02:00
Stypox
1dcb1953ba
Update NewPipeExtractor to v0.24.6
For some reason
com.github.TeamNewPipe.NewPipeExtractor:v0.24.6
didn't work, but
com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.6
as suggested on https://jitpack.io/#TeamNewPipe/NewPipeExtractor/v0.24.6 worked...
2025-05-07 12:36:08 +02:00
Profpatsch
862a8e8f26
Merge pull request #12188 from VougJo23/commentsfix
fix: support RTL usernames in comment header
2025-05-07 12:20:23 +02:00
Profpatsch
88395fa852
Merge pull request #12202 from AndrianaBilali/fix/timestamp-clicks-in-replies
Fix timestamps not working in comment replies
2025-05-07 12:07:03 +02:00
VougJo23
8d679626f0 fix: support RTL usernames in comment header
The `@` gets added by the youtube API and thus is a fixed member of
the username, so we do some simple detection logic to handle that
case (otherwise the `@` will be at the right side of a RTL username,
which is different of how Youtube displays these usernames in the
browser).

Fixes https://github.com/TeamNewPipe/NewPipe/issues/12141
2025-05-07 12:05:09 +02:00
Profpatsch
d2dc20c551 SearchFragment: show service name in search hint
The only hint (haha) which service one is searching in is currently
the color of the background. This is super confusing, yesterday a
friend tried to search for a video on youtube and the app was set to
Bandcamp, and they were super confused why nothing turned up.

So let’s put the name of the service in the hint!

The `updateService()` thing is a little confused, but I didn’t want
to refactor to improve the logic. It’s not doing anything
computationally intensive anyway.

For PeerTube, the sidebar calls it FramaTube but the service name is
PeerTube, I’m not sure why that is the case. Looks like the string
depends on the name of the instance? Hm, can be improved later I
think.
2025-05-07 10:12:41 +02:00
Andriana
e7f3750f5e Fix timestamps not working in comment replies
Use LinkMovementMethodCompat for comment links

Co-authored-by: Isira Seneviratne <31027858+Isira-Seneviratne@users.noreply.github.com>

Update import

Use LongPressLinkMovementMethod
2025-05-06 17:12:17 +02:00
j-haldane
48e826e912
Fix header crash in History List view (#12214)
* Adapt header handling changes from other recyclerview adapters to fix issue #4475 in StatisticsPlaylistFragment

* Remove unneeded LayoutInflater

* Revert "Remove unneeded LayoutInflater"

This reverts commit ab73dc1e72.

* Revert "Adapt header handling changes from other recyclerview adapters to fix issue #4475 in StatisticsPlaylistFragment"

This reverts commit 2abe71cc98.

* Remove header animation causing view recycling issue
2025-05-06 17:07:45 +02:00
Profpatsch
088cb8353e
Merge pull request #12256 from Profpatsch/improve-jitpack-workaraund-docs
build.gradle: Improve jitpack workaround doc & fix hash
2025-05-06 12:56:38 +02:00
Profpatsch
5ca544bc42 build.gradle: Improve jitpack workaround doc & fix hash 2025-05-06 10:48:20 +02:00
Stypox
aa1b7f8584
Merge pull request #12215 from naveensingh/fix-image-minimizer
Fix image minimizer pattern
2025-04-28 07:34:06 +02:00
Naveen Singh
ce16c6df5f
Fix image minimizer pattern
Added non-capturing group that matches either:

 - `user-attachments/assets`
 - `owner/repo/assets/digits`
2025-04-27 19:35:31 -04:00
Stypox
276bf390b2
Merge pull request #12117 from malania02/dev
Show download date of downloaded videos
2025-04-11 20:17:27 +02:00
malania02
f39eda086f
Fix for overlapping 2025-04-09 23:40:14 +02:00
Stypox
756327da39
Merge pull request #12093 from mileskrell/mileskrell/support-per-app-language-preferences
Support per-app language preferences
2025-04-08 23:13:07 +02:00
Stypox
5840d3a437
Merge pull request #12150 from FineFindus/fix/potoken-index
[YouTube] Access first element if array size is one
2025-04-08 23:06:04 +02:00
FineFindus
e1dedd45ed
[YouTube] Access first element if array size is one
Fixes a regression, where if the challenge data array size was one, the second element
would be accessed, leading to a crash.
This was introduced when porting the challenge parsing from JS to
Kotlin.

Ref: 53b599b042
2025-04-02 22:14:01 +02:00
malania02
912f07a1dd
Missing lines added 2025-03-30 14:50:05 +02:00
Miles Krell
205466c56a Move call to setApplicationLocales 2025-03-27 19:14:41 -04:00
Miles Krell
7f10312d0a Move migration to NewPipeSettings 2025-03-23 17:39:21 -04:00
malania02
63be3220e7
Show download date 2025-03-22 16:19:26 +01:00
malania02
536b78f2e6
textview for download date added 2025-03-22 16:13:45 +01:00
malania02
6d6b73ef73
textview for download date added 2025-03-22 16:09:58 +01:00
Stypox
196c27792b
Merge pull request #12044 from TeamNewPipe/android-auto
Add support for Android Auto *(season 2)*
2025-03-21 11:21:58 +01:00
Stypox
b3789315ad
Merge pull request #12104 from TeamNewPipe/update-npe
Update NewPipe Extractor and add new proguard rules
2025-03-21 10:52:37 +01:00
Miles Krell
c7bf498c04 Don't show toast because of changing content language or country 2025-03-16 20:27:05 -04:00
Miles Krell
35abb99dac Only show toast on Android <13 2025-03-16 20:15:38 -04:00
Miles Krell
70416e73f3 Move app language setting migration to SettingMigrations 2025-03-16 19:24:04 -04:00
TobiGr
a0b76c3385 Update NewPipe Extractor and add new proguard rules
New rules are required since Rhino and Rhino Engine 1.8.0
2025-03-16 22:08:10 +01:00
Tobi
c232193a46
Merge pull request #12083 from har-123/bugfix/11894_fix_duplicate_menu_options
Fix duplicate menu options in ChannelFragment
2025-03-16 10:34:52 +01:00
Siddhesh Naik
f289bea6b3 Fix sonar warning 2025-03-16 12:44:05 +05:30
Harshita
48b200868a BF-11894 : Fix the menu disappearing on performing backGesture 2025-03-16 12:44:05 +05:30
Harshita
54bf7f0ced BF-11894 : Fix the Duplicate menu options in ChannelFragment 2025-03-16 12:44:05 +05:30
Miles Krell
980a35a708 Move migration to separate method 2025-03-15 23:00:31 -04:00
Miles Krell
da106e2361 Don't try to migrate "system" app language 2025-03-15 22:54:17 -04:00
Miles Krell
3532ac96b4 Migrate from pre-Android 13 app language pref 2025-03-15 22:13:01 -04:00
Miles Krell
87693a2ad1 Redirect to per-app language settings on Android 13+ 2025-03-15 21:56:02 -04:00
Hosted Weblate
d321e57620 Translated using Weblate (Czech)
Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Catalan)

Currently translated at 88.2% (653 of 740 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 90.4% (76 of 84 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Punjabi)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Croatian)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Greek)

Currently translated at 25.0% (21 of 84 strings)

Translated using Weblate (Greek)

Currently translated at 23.8% (20 of 84 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 99.5% (737 of 740 strings)

Co-authored-by: 439JBYL80IGQTF25UXNR0X1BG <439JBYL80IGQTF25UXNR0X1BG@users.noreply.hosted.weblate.org>
Co-authored-by: Andrey F <firsan777@mail.ru>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Antonin Del Fabbro <message@antonin.one>
Co-authored-by: Christian Eichert <c@zp1.net>
Co-authored-by: Drugi Sapog <dindrugi@users.noreply.hosted.weblate.org>
Co-authored-by: Eduardo Calixto <eduardogubertcalixto@gmail.com>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Igor Rückert <igorruckert@yahoo.com.br>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: Jan Layola <gilajan@protonmail.com>
Co-authored-by: Kevin Wang <wmk153024@gmail.com>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Petr Kadlec <mormegil@centrum.cz>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Sergio Marques <so.boston.android@gmail.com>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: XxVictoriaxX <evakonoob@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: bittin1ddc447d824349b2 <bittin@reimu.nl>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: whistlingwoods <72640314+whistlingwoods@users.noreply.github.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ar/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/el/
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/it/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pa/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hans/
Translation: NewPipe/Metadata
2025-03-15 17:43:36 +01:00
Tobi
fb4a65a14a
Merge pull request #12043 from TeamNewPipe/hide-view-logs
Disable logs about view animations by default
2025-03-15 17:17:59 +01:00
Stypox
3047704e1c
Merge pull request #12089 from mileskrell/mileskrell/fix-audio-track-labels
Disambiguate audio track labels
2025-03-15 12:45:20 +01:00
Stypox
3dcfdaf510
Merge pull request #12065 from tfga/YouTubeTemporaryPlaylist
Share as YouTube temporary playlist
2025-03-15 10:11:59 +01:00
Thiago F. G. Albuquerque
2ceb70236e sharePlaylist(): converting javadoc from Markdown back to "classic javadoc"
(request from @Stypox)
2025-03-14 21:56:42 -03:00
Thiago F. G. Albuquerque
be097f26c8 Deleting the "explanatory text" bellow the title
<string name="share_playlist_with_titles_message">Share playlist with details such as playlist name and video titles or as a simple list of video URLs</string>
    Share playlist with details such as playlist name and video titles or as a simple list of video URLs</string>

(Discussion: https://github.com/TeamNewPipe/NewPipe/pull/12065#discussion_r1994349485)
2025-03-13 19:10:26 -03:00
Thiago F. G. Albuquerque
098f60d593 Don't add the title when sharing as YouTube temp playlist 2025-03-13 18:16:09 -03:00
Thiago F. G. Albuquerque
eb0568044a R.string.share_playlist_as_youtube_temporary_playlist: pt-BR
+ Minor fixes to related translations
2025-03-12 19:09:31 -03:00
Thiago F. G. Albuquerque
f3b3d5c3e7 R.string.share_playlist_as_youtube_temporary_playlist 2025-03-12 19:08:09 -03:00
Miles Krell
b888dc72cf Support per-app language preferences 2025-03-11 23:29:23 -04:00
Thiago F. G. Albuquerque
599d86151a Making ktLint happy 2025-03-11 21:26:58 -03:00
tfga
587df093ea
YouTube video IDs are 11 characters long
Co-authored-by: Stypox <stypox@pm.me>
2025-03-11 20:35:41 -03:00
tfga
8830e87242
YouTube video IDs are 11 characters long
Co-authored-by: Stypox <stypox@pm.me>
2025-03-11 20:35:18 -03:00
Thiago F. G. Albuquerque
f96b8f7b2a Comment: maximum length of 50 items
(PR review from @Stypox)
2025-03-11 20:19:54 -03:00
Thiago F. G. Albuquerque
c28478ae53 getYouTubeId(): Changing implementation to use YoutubeStreamLinkHandler
(PR review from @Stypox)
2025-03-11 20:12:25 -03:00
Miles Krell
10110397fd Use display name instead of only the language 2025-03-10 22:01:09 -04:00
tfga
d81244e77c
YT temp playlist URL: http => https
Co-authored-by: Stypox <stypox@pm.me>
2025-03-10 19:11:20 -03:00
Stypox
ea20ca9e72
Merge pull request #12067 from Isira-Seneviratne/Fix-notification-grouping
Fix stream notification grouping
2025-02-28 11:51:11 +01:00
Isira Seneviratne
f0c89494dd Fix stream notification grouping 2025-02-27 09:15:40 +05:30
Thiago F. G. Albuquerque
0fd2d4fed6 [#11930] Removing Apache Commons Collections
It's no longer needed after the conversion to Kotlin.
2025-02-26 21:29:48 -03:00
Thiago F. G. Albuquerque
3c7b026d7d [#11930] Updating javadoc 2025-02-25 20:23:07 -03:00
Thiago F. G. Albuquerque
998d84de6c [#11930] Converting to Kotlin 2025-02-25 18:56:12 -03:00
Thiago F. G. Albuquerque
76a02d5858 [#11930] Extracting to a separate file 2025-02-24 20:16:40 -03:00
Thiago F. G. Albuquerque
24bb71a23f [#11930] Making it more efficient: Reverse iteration + limit(50) + reverse 2025-02-24 19:22:36 -03:00
Stypox
49b71942ad
Fix style and add comment about null player 2025-02-24 14:21:05 +01:00
Thompson3142
c9ec257a5e
Ugly fix for broken text colors in dark mode (#12035)
* Ugly fix for broken text colors in dark mode

* Add comment for clarification

* Added error prevention

* Update app/src/main/java/org/schabi/newpipe/MainActivity.java

---------

Co-authored-by: Stypox <stypox@pm.me>
2025-02-21 09:38:58 +00:00
Thiago F. G. Albuquerque
b1f995a78c [#11930] Playlist with more than 50 items 2025-02-20 16:26:03 -03:00
Thiago F. G. Albuquerque
acac50a1d1 [#11930] Non-Youtube URLs should be ignored 2025-02-19 16:29:34 -03:00
Thiago F. G. Albuquerque
c6b87cd316 [#11930] Making CheckStyle happy 2025-02-18 20:59:13 -03:00
Thiago F. G. Albuquerque
94d4c21cc7 [#11930] @Test export_justUrls() 2025-02-18 17:47:22 -03:00
Stypox
a7a7dc5363
Handle player and player service separately
This is, again, a consequence of the commit "Drop some assumptions on how PlayerService is started and reused".
This commit notified VideoDetailFragment of player starting and stopping independently of the player.
Read the comments in the code changes for more information.
2025-02-18 19:27:46 +01:00
Stypox
126f4b0e30
Fix crash when closing video detail fragment
This bug started appearing because the way to close the player is now unified in PlayerHolder.stopService(), which causes the player to reach back to the video detail fragment with a notification of the shutdown (i.e. onServiceStopped() is called). This is fixed by adding a nullability check on the binding.
2025-02-18 18:03:10 +01:00
Stypox
6558794d26
Try to bind to PlayerService when MainActivity starts
Fixes mini-player not appearing on app start if the player service is already playing something.

The PlayerService (and the player) may be started from an external intent that does not involve the MainActivity (e.g. RouterActivity or Android Auto's media browser interface).
This PR tries to bind to the PlayerService as soon as the MainActivity starts, but only does so in a passive way, i.e. if the service is not already running it is not started.
Once the connection between PlayerHolder and PlayerService is setup, the ACTION_PLAYER_STARTED broadcast is sent to MainActivity so that it can setup the bottom mini-player.
Another important thing this commit does is to check whether the player is open before actually adding the mini-player view, since the PlayerService could be bound even without a running player (e.g. Android Auto's media browser is being used). This is a consequence of commit "Drop some assumptions on how PlayerService is started and reused".
2025-02-18 17:49:38 +01:00
Stypox
1d12874937
Merge pull request #12046 from TobiGr/weblate
Update translations
2025-02-16 22:01:41 +01:00
Stypox
1d98518bfa
Fix loading remote playlists in media browser 2025-02-16 21:44:50 +01:00
Stypox
e5458bcb14
Properly handle item errors during media browser loading
Non-item errors, i.e. critical parsing errors of the page, are still handled properly.
2025-02-16 21:44:50 +01:00
Stypox
dc62d211f5
Properly stop PlayerService
This commit is a consequence of the commit "Drop some assumptions on how PlayerService is started and reused". Since the assumptions on how the PlayerService is started and reused have changed, we also need to adapt the way it is stopped. This means allowing the service to remain alive even after the player is destroyed, in case the system is still accessing PlayerService e.g. through the media browser interface. The foreground service needs to be stopped and the notification removed in any case.
2025-02-16 21:44:49 +01:00
Stypox
ec6612dd71
Call exoPlayer.prepare() on PlaybackPreparer.onPrepare()
If a playbackPreparer is set, then instead of calling `player.prepare()`, the MediaSessionConnector will call `playbackPreparer.onPrepare(true)` instead, as seen below.
This commit makes it so that playbackPreparer.onPrepare(true) restores the original behavior of just calling player.prepare().

From MediaSessionConnector -> MediaSessionCompat.Callback implementation:
```java
    @Override
    public void onPlay() {
      if (canDispatchPlaybackAction(PlaybackStateCompat.ACTION_PLAY)) {
        if (player.getPlaybackState() == Player.STATE_IDLE) {
          if (playbackPreparer != null) {
            playbackPreparer.onPrepare(/* playWhenReady= */ true);
          } else {
            player.prepare();
          }
        } else if (player.getPlaybackState() == Player.STATE_ENDED) {
          seekTo(player, player.getCurrentMediaItemIndex(), C.TIME_UNSET);
        }
        Assertions.checkNotNull(player).play();
      }
    }
```
2025-02-16 21:44:49 +01:00
Stypox
064e1d39c7
Use the media browser implementation in PlayerService
Now the media browser queries are replied to by MediaBrowserImpl

Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:44:05 +01:00
Stypox
4c88a193bd
Add MediaBrowserImpl
This class implements the media browser service interface as a standalone class for clearer separation of concerns (otherwise everything would need to go in PlayerService, since PlayerService overrides MediaBrowserServiceCompat)

Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
Co-authored-by: Profpatsch <mail@profpatsch.de>
2025-02-16 21:43:46 +01:00
Stypox
3fcac10e7f
Add MediaBrowserPlaybackPreparer
This class will receive the media URLs generated by [MediaBrowserImpl] and will start playback of the corresponding streams or playlists.

Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
Co-authored-by: Profpatsch <mail@profpatsch.de>
2025-02-16 21:43:35 +01:00
Stypox
6cedd117fe
Add StreamHistoryEntry.toStreamInfoItem()
Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:40:55 +01:00
Stypox
5eabcb52b5
Add getThumbnailUrl() to PlaylistLocalItem interface
Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:40:48 +01:00
Stypox
690b40d0c4
Allow creating PlayQueue from ListInfo and index 2025-02-16 21:40:47 +01:00
Stypox
9bb2c0b484
Add getPlaylist(id) to RemotePlaylistManager
Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:40:36 +01:00
Stypox
1e08cc8c8f
Add MediaBrowserCommon with info item's and pages' IDs
Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:40:29 +01:00
Stypox
7d17468266
Instantiate media session and connector in PlayerService
This changes significantly how the MediaSessionCompat and MediaSessionConnector objects are used:
- now they are tied to the service and not to the player, and so they might be reused with multiple players (which should be allowed)
- now they can exist even if there is no player (which is fundamental to be able to answer media browser queries)
2025-02-16 21:40:13 +01:00
Stypox
5819546ea9
Have PlayerService implement MediaBrowserServiceCompat
Co-authored-by: Haggai Eran <haggai.eran@gmail.com>
2025-02-16 21:36:59 +01:00
Stypox
cfb6e114d6
Disable logs about view animations by default 2025-02-16 10:31:42 +01:00
Stypox
b764ad33c4
Drop some assumptions on how PlayerService is started and reused
Read the comments in the lines changed to understand more
2025-02-15 17:48:19 +01:00
Hosted Weblate
430b4eb916
Translated using Weblate (Persian)
Currently translated at 92.7% (686 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Georgian)

Currently translated at 83.3% (70 of 84 strings)

Translated using Weblate (Estonian)

Currently translated at 16.6% (14 of 84 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Mainfränkisch)

Currently translated at 1.0% (8 of 740 strings)

Translated using Weblate (Bavarian)

Currently translated at 3.9% (29 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (84 of 84 strings)

Added translation using Weblate (Mainfränkisch)

Translated using Weblate (Thai)

Currently translated at 36.6% (271 of 740 strings)

Translated using Weblate (Armenian)

Currently translated at 28.2% (209 of 740 strings)

Translated using Weblate (Georgian)

Currently translated at 85.7% (72 of 84 strings)

Translated using Weblate (Thai)

Currently translated at 34.3% (254 of 740 strings)

Translated using Weblate (Gujarati)

Currently translated at 11.3% (84 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Nepali)

Currently translated at 1.1% (1 of 84 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (French)

Currently translated at 100.0% (84 of 84 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Gujarati)

Currently translated at 11.0% (82 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (740 of 740 strings)

Co-authored-by: Alex25820 <alexs25820@gmail.com>
Co-authored-by: Bruno Fragoso <darth_signa@hotmail.com>
Co-authored-by: Davit Mayilyan <davit.mayilyan@protonmail.ch>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Garfield2150 <knd.garfield@gmail.com>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Goudarz Jafari <goudarz.jafari@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kchenik Poudel <Kakapoudel7@gmail.com>
Co-authored-by: Kuko <kuko7@protonmail.ch>
Co-authored-by: Paul Sibila <p.aul@mail.de>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: freddyLovesUs <compteperso@outlook.com>
Co-authored-by: રાજ ભાતેલીઆ <rajbhatelia@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/et/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ka/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ne/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sv/
Translation: NewPipe/Metadata
2025-02-15 13:08:00 +01:00
Thiago F. G. Albuquerque
2339f51ad4 [#11930] Share as YouTube temporary playlist
Initial commit.
2025-02-14 21:14:42 -03:00
Stypox
c6e1721884
Add translated changelogs for v0.27.6 (1003)
Copied from 1002.txt
2025-02-05 11:30:37 +01:00
Stypox
94684fe380
Merge branch 'weblate-dev' into dev 2025-02-05 11:29:14 +01:00
Hosted Weblate
398a2f55ce
Merge branch 'origin/dev' into Weblate. 2025-02-05 11:28:09 +01:00
Stypox
1f7b3b5b06
Add changelog for v0.27.6 (1003) 2025-02-05 11:25:58 +01:00
Stypox
909ed616c4
Hotfix release v0.27.6 (1003) 2025-02-05 11:14:17 +01:00
Stypox
dd223af28d
Merge pull request #11955 from Stypox/po-token
[YouTube] Add support for poTokens
2025-02-05 10:52:16 +01:00
Stypox
dbee8d8128
Update NewPipeExtractor to v0.24.5
Using commit 9f83b385a since JitPack is buggy...
2025-02-05 10:24:34 +01:00
Stypox
b62a09b5b3
Use WebSettingsCompat.setSafeBrowsingEnabled 2025-02-04 21:50:10 +01:00
Stypox
87317c6faf
Reorder functions in PoTokenWebView 2025-02-04 21:38:01 +01:00
Stypox
53b599b042
Make JavaScript code compatible with older WebViews 2025-02-04 21:38:01 +01:00
Stypox
21df24abfd
Detect when WebView is broken and return null poToken
Some old Android devices have a broken WebView implementation, that can't execute the poToken code. This is now detected and the getWebClientPoToken return null instead of throwing an error in such a case, to allow the extractor to try to extract the video data even without a poToken.
2025-02-04 11:22:50 +01:00
Hosted Weblate
ca4592a935
Translated using Weblate (Russian)
Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 13.2% (11 of 83 strings)

Translated using Weblate (Latin)

Currently translated at 8.6% (64 of 740 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (Turkish)

Currently translated at 48.1% (40 of 83 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 96.3% (80 of 83 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Vietnamese)

Currently translated at 78.3% (65 of 83 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (German)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (83 of 83 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (740 of 740 strings)

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

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (N’Ko)

Currently translated at 89.4% (662 of 740 strings)

Translated using Weblate (Norwegian Nynorsk)

Currently translated at 19.8% (147 of 740 strings)

Translated using Weblate (Georgian)

Currently translated at 89.1% (660 of 740 strings)

Translated using Weblate (Icelandic)

Currently translated at 99.0% (733 of 740 strings)

Translated using Weblate (Kurdish (Northern))

Currently translated at 65.4% (484 of 740 strings)

Translated using Weblate (Somali)

Currently translated at 75.1% (556 of 740 strings)

Translated using Weblate (Uzbek (Latin script))

Currently translated at 62.0% (459 of 740 strings)

Translated using Weblate (English (United Kingdom))

Currently translated at 7.4% (55 of 740 strings)

Translated using Weblate (Odia)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Santali)

Currently translated at 14.5% (108 of 740 strings)

Translated using Weblate (Bengali)

Currently translated at 76.7% (568 of 740 strings)

Translated using Weblate (Sardinian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Bengali (India))

Currently translated at 40.1% (297 of 740 strings)

Translated using Weblate (Kurdish (Central))

Currently translated at 84.0% (622 of 740 strings)

Translated using Weblate (Arabic (Libya))

Currently translated at 97.7% (723 of 740 strings)

Translated using Weblate (Malayalam)

Currently translated at 76.4% (566 of 740 strings)

Translated using Weblate (Interlingua)

Currently translated at 32.2% (239 of 740 strings)

Translated using Weblate (Filipino)

Currently translated at 31.3% (232 of 740 strings)

Translated using Weblate (Thai)

Currently translated at 30.0% (222 of 740 strings)

Translated using Weblate (Nepali)

Currently translated at 59.0% (437 of 740 strings)

Translated using Weblate (Danish)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Galician)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Malay)

Currently translated at 57.9% (429 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.0% (696 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Punjabi)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Albanian)

Currently translated at 79.8% (591 of 740 strings)

Translated using Weblate (Dutch (Belgium))

Currently translated at 75.1% (556 of 740 strings)

Translated using Weblate (Urdu)

Currently translated at 68.2% (505 of 740 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Catalan)

Currently translated at 87.0% (644 of 740 strings)

Translated using Weblate (Kurdish)

Currently translated at 63.7% (472 of 740 strings)

Translated using Weblate (Bulgarian)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Telugu)

Currently translated at 58.1% (430 of 740 strings)

Translated using Weblate (Hindi)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Finnish)

Currently translated at 97.9% (725 of 740 strings)

Translated using Weblate (Croatian)

Currently translated at 98.9% (732 of 740 strings)

Translated using Weblate (Vietnamese)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Lithuanian)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Hebrew)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Bengali (Bangladesh))

Currently translated at 54.3% (402 of 740 strings)

Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Asturian)

Currently translated at 63.3% (469 of 740 strings)

Translated using Weblate (Persian)

Currently translated at 92.4% (684 of 740 strings)

Translated using Weblate (Polish)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Turkish)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Ukrainian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Indonesian)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Czech)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Esperanto)

Currently translated at 71.4% (529 of 740 strings)

Translated using Weblate (Slovak)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Romanian)

Currently translated at 99.8% (739 of 740 strings)

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

Currently translated at 99.3% (735 of 740 strings)

Translated using Weblate (Basque)

Currently translated at 99.8% (739 of 740 strings)

Translated using Weblate (Italian)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (Korean)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Japanese)

Currently translated at 99.4% (736 of 740 strings)

Translated using Weblate (Russian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Serbian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Dutch)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (738 of 740 strings)

Translated using Weblate (German)

Currently translated at 99.5% (737 of 740 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Tamil)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (German)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Macedonian)

Currently translated at 6.0% (5 of 82 strings)

Translated using Weblate (Macedonian)

Currently translated at 80.6% (597 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (French)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Esperanto)

Currently translated at 71.7% (531 of 740 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Kabyle)

Currently translated at 28.7% (213 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 12.1% (10 of 82 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Gujarati)

Currently translated at 11.0% (82 of 740 strings)

Translated using Weblate (Azerbaijani)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 10.9% (9 of 82 strings)

Translated using Weblate (Danish)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 98.7% (81 of 82 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (French)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Undetermined)

Currently translated at 2.4% (2 of 82 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (82 of 82 strings)

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

Currently translated at 28.0% (23 of 82 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Dutch)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Gujarati)

Currently translated at 9.4% (70 of 740 strings)

Translated using Weblate (Estonian)

Currently translated at 9.7% (8 of 82 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Hungarian)

Currently translated at 74.3% (61 of 82 strings)

Translated using Weblate (Russian)

Currently translated at 98.7% (81 of 82 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (740 of 740 strings)

Translated using Weblate (Hungarian)

Currently translated at 64.6% (53 of 82 strings)

Translated using Weblate (Czech)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Russian)

Currently translated at 97.5% (80 of 82 strings)

Translated using Weblate (German)

Currently translated at 100.0% (82 of 82 strings)

Translated using Weblate (Bulgarian)

Currently translated at 100.0% (740 of 740 strings)

Co-authored-by: Agnieszka C <aga_04@o2.pl>
Co-authored-by: Alex25820 <alexs25820@gmail.com>
Co-authored-by: Andrey F <firsan777@mail.ru>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Anthony Romero <dagazcii@gmail.com>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: Bảo Nam (Namm) <namb20994@gmail.com>
Co-authored-by: C. Rüdinger <Mail-an-CR@web.de>
Co-authored-by: Ding User <dengus@users.noreply.hosted.weblate.org>
Co-authored-by: Emin Tufan Çetin <etcetin@gmail.com>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: GeoCup <geokapaniaris@gmail.com>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: H Tamás <hovanszki@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Igor Sorocean <sorocean.igor@gmail.com>
Co-authored-by: Ihor Hordiichuk <igor_ck@outlook.com>
Co-authored-by: Jaidyn Ann <jadedctrl@posteo.net>
Co-authored-by: Linerly <linerly@proton.me>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: MatthieuPh <matthieu.philippe@protonmail.com>
Co-authored-by: Mickaël Binos <mickaelbinos@outlook.com>
Co-authored-by: Miguel <mp0187595@tutamail.com>
Co-authored-by: Milan <mobrcian@hotmail.com>
Co-authored-by: NEXI <nexiphotographer@gmail.com>
Co-authored-by: Nicolas SALMIERI <1salmieri.nicolas@gmail.com>
Co-authored-by: NormalRandomPeople <normal.scribe833@silomails.com>
Co-authored-by: Philip Goto <philip.goto@gmail.com>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: Rex_sa <rex.sa@pm.me>
Co-authored-by: Rijolo <rijolo4790@gholar.com>
Co-authored-by: Sveinn í Felli <sv1@fellsnet.is>
Co-authored-by: Szia Tomi <sziatomi01@gmail.com>
Co-authored-by: TobiGr <TobiGr@users.noreply.github.com>
Co-authored-by: VfBFan <VfBFan@users.noreply.hosted.weblate.org>
Co-authored-by: VfBFan <drop0815@posteo.de>
Co-authored-by: VisionR1 <25982450+VisionR1@users.noreply.github.com>
Co-authored-by: Vtrytobe <vtrytobe@gmail.com>
Co-authored-by: cat <catsnote@proton.me>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: gfbdrgng <hnaofegnp@hldrive.com>
Co-authored-by: hajayad577 <hajayad577@numerobo.com>
Co-authored-by: jpkaster 77 <jpkaster81@gmail.com>
Co-authored-by: polarwood <wreckfitzgerald@proton.me>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: yummysheepouo <jerry88182821@gmail.com>
Co-authored-by: zmni <zmni@outlook.com>
Co-authored-by: Валентин Барсуков <valikbars04@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Co-authored-by: Максим Горпиніч <mgorpinic2005@gmail.com>
Co-authored-by: મેબીરાજ <rajbhatelia@gmail.com>
Co-authored-by: રાજ ભાતેલીઆ <rajbhatelia@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
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/cs/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/de/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/es/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/et/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/fr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/hu/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/id/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/it/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/mk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/pt_PT/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ru/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/sv/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/ta/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/tr/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/uk/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/und/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/vi/
Translate-URL: https://hosted.weblate.org/projects/newpipe/metadata/zh_Hant_HK/
Translation: NewPipe/Metadata
2025-02-04 10:59:39 +01:00
Stypox
3fc487310b
Use Runnable instead of () -> Unit if converted to Runnable anyway 2025-02-04 10:23:45 +01:00
Stypox
056809cb0d
Use "this" instead of "globalThis" as global scope
globalThis was introduced only on newer versions of JS
2025-02-04 10:22:10 +01:00
AudricV
a60bb3e7af
[YouTube] Change BotGuard endpoint to youtube.com's one
This prevents non-abilities to fetch BotGuard challenge and send its
result with the jnn-pa.googleapis.com domain (domain block like done
on Pi-hole lists or DNS servers).

That's what the official website uses to send the challenge execution
result, however it uses InnerTube to fetch the challenge. Embeds
still use the jnn-pa.googleapis.com domain.

Also rename the makeJnnPaGoogleapisRequest method appropriately.
2025-02-03 13:05:39 +01:00
AudricV
ecd3f6c2ee
[YouTube] Clarify BotGuard API key's origin and disable related Sonar warning 2025-02-01 15:40:16 +01:00
AudricV
70ff47b810
[YouTube] Get visitorData from the service to get valid responses 2025-02-01 15:39:07 +01:00
AudricV
b8e050f6c4
Adapt YoutubeHttpDataSource to extractor changes and improve requests
Always use POST requests and the same body that official HTML5 clients
use for a while.
2025-01-31 22:50:10 +01:00
AudricV
46d0bc1004
Update NewPipeExtractor 2025-01-31 22:28:08 +01:00
Stypox
e7fe84f2c7
Make sure downloadAndRunBotguard() is called after <script> loaded 2025-01-31 21:47:46 +01:00
Stypox
2b183a0576
Wrap logs in BuildConfig.DEBUG 2025-01-31 21:47:46 +01:00
Stypox
f856bd9306
Recreate poToken generator if current is broken
This will be tried only once, and afterwards an error will be thrown
2025-01-31 21:47:45 +01:00
Stypox
0066b322e1
Unify running on main thread 2025-01-31 21:47:45 +01:00
Stypox
3bdae81c0a
Fix checkstyle 2025-01-31 21:47:45 +01:00
Stypox
6010c4ea7f
Connect poToken generation to extractor 2025-01-31 21:47:45 +01:00
Stypox
690b3410e9
Interfaces for poTokens + WebView implementation 2025-01-31 21:47:44 +01:00
Profpatsch
ba86ce137b
Merge pull request #11969 from neosis91/dev
DownloaderImpl: Auto-close resources and simplify header setting
2025-01-31 15:56:39 +01:00
Bertrand Jaunet
410c01547c DownloaderImpl: Auto-close resources and simplify header setting
The headers should be overwritten in the same way, based on how
`.header` is the same as `.removeHeader().addHeader()`.

We weren’t closing the request resources after using them, potentially
leaking file handles. This will add autoclosing for both the request
and the body objects.
2025-01-31 12:36:27 +01:00
Stypox
47263f5254
Merge pull request #11959 from Stypox/fix-loading-stream-twice
Fix loading StreamInfo twice on first VideoDetailFragment opening
2025-01-27 14:56:51 +01:00
Stypox
01bf855015
Fix naming in VideoDetailFragment: video->stream, videoUrl->url 2025-01-27 14:52:35 +01:00
Profpatsch
ebf3008729
Merge pull request #11870 from TeamNewPipe/sidebar_donations
Add link to donation page on app drawer
2025-01-27 13:59:29 +01:00
Christian Schabesberger
33ecfb757e Sidebar: Add donation link to app drawer
This creates a donation link that leads to our donation page on the
NewPipe website.
2025-01-27 13:43:34 +01:00
Stypox
ffe26d882b
Fix loading StreamInfo twice on first VideoDetailFragment opening 2025-01-26 12:39:07 +01:00
Stypox
83f8141fe7
Merge pull request #11806 from Thompson3142/fix_subtitle_size
Fix caption sizes not being changed
2025-01-25 18:10:56 +01:00
Profpatsch
9253640fae
Merge pull request #11887 from Nikunj-Aggarwal/bg-iso-timestamp
Convert error report timestamps to ISO format
2025-01-23 19:51:18 +01:00
Stypox
8b5aa5cd9b
Merge branch 'master' into dev 2025-01-22 11:10:22 +01:00
Profpatsch
a955408053
Merge pull request #11928 from LeMeuble/bug-checksum-deleted-file
Fix the issue of getting the checksum of a removed file
2025-01-21 17:40:50 +01:00
Thompson3142
86203d6800 MainPlayer/PopupPlayer: Use system settings for subtitle size
This will use the exact subtitle sizes the user requested, both for
the main and the popup player. They will always be the same fraction
of the video, even if the popup player is resized.
2025-01-21 17:23:08 +01:00
Profpatsch
edd19641ac ErrorActivity: add Timestamp and Package/Service to markdown export
These were displayed in the UI, but not added into the markdown export
string.
2025-01-21 16:25:54 +01:00
Nikunj-Aggarwal
65749cbac0 ErrorActivity: Use a proper zoned ISO timestamp
Will have a timezone offset and be parsable as valid ISO8601
timestamp.

Also change the label in the UI to just say “Timestamp”
2025-01-21 16:24:07 +01:00
LeMeuble
658ddfc921 Fix issue of checksum for removed file 2025-01-16 10:46:25 +01:00
Stypox
f7d0fd545d
Merge pull request #11879 from tom93/pr/fix-image-minimizer-multiple-images
Fix image-minimizer on lines containing multiple images
2025-01-04 09:34:07 +01:00
Tom Levy
27e6be792f Fix image-minimizer on lines containing multiple images 2025-01-04 08:15:44 +00:00
Stypox
3fc0147f47
Merge pull request #11784 from Rishi2003Das/typo_change
Correct a Typo in Contributing.md
2024-12-17 10:42:34 +01:00
Rishi Das
c6b05c6094
Update CONTRIBUTING.md 2024-12-08 02:00:41 +05:30
Rishi Das
240a2fe36b
Update CONTRIBUTING.md 2024-12-08 02:00:04 +05:30
Rishi Das
de46e3abb3
Update CONTRIBUTING.md 2024-12-08 01:59:14 +05:30
Stypox
70748fa0bc
Use JDK 21 in build-release-apk.yml
See https://github.com/TeamNewPipe/NewPipe/issues/11754
2024-12-02 13:49:30 +01:00
552 changed files with 9874 additions and 2881 deletions

View file

@ -6,7 +6,7 @@ NewPipe contribution guidelines
## Crash reporting
Report crashes through the **automated crash report system** of NewPipe.
This way all the data needed for debugging is included in your bugreport for GitHub.
This way all the data needed for debugging is included in your bug report for GitHub.
You'll see *exactly* what is sent, be able to add **your comments**, and then send it.
## Issue reporting/feature requests

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'
cache: 'gradle'
- name: "Build release APK"

View file

@ -72,8 +72,8 @@ jobs:
- api-level: 21
target: default
arch: x86
- api-level: 33
target: google_apis # emulator API 33 only exists with Google APIs
- api-level: 35
target: default
arch: x86_64
permissions:
@ -111,6 +111,7 @@ jobs:
path: app/build/reports/androidTests/connected/**
sonar:
if: ${{ false }} # the key has expired and needs to be regenerated by the sonar admins
runs-on: ubuntu-latest
permissions:

View file

@ -32,12 +32,12 @@ module.exports = async ({github, context}) => {
}
// Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm;
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm;
// Check if we found something
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
|| REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody);
|| REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody);
if (!foundSimpleImages) {
console.log('Found no simple images to process');
return;
@ -52,7 +52,7 @@ module.exports = async ({github, context}) => {
// Try to find and replace the images with minimized ones
let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync);
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync);
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync);
if (!wasMatchModified) {
console.log('Nothing was modified. Skipping update');

View file

@ -1,20 +1,26 @@
<h3 align="center">We are planning to <i>rewrite</i> large chunks of the codebase, to bring about <a href="https://github.com/TeamNewPipe/NewPipe/discussions/10118">a new, modern and stable NewPipe</a>!</h3>
<h4 align="center">Please do <b>not</b> open pull requests for <i>new features</i> now, only bugfix PRs will be accepted.</h4>
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
<h2 align="center"><b>NewPipe</b></h2>
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" height=80/></a></p>
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
<p align="center">
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub release"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a>
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
</p>
<p align="center">
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
</p>
<hr>
<p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#supported-services">Supported Services</a> &bull; <a href="#description">Description</a> &bull; <a href="#features">Features</a> &bull; <a href="#installation-and-updates">Installation and updates</a> &bull; <a href="#contribution">Contribution</a> &bull; <a href="#donate">Donate</a> &bull; <a href="#license">License</a></p>
<p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p>

View file

@ -12,20 +12,20 @@ plugins {
}
android {
compileSdk 34
compileSdk 36
namespace 'org.schabi.newpipe'
defaultConfig {
applicationId "org.schabi.newpipe"
resValue "string", "app_name", "NewPipe"
minSdk 21
targetSdk 33
targetSdk 35
if (System.properties.containsKey('versionCodeOverride')) {
versionCode System.getProperty('versionCodeOverride') as Integer
} else {
versionCode 1002
versionCode 1005
}
versionName "0.27.5"
versionName "0.28.0"
if (System.properties.containsKey('versionNameSuffix')) {
versionNameSuffix System.getProperty('versionNameSuffix')
}
@ -97,6 +97,10 @@ android {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
androidResources {
generateLocaleConfig = true
}
buildFeatures {
viewBinding true
buildConfig true
@ -205,10 +209,12 @@ dependencies {
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
// name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:nanojson:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
// the corresponding commit hash, since JitPack is sometimes buggy
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.4'
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
// If theres already a git hash, just add more of it to the end (or remove a letter)
// to cause jitpack to regenerate the artifact.
implementation 'com.github.TeamNewPipe:NewPipeExtractor:0023b22095a2d62a60cdfc87f4b5cd85c8b266c3'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
@ -219,7 +225,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0'
@ -241,6 +247,7 @@ dependencies {
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.11.0'
implementation "androidx.webkit:webkit:1.9.0"
/** Third-party libraries **/
// Instance state boilerplate elimination
@ -273,7 +280,7 @@ dependencies {
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
// Image loading
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
//noinspection NewerVersionAvailable,GradleDependency --> 2.8 is the last version, not 2.71828!
implementation "com.squareup.picasso:picasso:2.8"
// Markdown library for Android

View file

@ -5,10 +5,17 @@
## Rules for NewPipeExtractor
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
## Rules for Rhino and Rhino Engine
-keep class org.mozilla.javascript.* { *; }
-keep class org.mozilla.javascript.** { *; }
-keep class org.mozilla.javascript.engine.** { *; }
-keep class org.mozilla.classfile.ClassFileWriter
-dontwarn org.mozilla.javascript.JavaToJSONConverters
-dontwarn org.mozilla.javascript.tools.**
-keep class javax.script.** { *; }
-dontwarn javax.script.**
-keep class jdk.dynalink.** { *; }
-dontwarn jdk.dynalink.**
## Rules for ExoPlayer
-keep class com.google.android.exoplayer2.** { *; }

View file

@ -12,6 +12,7 @@ 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;
@ -23,8 +24,23 @@ import static org.junit.Assert.assertTrue;
@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() {
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:
@ -39,7 +55,7 @@ public class ErrorInfoTest {
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
parcel.recycle();
}

View file

@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- We need to be able to open links in the browser on API 30+ -->
@ -57,6 +59,15 @@
</intent-filter>
</receiver>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<service
android:name=".player.PlayerService"
android:exported="true"
@ -64,6 +75,9 @@
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<activity
@ -82,9 +96,22 @@
android:exported="false"
android:label="@string/title_activity_about" />
<service android:name=".local.subscription.services.SubscriptionsImportService" />
<service android:name=".local.subscription.services.SubscriptionsExportService" />
<service android:name=".local.feed.service.FeedLoadService" />
<service
android:name=".local.subscription.services.SubscriptionsImportService"
android:foregroundServiceType="dataSync" />
<service
android:name=".local.subscription.services.SubscriptionsExportService"
android:foregroundServiceType="dataSync" />
<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"
@ -116,7 +143,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
android:name=".util.FilePickerActivityHelper"
@ -409,6 +437,7 @@
</activity>
<service
android:name=".RouterActivity$FetcherService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- opting out of sending metrics to Google in Android System WebView -->
@ -424,5 +453,10 @@
<meta-data
android:name="com.samsung.android.multidisplay.keep_process_alive"
android:value="true" />
<!-- Android Auto -->
<meta-data android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@mipmap/ic_launcher" />
</application>
</manifest>

View file

@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
function loadBotGuard(challengeData) {
this.vm = this[challengeData.globalName];
this.program = challengeData.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');
if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');
const vmFunctionsCallback = function (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
passEventFunction: passEventFunction,
checkCameraFunction: checkCameraFunction
};
};
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
return new Promise(function (resolve, reject) {
i = 0
refreshIntervalId = setInterval(function () {
if (!!this.vmFunctions.asyncSnapshotFunction) {
resolve(this)
clearInterval(refreshIntervalId);
}
if (i >= 10000) {
reject("asyncSnapshotFunction is null even after 10 seconds")
clearInterval(refreshIntervalId);
}
i += 1;
}, 1);
})
}
/**
* Takes a snapshot asynchronously.
* @returns The snapshot result.
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
function snapshot(args) {
return new Promise(function (resolve, reject) {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
function runBotGuard(challengeData) {
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
const webPoSignalOutput = [];
return loadBotGuard({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
}).then(function (botguard) {
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
}).then(function (botguardResponse) {
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
})
}
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
const getMinter = webPoSignalOutput[0];
if (!getMinter)
throw new Error('PMD:Undefined');
const mintCallback = getMinter(integrityToken);
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
const result = mintCallback(identifier);
if (!result)
throw new Error('YNJ:Undefined');
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');
return result;
}
</script></head><body></body></html>

View file

@ -17,6 +17,7 @@ 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;
@ -26,6 +27,7 @@ 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;
@ -100,7 +102,7 @@ public class App extends Application {
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
Localization.initPrettyTime(Localization.resolvePrettyTime());
BridgeStateSaverInitializer.init(this);
StateSaver.init(this);
@ -118,6 +120,8 @@ public class App extends Application {
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler();
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
}
@Override

View file

@ -29,7 +29,7 @@ import okhttp3.ResponseBody;
public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
@ -137,7 +137,8 @@ public final class DownloaderImpl extends Downloader {
}
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
.method(httpMethod, requestBody)
.url(url)
.addHeader("User-Agent", USER_AGENT);
final String cookies = getCookies(url);
@ -145,38 +146,33 @@ public final class DownloaderImpl extends Downloader {
requestBuilder.addHeader("Cookie", cookies);
}
for (final Map.Entry<String, List<String>> pair : headers.entrySet()) {
final String headerName = pair.getKey();
final List<String> headerValueList = pair.getValue();
headers.forEach((headerName, headerValueList) -> {
requestBuilder.removeHeader(headerName);
headerValueList.forEach(headerValue ->
requestBuilder.addHeader(headerName, headerValue));
});
if (headerValueList.size() > 1) {
requestBuilder.removeHeader(headerName);
for (final String headerValue : headerValueList) {
requestBuilder.addHeader(headerName, headerValue);
}
} else if (headerValueList.size() == 1) {
requestBuilder.header(headerName, headerValueList.get(0));
try (
okhttp3.Response response = client.newCall(requestBuilder.build()).execute()
) {
if (response.code() == 429) {
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
String responseBodyToReturn = null;
try (ResponseBody body = response.body()) {
if (body != null) {
responseBodyToReturn = body.string();
}
}
final String latestUrl = response.request().url().toString();
return new Response(
response.code(),
response.message(),
response.headers().toMultimap(),
responseBodyToReturn,
latestUrl);
}
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
if (response.code() == 429) {
response.close();
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
final ResponseBody body = response.body();
String responseBodyToReturn = null;
if (body != null) {
responseBodyToReturn = body.string();
}
final String latestUrl = response.request().url().toString();
return new Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl);
}
}

View file

@ -20,8 +20,6 @@
package org.schabi.newpipe;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@ -38,6 +36,7 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebView;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.FrameLayout;
@ -49,6 +48,7 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
@ -80,6 +80,7 @@ import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.settings.UpdateSettingsFragment;
import org.schabi.newpipe.settings.migration.MigrationManager;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.KioskTranslator;
@ -92,6 +93,7 @@ import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.ArrayList;
@ -120,10 +122,14 @@ public class MainActivity extends AppCompatActivity {
private static final int ITEM_ID_DOWNLOADS = -4;
private static final int ITEM_ID_HISTORY = -5;
private static final int ITEM_ID_SETTINGS = 0;
private static final int ITEM_ID_ABOUT = 1;
private static final int ITEM_ID_DONATION = 1;
private static final int ITEM_ID_ABOUT = 2;
private static final int ORDER = 0;
public static final String KEY_IS_IN_BACKGROUND = "is_in_background";
private SharedPreferences sharedPreferences;
private SharedPreferences.Editor sharedPrefEditor;
/*//////////////////////////////////////////////////////////////////////////
// Activity's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@ -135,11 +141,26 @@ public class MainActivity extends AppCompatActivity {
+ "savedInstanceState = [" + savedInstanceState + "]");
}
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
ThemeHelper.setDayNightMode(this);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
assureCorrectAppLanguage(this);
// Fixes text color turning black in dark/black mode:
// https://github.com/TeamNewPipe/NewPipe/issues/12016
// For further reference see: https://issuetracker.google.com/issues/37124582
if (DeviceUtils.supportsWebView()) {
try {
new WebView(this);
} catch (final Throwable e) {
if (DEBUG) {
Log.e(TAG, "Failed to create WebView", e);
}
}
}
super.onCreate(savedInstanceState);
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
sharedPrefEditor = sharedPreferences.edit();
mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
drawerLayoutBinding = mainBinding.drawerLayout;
@ -174,6 +195,8 @@ public class MainActivity extends AppCompatActivity {
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
MigrationManager.showUserInfoIfPresent(this);
}
@Override
@ -181,16 +204,29 @@ public class MainActivity extends AppCompatActivity {
super.onPostCreate(savedInstanceState);
final App app = App.getApp();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
&& sharedPreferences
.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
}
}
@Override
protected void onStart() {
super.onStart();
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply();
Log.d(TAG, "App moved to foreground");
}
@Override
protected void onStop() {
super.onStop();
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply();
Log.d(TAG, "App moved to background");
}
private void setupDrawer() throws ExtractionException {
addDrawerMenuForCurrentService();
@ -228,19 +264,6 @@ public class MainActivity extends AppCompatActivity {
*/
private void addDrawerMenuForCurrentService() throws ExtractionException {
//Tabs
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
final StreamingService service = NewPipe.getService(currentServiceId);
int kioskMenuItemId = 0;
for (final String ks : service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
.getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks));
kioskMenuItemId++;
}
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
R.string.tab_subscriptions)
@ -258,10 +281,28 @@ public class MainActivity extends AppCompatActivity {
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
.setIcon(R.drawable.ic_history);
//Kiosks
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
final StreamingService service = NewPipe.getService(currentServiceId);
int kioskMenuItemId = 0;
for (final String ks : service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator
.getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks));
kioskMenuItemId++;
}
//Settings and About
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
.setIcon(R.drawable.ic_settings);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER,
R.string.donation_title)
.setIcon(R.drawable.volunteer_activism_ic);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
.setIcon(R.drawable.ic_info_outline);
@ -273,10 +314,13 @@ public class MainActivity extends AppCompatActivity {
changeService(item);
break;
case R.id.menu_tabs_group:
tabSelected(item);
break;
case R.id.menu_kiosks_group:
try {
tabSelected(item);
kioskSelected(item);
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
}
break;
case R.id.menu_options_about_group:
@ -300,7 +344,7 @@ public class MainActivity extends AppCompatActivity {
.setChecked(true);
}
private void tabSelected(final MenuItem item) throws ExtractionException {
private void tabSelected(final MenuItem item) {
switch (item.getItemId()) {
case ITEM_ID_SUBSCRIPTIONS:
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
@ -317,18 +361,19 @@ public class MainActivity extends AppCompatActivity {
case ITEM_ID_HISTORY:
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break;
default:
final StreamingService currentService = ServiceHelper.getSelectedService(this);
int kioskMenuItemId = 0;
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
if (kioskMenuItemId == item.getItemId()) {
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
currentService.getServiceId(), kioskId);
break;
}
kioskMenuItemId++;
}
}
}
private void kioskSelected(final MenuItem item) throws ExtractionException {
final StreamingService currentService = ServiceHelper.getSelectedService(this);
int kioskMenuItemId = 0;
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
if (kioskMenuItemId == item.getItemId()) {
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
currentService.getServiceId(), kioskId);
break;
}
kioskMenuItemId++;
}
}
@ -337,6 +382,9 @@ public class MainActivity extends AppCompatActivity {
case ITEM_ID_SETTINGS:
NavigationHelper.openSettings(this);
break;
case ITEM_ID_DONATION:
ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url));
break;
case ITEM_ID_ABOUT:
NavigationHelper.openAbout(this);
break;
@ -366,6 +414,7 @@ public class MainActivity extends AppCompatActivity {
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group);
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
// Show up or down arrow
@ -459,9 +508,8 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onResume() {
assureCorrectAppLanguage(this);
// Change the date format to match the selected language on resume
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
Localization.initPrettyTime(Localization.resolvePrettyTime());
super.onResume();
// Close drawer on return, and don't show animation,
@ -483,13 +531,11 @@ public class MainActivity extends AppCompatActivity {
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
}
final SharedPreferences sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(this);
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "Theme has changed, recreating activity...");
}
sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
ActivityCompat.recreate(this);
}
@ -497,7 +543,7 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) {
Log.d(TAG, "main page has changed, recreating main fragment...");
}
sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
NavigationHelper.openMainActivity(this);
}
@ -839,7 +885,8 @@ public class MainActivity extends AppCompatActivity {
@Override
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
VideoDetailFragment.ACTION_PLAYER_STARTED)
&& PlayerHolder.getInstance().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.
@ -850,7 +897,12 @@ public class MainActivity extends AppCompatActivity {
};
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
registerReceiver(broadcastReceiver, intentFilter);
ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter,
ContextCompat.RECEIVER_EXPORTED);
// 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);
}
}
@ -924,4 +976,5 @@ public class MainActivity extends AppCompatActivity {
return sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
}
}

View file

@ -58,20 +58,10 @@ import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.StreamingService.LinkType;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
import org.schabi.newpipe.extractor.exceptions.PaidContentException;
import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
@ -84,7 +74,6 @@ import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants;
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.PermissionHelper;
import org.schabi.newpipe.util.ThemeHelper;
@ -132,7 +121,6 @@ public class RouterActivity extends AppCompatActivity {
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
// Pass-through touch events to background activities
// so that our transparent window won't lock UI in the mean time
@ -262,7 +250,8 @@ public class RouterActivity extends AppCompatActivity {
showUnsupportedUrlDialog(url);
}
}, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
null, url))));
}
/**
@ -271,40 +260,19 @@ public class RouterActivity extends AppCompatActivity {
* @param errorInfo the error information
*/
private static void handleError(final Context context, final ErrorInfo errorInfo) {
if (errorInfo.getThrowable() != null) {
errorInfo.getThrowable().printStackTrace();
}
if (errorInfo.getThrowable() instanceof ReCaptchaException) {
if (errorInfo.getRecaptchaUrl() != null) {
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
// Starting ReCaptcha Challenge Activity
final Intent intent = new Intent(context, ReCaptchaActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
context.startActivity(intent);
} else if (errorInfo.getThrowable() != null
&& ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
Toast.makeText(context, R.string.restricted_video_no_stream,
Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof PaidContentException) {
Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof PrivateContentException) {
Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
Toast.makeText(context, R.string.soundcloud_go_plus_content,
Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
Toast.makeText(context, R.string.youtube_music_premium_content,
Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
} else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
} else {
} else if (errorInfo.isReportable()) {
ErrorUtil.createNotification(context, errorInfo);
} else {
// this exception does not usually indicate a problem that should be reported,
// so just show a toast instead of the notification
Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
}
if (context instanceof RouterActivity) {
@ -667,7 +635,8 @@ public class RouterActivity extends AppCompatActivity {
startActivity(intent);
finish();
}, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
null, currentUrl)))
);
return;
}
@ -854,10 +823,10 @@ public class RouterActivity extends AppCompatActivity {
})
)),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
throwable,
UserAction.REQUESTED_STREAM,
throwable, UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
((RouterActivity) ctx).currentService.getServiceId())
((RouterActivity) ctx).currentService.getServiceId(),
currentUrl)
))
)
);
@ -997,7 +966,7 @@ public class RouterActivity extends AppCompatActivity {
}
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
choice.url + " opened with " + choice.playerChoice,
choice.serviceId)));
choice.serviceId, choice.url)));
}
}

View file

@ -16,14 +16,12 @@ import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityAboutBinding
import org.schabi.newpipe.databinding.FragmentAboutBinding
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Localization.assureCorrectAppLanguage(this)
super.onCreate(savedInstanceState)
ThemeHelper.setTheme(this)
title = getString(R.string.title_activity_about)

View file

@ -19,7 +19,6 @@ 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.Localization
import org.schabi.newpipe.util.external_communication.ShareUtils
/**
@ -100,7 +99,6 @@ class LicenseFragment : Fragment() {
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
Localization.assureCorrectAppLanguage(context)
val builder = AlertDialog.Builder(requireContext())
.setTitle(softwareComponent.name)
.setView(webView)

View file

@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model
import androidx.room.ColumnInfo
import androidx.room.Embedded
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(
@ -27,4 +29,17 @@ data class StreamHistoryEntry(
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
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)
}
}

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.database.playlist;
import androidx.annotation.Nullable;
import org.schabi.newpipe.database.LocalItem;
public interface PlaylistLocalItem extends LocalItem {
@ -10,4 +12,7 @@ public interface PlaylistLocalItem extends LocalItem {
long getUid();
void setDisplayIndex(long displayIndex);
@Nullable
String getThumbnailUrl();
}

View file

@ -9,6 +9,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
import androidx.annotation.Nullable;
public class PlaylistMetadataEntry implements PlaylistLocalItem {
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
@ -71,4 +73,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
@Nullable
@Override
public String getThumbnailUrl() {
return thumbnailUrl;
}
}

View file

@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.model;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
@ -134,6 +135,8 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.name = name;
}
@Nullable
@Override
public String getThumbnailUrl() {
return thumbnailUrl;
}

View file

@ -20,8 +20,6 @@ import org.schabi.newpipe.views.FocusOverlayView;
import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.ui.fragment.MissionsFragment;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class DownloadActivity extends AppCompatActivity {
private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag";
@ -33,7 +31,6 @@ public class DownloadActivity extends AppCompatActivity {
i.setClass(this, DownloadManagerService.class);
startService(i);
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
super.onCreate(savedInstanceState);

View file

@ -2,7 +2,6 @@ package org.schabi.newpipe.download;
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.ComponentName;
@ -390,8 +389,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size",
currentInfo.getServiceId()))));
"Downloading video stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -400,8 +398,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size",
currentInfo.getServiceId()))));
"Downloading audio stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -410,8 +407,7 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading subtitle stream size",
currentInfo.getServiceId()))));
"Downloading subtitle stream size", currentInfo))));
}
private void setupAudioTrackSpinner() {
@ -751,7 +747,6 @@ public class DownloadDialog extends DialogFragment
}
private void showFailedDialog(@StringRes final int msg) {
assureCorrectAppLanguage(requireContext());
new AlertDialog.Builder(context)
.setTitle(R.string.general_error)
.setMessage(msg)

View file

@ -36,8 +36,8 @@ public class AcraReportSender implements ReportSender {
ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)},
UserAction.UI_ERROR,
ErrorInfo.SERVICE_NONE,
"ACRA report",
null,
R.string.app_ui_crash));
}
}

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.error;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
@ -26,7 +24,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.stream.Collectors;
@ -67,10 +65,6 @@ public class ErrorActivity extends AppCompatActivity {
public static final String ERROR_GITHUB_ISSUE_URL =
"https://github.com/TeamNewPipe/NewPipe/issues";
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private ErrorInfo errorInfo;
private String currentTimeStamp;
@ -83,7 +77,6 @@ public class ErrorActivity extends AppCompatActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
ThemeHelper.setDayNightMode(this);
@ -107,7 +100,9 @@ public class ErrorActivity extends AppCompatActivity {
// important add guru meditation
addGuruMeditation();
currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now());
// print current time, as zoned ISO8601 timestamp
final ZonedDateTime now = ZonedDateTime.now();
currentTimeStamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
activityErrorBinding.errorReportEmailButton.setOnClickListener(v ->
openPrivacyPolicyDialog(this, "EMAIL"));
@ -120,7 +115,7 @@ public class ErrorActivity extends AppCompatActivity {
// normal bugreport
buildInfo(errorInfo);
activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
// print stack trace once again for debugging:
@ -250,6 +245,9 @@ public class ErrorActivity extends AppCompatActivity {
.append("\n* __Content Language:__ ").append(getContentLanguageString())
.append("\n* __App Language:__ ").append(getAppLanguage())
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
.append("\n* __Timestamp:__ ").append(currentTimeStamp)
.append("\n* __Package:__ ").append(getPackageName())
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
.append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME)
.append("\n* __OS:__ ").append(getOsString()).append("\n");
@ -305,7 +303,7 @@ public class ErrorActivity extends AppCompatActivity {
}
private String getAppLanguage() {
return Localization.getAppLocale(getApplicationContext()).toString();
return Localization.getAppLocale().toString();
}
private String getOsString() {

View file

@ -1,115 +1,304 @@
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 kotlinx.parcelize.IgnoredOnParcel
import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.Loader
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
import org.schabi.newpipe.extractor.exceptions.PaidContentException
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.player.mediasource.FailedMediaSource
import org.schabi.newpipe.player.resolver.PlaybackResolver
import java.net.UnknownHostException
/**
* An error has occurred in the app. This class contains plain old parcelable data that can be used
* to report the error and to show it to the user along with correct action buttons.
*/
@Parcelize
class ErrorInfo(
class ErrorInfo private constructor(
val stackTraces: Array<String>,
val userAction: UserAction,
val serviceName: String,
val request: String,
val messageStringId: Int
val serviceId: Int?,
private val message: ErrorMessage,
/**
* If `true`, a report button will be shown for this error. Otherwise the error is not something
* that can really be reported (e.g. a network issue, or content not being available at all).
*/
val isReportable: Boolean,
/**
* If `true`, the process causing this error can be retried, otherwise not.
*/
val isRetryable: Boolean,
/**
* If present, indicates that the exception was a ReCaptchaException, and this is the URL
* provided by the service that can be used to solve the ReCaptcha challenge.
*/
val recaptchaUrl: String?,
/**
* If present, this resource can alternatively be opened in browser (useful if NewPipe is
* badly broken).
*/
val openInBrowserUrl: String?,
) : Parcelable {
// no need to store throwable, all data for report is in other variables
// also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
@IgnoredOnParcel
var throwable: Throwable? = null
private constructor(
@JvmOverloads
constructor(
throwable: Throwable,
userAction: UserAction,
serviceName: String,
request: String
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
) : this(
throwableToStringList(throwable),
userAction,
serviceName,
request,
getMessageStringId(throwable, userAction)
) {
this.throwable = throwable
}
serviceId,
getMessage(throwable, userAction, serviceId),
isReportable(throwable),
isRetryable(throwable),
(throwable as? ReCaptchaException)?.url,
openInBrowserUrl,
)
private constructor(
throwable: List<Throwable>,
@JvmOverloads
constructor(
throwables: List<Throwable>,
userAction: UserAction,
serviceName: String,
request: String
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
) : this(
throwableListToStringList(throwable),
throwableListToStringList(throwables),
userAction,
serviceName,
request,
getMessageStringId(throwable.firstOrNull(), userAction)
) {
this.throwable = throwable.firstOrNull()
serviceId,
getMessage(throwables.firstOrNull(), userAction, serviceId),
throwables.any(::isReportable),
throwables.isEmpty() || throwables.any(::isRetryable),
throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
openInBrowserUrl,
)
// constructor to manually build ErrorInfo when no throwable is available
constructor(
stackTraces: Array<String>,
userAction: UserAction,
request: String,
serviceId: Int?,
@StringRes message: Int
) :
this(
stackTraces, userAction, request, serviceId, ErrorMessage(message),
true, false, null, null
)
// constructor with only one throwable to extract service id and openInBrowserUrl from an Info
constructor(
throwable: Throwable,
userAction: UserAction,
request: String,
info: Info?,
) :
this(throwable, userAction, request, info?.serviceId, info?.url)
// constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
constructor(
throwables: List<Throwable>,
userAction: UserAction,
request: String,
info: Info?,
) :
this(throwables, userAction, request, info?.serviceId, info?.url)
fun getServiceName(): String {
return getServiceName(serviceId)
}
// constructors with single throwable
constructor(throwable: Throwable, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request)
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request)
// constructors with list of throwables
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request)
fun getMessage(context: Context): String {
return message.getString(context)
}
companion object {
const val SERVICE_NONE = "none"
@Parcelize
class ErrorMessage(
@StringRes
private val stringRes: Int,
private vararg val formatArgs: String,
) : Parcelable {
fun getString(context: Context): String {
return if (formatArgs.isEmpty()) {
// use ContextCompat.getString() just in case context is not AppCompatActivity
ContextCompat.getString(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)
}
}
}
const val SERVICE_NONE = "<unknown_service>"
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()
private fun getInfoServiceName(info: Info?) =
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
@StringRes
private fun getMessageStringId(
fun getMessage(
throwable: Throwable?,
action: UserAction
): Int {
action: UserAction?,
serviceId: Int?,
): ErrorMessage {
return when {
throwable is AccountTerminatedException -> R.string.account_terminated
throwable is ContentNotAvailableException -> R.string.content_not_available
throwable != null && throwable.isNetworkRelated -> R.string.network_error
throwable is ContentNotSupportedException -> R.string.content_not_supported
throwable is ExtractionException -> R.string.parsing_error
// player exceptions
// some may be IOException, so do these checks before isNetworkRelated!
throwable is ExoPlaybackException -> {
when (throwable.type) {
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
else -> R.string.player_unrecoverable_failure
val cause = throwable.cause
when {
cause is HttpDataSource.InvalidResponseCodeException -> {
if (cause.responseCode == 403) {
if (serviceId == YouTube.serviceId) {
ErrorMessage(R.string.youtube_player_http_403)
} else {
ErrorMessage(R.string.player_http_403)
}
} else {
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)
}
}
action == UserAction.UI_ERROR -> R.string.app_ui_crash
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
else -> R.string.general_error
throwable is FailedMediaSource.FailedMediaSourceException ->
getMessage(throwable.cause, action, serviceId)
throwable is PlaybackResolver.ResolverException ->
ErrorMessage(R.string.player_stream_failure)
// content not available exceptions
throwable is AccountTerminatedException ->
throwable.message
?.takeIf { reason -> !reason.isEmpty() }
?.let { reason ->
ErrorMessage(
R.string.account_terminated_service_provides_reason,
getServiceName(serviceId),
reason
)
}
?: 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 ->
ErrorMessage(R.string.parsing_error)
// 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)
}
}
fun isReportable(throwable: Throwable?): Boolean {
return when (throwable) {
// 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
// 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
}
}
fun isRetryable(throwable: Throwable?): Boolean {
return when (throwable) {
// we know the content is not available, retrying won't help
is ContentNotAvailableException -> false
// 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
}
}
}

View file

@ -2,7 +2,6 @@ package org.schabi.newpipe.error
import android.content.Context
import android.content.Intent
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.TextView
@ -14,28 +13,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
import org.schabi.newpipe.extractor.exceptions.PaidContentException
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.isInterruptedCaused
import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper
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!!
@ -56,12 +41,15 @@ class ErrorPanelHelper(
errorPanelRoot.findViewById(R.id.error_open_in_browser)
private var errorDisposable: Disposable? = null
private var retryShouldBeShown: Boolean = (onRetry != null)
init {
errorDisposable = errorRetryButton.clicks()
.debounce(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onRetry.run() }
if (onRetry != null) {
errorDisposable = errorRetryButton.clicks()
.debounce(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onRetry.run() }
}
}
private fun ensureDefaultVisibility() {
@ -75,64 +63,32 @@ class ErrorPanelHelper(
}
fun showError(errorInfo: ErrorInfo) {
if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
if (DEBUG) {
Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
}
return
}
ensureDefaultVisibility()
errorTextView.text = errorInfo.getMessage(context)
if (errorInfo.throwable is ReCaptchaException) {
errorTextView.setText(R.string.recaptcha_request_toast)
showAndSetErrorButtonAction(
R.string.recaptcha_solve
) {
if (errorInfo.recaptchaUrl != null) {
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
// Starting ReCaptcha Challenge Activity
val intent = Intent(context, ReCaptchaActivity::class.java)
intent.putExtra(
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
(errorInfo.throwable as ReCaptchaException).url
)
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
errorActionButton.setOnClickListener(null)
}
errorRetryButton.isVisible = true
showAndSetOpenInBrowserButtonAction(errorInfo)
} else if (errorInfo.throwable is AccountTerminatedException) {
errorTextView.setText(R.string.account_terminated)
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
errorServiceInfoTextView.text = context.resources.getString(
R.string.service_provides_reason,
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
)
errorServiceInfoTextView.isVisible = true
errorServiceExplanationTextView.text =
(errorInfo.throwable as AccountTerminatedException).message
errorServiceExplanationTextView.isVisible = true
}
} else {
showAndSetErrorButtonAction(
R.string.error_snackbar_action
) {
} else if (errorInfo.isReportable) {
showAndSetErrorButtonAction(R.string.error_snackbar_action) {
ErrorUtil.openActivity(context, errorInfo)
}
}
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
if (errorInfo.isRetryable) {
errorRetryButton.isVisible = retryShouldBeShown
}
if (errorInfo.throwable !is ContentNotAvailableException &&
errorInfo.throwable !is ContentNotSupportedException
) {
// show retry button only for content which is not unavailable or unsupported
errorRetryButton.isVisible = true
if (errorInfo.openInBrowserUrl != null) {
errorOpenInBrowserButton.isVisible = true
errorOpenInBrowserButton.setOnClickListener {
ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
}
showAndSetOpenInBrowserButtonAction(errorInfo)
}
setRootVisible()
@ -150,15 +106,6 @@ class ErrorPanelHelper(
errorActionButton.setOnClickListener(listener)
}
fun showAndSetOpenInBrowserButtonAction(
errorInfo: ErrorInfo
) {
errorOpenInBrowserButton.isVisible = true
errorOpenInBrowserButton.setOnClickListener {
ShareUtils.openUrlInBrowser(context, errorInfo.request)
}
}
fun showTextError(errorString: String) {
ensureDefaultVisibility()
@ -189,27 +136,5 @@ class ErrorPanelHelper(
companion object {
val TAG: String = ErrorPanelHelper::class.simpleName!!
val DEBUG: Boolean = MainActivity.DEBUG
@StringRes
fun getExceptionDescription(throwable: Throwable?): Int {
return when (throwable) {
is AgeRestrictedContentException -> R.string.restricted_video_no_stream
is GeographicRestrictionException -> R.string.georestricted_content
is PaidContentException -> R.string.paid_content
is PrivateContentException -> R.string.private_content
is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
is ContentNotAvailableException -> R.string.content_not_available
is ContentNotSupportedException -> R.string.content_not_supported
else -> {
// show retry button only for content which is not unavailable or unsupported
if (throwable != null && throwable.isNetworkRelated) {
R.string.network_error
} else {
R.string.error_snackbar_message
}
}
}
}
}
}

View file

@ -10,8 +10,11 @@ import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
/**
@ -35,12 +38,20 @@ class ErrorUtil {
* activity (since the workflow would be interrupted anyway in that case). So never use this
* for background services.
*
* If the crashed occurred while the app was in the background open a notification instead
*
* @param context the context to use to start the new activity
* @param errorInfo the error info to be reported
*/
@JvmStatic
fun openActivity(context: Context, errorInfo: ErrorInfo) {
context.startActivity(getErrorActivityIntent(context, errorInfo))
if (PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
) {
createNotification(context, errorInfo)
} else {
context.startActivity(getErrorActivityIntent(context, errorInfo))
}
}
/**
@ -111,7 +122,7 @@ class ErrorUtil {
)
.setSmallIcon(R.drawable.ic_bug_report)
.setContentTitle(context.getString(R.string.error_report_notification_title))
.setContentText(context.getString(errorInfo.messageStringId))
.setContentText(errorInfo.getMessage(context))
.setAutoCancel(true)
.setContentIntent(
PendingIntentCompat.getActivity(
@ -126,9 +137,11 @@ class ErrorUtil {
NotificationManagerCompat.from(context)
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
// since the notification is silent, also show a toast, otherwise the user is confused
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
.show()
ContextCompat.getMainExecutor(context).execute {
// since the notification is silent, also show a toast, otherwise the user is confused
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
.show()
}
}
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
@ -143,10 +156,10 @@ class ErrorUtil {
// fallback to showing a notification if no root view is available
createNotification(context, errorInfo)
} else {
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
.setActionTextColor(Color.YELLOW)
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
openActivity(context, errorInfo)
context.startActivity(getErrorActivityIntent(context, errorInfo))
}.show()
}
}

View file

@ -32,7 +32,10 @@ public enum UserAction {
PREFERENCES_MIGRATION("migration of preferences"),
SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
OPEN_INFO_ITEM_DIALOG("open info item dialog");
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;

View file

@ -7,16 +7,57 @@ import android.view.ViewGroup;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorPanelHelper;
public class BlankFragment extends BaseFragment {
@State
@Nullable
ErrorInfo errorInfo;
@Nullable
ErrorPanelHelper errorPanel = null;
/**
* Builds a blank fragment that just says the app name and suggests clicking on search.
*/
public BlankFragment() {
this(null);
}
/**
* @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel.
*/
public BlankFragment(@Nullable final ErrorInfo errorInfo) {
this.errorInfo = errorInfo;
}
@Nullable
@Override
public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
final Bundle savedInstanceState) {
setTitle("NewPipe");
return inflater.inflate(R.layout.fragment_blank, container, false);
final View view = inflater.inflate(R.layout.fragment_blank, container, false);
if (errorInfo != null) {
errorPanel = new ErrorPanelHelper(this, view, null);
errorPanel.showError(errorInfo);
view.findViewById(R.id.blank_page_content).setVisibility(View.GONE);
}
return view;
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (errorPanel != null) {
errorPanel.dispose();
errorPanel = null;
}
}
@Override

View file

@ -36,8 +36,9 @@ import com.google.android.material.tabs.TabLayout;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentMainBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager;
@ -303,9 +304,9 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
final Fragment fragment;
try {
fragment = tab.getFragment(context);
} catch (final ExtractionException e) {
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
return new BlankFragment();
} catch (final Throwable t) {
return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB,
"Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context)));
}
if (fragment instanceof BaseFragment) {

View file

@ -93,7 +93,7 @@ public class DescriptionFragment extends BaseDescriptionFragment {
if (streamInfo.getLanguageInfo() != null) {
addMetadataItem(inflater, layout, false, R.string.metadata_language,
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale()));
}
addMetadataItem(inflater, layout, true, R.string.metadata_support,

View file

@ -93,6 +93,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.OnKeyDownListener;
@ -236,11 +237,14 @@ public final class VideoDetailFragment
// Service management
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onServiceConnected(final Player connectedPlayer,
final PlayerService connectedPlayerService,
final boolean playAfterConnect) {
player = connectedPlayer;
public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
playerService = connectedPlayerService;
}
@Override
public void onPlayerConnected(@NonNull final Player connectedPlayer,
final boolean playAfterConnect) {
player = connectedPlayer;
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
@ -272,22 +276,29 @@ public final class VideoDetailFragment
updateOverlayPlayQueueButtonVisibility();
}
@Override
public void onPlayerDisconnected() {
player = null;
// the binding could be null at this point, if the app is finishing
if (binding != null) {
restoreDefaultBrightness();
}
}
@Override
public void onServiceDisconnected() {
playerService = null;
player = null;
restoreDefaultBrightness();
}
/*////////////////////////////////////////////////////////////////////////*/
public static VideoDetailFragment getInstance(final int serviceId,
@Nullable final String videoUrl,
@Nullable final String url,
@NonNull final String name,
@Nullable final PlayQueue queue) {
final VideoDetailFragment instance = new VideoDetailFragment();
instance.setInitialData(serviceId, videoUrl, name, queue);
instance.setInitialData(serviceId, url, name, queue);
return instance;
}
@ -866,7 +877,7 @@ public final class VideoDetailFragment
}
}
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
url == null ? "no url" : url, serviceId)));
url == null ? "no url" : url, serviceId, url)));
}
/*//////////////////////////////////////////////////////////////////////////
@ -1156,8 +1167,12 @@ public final class VideoDetailFragment
final PlayQueue queue = setupPlayQueueForIntent(false);
tryAddVideoPlayerView();
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
PlayerService.class, queue, true, autoPlayEnabled);
final Context context = requireContext();
final Intent playerIntent =
NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
.putExtra(Player.RESUME_PLAYBACK, true);
ContextCompat.startForegroundService(activity, playerIntent);
}
@ -1401,10 +1416,8 @@ public final class VideoDetailFragment
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
// Rebound to the service if it was closed via notification or mini player
if (!playerHolder.isBound()) {
playerHolder.startService(
false, VideoDetailFragment.this);
}
playerHolder.setListener(VideoDetailFragment.this);
playerHolder.tryBindIfNeeded(context);
break;
}
}
@ -1413,7 +1426,8 @@ public final class VideoDetailFragment
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
intentFilter.addAction(ACTION_PLAYER_STARTED);
activity.registerReceiver(broadcastReceiver, intentFilter);
ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
ContextCompat.RECEIVER_EXPORTED);
}
@ -1582,8 +1596,8 @@ public final class VideoDetailFragment
}
if (!info.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(info.getErrors(),
UserAction.REQUESTED_STREAM, info.getUrl(), info));
showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
"Some info not extracted: " + info.getUrl(), info));
}
}
@ -1736,7 +1750,7 @@ public final class VideoDetailFragment
playQueue = queue;
if (DEBUG) {
Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
+ serviceId + "], videoUrl = [" + url + "], name = ["
+ serviceId + "], url = [" + url + "], name = ["
+ title + "], playQueue = [" + playQueue + "]");
}
@ -1848,13 +1862,16 @@ public final class VideoDetailFragment
@Override
public void onServiceStopped() {
setOverlayPlayPauseImage(false);
if (currentInfo != null) {
updateOverlayData(currentInfo.getName(),
currentInfo.getUploaderName(),
currentInfo.getThumbnails());
// the binding could be null at this point, if the app is finishing
if (binding != null) {
setOverlayPlayPauseImage(false);
if (currentInfo != null) {
updateOverlayData(currentInfo.getName(),
currentInfo.getUploaderName(),
currentInfo.getThumbnails());
}
updateOverlayPlayQueueButtonVisibility();
}
updateOverlayPlayQueueButtonVisibility();
}
@Override

View file

@ -8,6 +8,7 @@ import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
@ -42,6 +43,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
private final UserAction errorUserAction;
protected L currentInfo;
@Nullable
protected Page currentNextPage;
protected Disposable currentWorker;
@ -151,7 +153,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
handleResult(result);
}, throwable ->
showError(new ErrorInfo(throwable, errorUserAction,
"Start loading: " + url, serviceId)));
"Start loading: " + url, serviceId, url)));
}
/**
@ -182,7 +184,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
handleNextItems(infoItemsPage);
}, (@NonNull Throwable throwable) ->
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
errorUserAction, "Loading more items: " + url, serviceId)));
errorUserAction, "Loading more items: " + url, serviceId, url)));
}
private void forbidDownwardFocusScroll() {
@ -208,7 +210,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!result.getErrors().isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction,
"Get next items of: " + url, serviceId));
"Get next items of: " + url, serviceId, url));
}
}
@ -248,7 +250,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!errors.isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
errorUserAction, "Start loading: " + url, serviceId));
errorUserAction, "Start loading: " + url, serviceId, url));
}
}
}

View file

@ -81,9 +81,7 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(
requireContext(),
channelInfo.getSubscriberCount()));
Localization.localizeNumber(channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,

View file

@ -120,67 +120,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
}
@Override
public void onPrepareMenu(@NonNull final Menu menu) {
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateRssButton();
updateNotifyButton(channelSubscription);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(),
currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name,
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
}
break;
default:
return false;
}
return true;
}
};
activity.addMenuProvider(menuProvider);
}
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
@ -195,6 +134,67 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
}
@Override
public void onPrepareMenu(@NonNull final Menu menu) {
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateRssButton();
updateNotifyButton(channelSubscription);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(),
currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name,
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
}
break;
default:
return false;
}
return true;
}
};
activity.addMenuProvider(menuProvider);
}
@Override // called from onViewCreated in BaseFragment.onViewCreated
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
@ -232,6 +232,14 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
binding.subChannelTitleView.setOnClickListener(openSubChannel);
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (menuProvider != null) {
activity.removeMenuProvider(menuProvider);
}
}
@Override
public void onDestroy() {
super.onDestroy();
@ -240,7 +248,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
disposables.clear();
binding = null;
activity.removeMenuProvider(menuProvider);
menuProvider = null;
}
@ -570,7 +577,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
url == null ? "No URL" : url, serviceId)));
url == null ? "No URL" : url, serviceId, url)));
}
@Override

View file

@ -28,6 +28,7 @@ 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;
@ -110,7 +111,7 @@ public final class CommentRepliesFragment
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();
};
}

View file

@ -54,6 +54,7 @@ import org.schabi.newpipe.extractor.MetaInfo;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.search.SearchExtractor;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
@ -144,6 +145,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>();
private StreamingService service;
@Nullable
private Page nextPage;
private boolean showLocalSuggestions = true;
private boolean showRemoteSuggestions = true;
@ -219,6 +221,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
searchBinding = FragmentSearchBinding.bind(rootView);
super.onViewCreated(rootView, savedInstanceState);
updateService();
// Add the service name to search string hint
// to make it more obvious which platform is being searched.
if (service != null) {
searchEditText.setHint(
getString(R.string.search_with_service_name,
service.getServiceInfo().getName()));
}
showSearchOnStart();
initSearchListeners();
}
@ -924,7 +935,21 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
infoListAdapter.clearStreamItemList();
showEmptyState();
} else {
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId));
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId,
getOpenInBrowserUrlForErrors()));
}
}
@Nullable
private String getOpenInBrowserUrlForErrors() {
if (TextUtils.isEmpty(searchString)) {
return null;
}
try {
return service.getSearchQHFactory().getUrl(searchString,
Arrays.asList(contentFilter), sortFilter);
} catch (final NullPointerException | ParsingException ignored) {
return null;
}
}
@ -936,6 +961,20 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
filterItemCheckedId = item.getItemId();
item.setChecked(true);
if (service != null) {
final boolean isNotFiltered = theContentFilter.isEmpty()
|| "all".equals(theContentFilter.get(0));
if (isNotFiltered) {
searchEditText.setHint(
getString(R.string.search_with_service_name,
service.getServiceInfo().getName()));
} else {
searchEditText.setHint(getString(R.string.search_with_service_name_and_filter,
service.getServiceInfo().getName(),
item.getTitle()));
}
}
contentFilter = theContentFilter.toArray(new String[0]);
if (!TextUtils.isEmpty(searchString)) {
@ -998,7 +1037,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
&& !(exceptions.size() == 1
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
searchString, serviceId));
searchString, serviceId, getOpenInBrowserUrlForErrors()));
}
searchSuggestion = result.getSearchSuggestion();
@ -1065,15 +1104,26 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
public void handleNextItems(final ListExtractor.InfoItemsPage<?> result) {
showListFooter(false);
infoListAdapter.addInfoItemList(result.getItems());
nextPage = result.getNextPage();
if (!result.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
+ "pageIds: " + nextPage.getIds() + ", "
+ "pageCookies: " + nextPage.getCookies(),
serviceId));
// nextPage should be non-null at this point, because it refers to the page
// whose results are handled here, but let's check it anyway
if (nextPage == null) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → nextPage == null", serviceId,
getOpenInBrowserUrlForErrors()));
} else {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
+ "pageIds: " + nextPage.getIds() + ", "
+ "pageCookies: " + nextPage.getCookies(),
serviceId, getOpenInBrowserUrlForErrors()));
}
}
// keep the reassignment of nextPage after the error handling to ensure that nextPage
// still holds the correct value during the error handling
nextPage = result.getNextPage();
super.handleNextItems(result);
}

View file

@ -101,14 +101,16 @@ public class CommentInfoItemHolder extends InfoItemHolder {
}
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);
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
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()));

View file

@ -7,3 +7,16 @@ import androidx.core.os.BundleCompat
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
fun Bundle?.toDebugString(): String {
if (this == null) {
return "null"
}
val string = StringBuilder("Bundle{")
for (key in this.keySet()) {
@Suppress("DEPRECATION") // we want this[key] to return items of any type
string.append(" ").append(key).append(" => ").append(this[key]).append(";")
}
string.append(" }")
return string.toString()
}

View file

@ -17,8 +17,10 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.schabi.newpipe.MainActivity
// logs in this class are disabled by default since it's usually not useful,
// you can enable them by setting this flag to MainActivity.DEBUG
private const val DEBUG = false
private const val TAG = "ViewUtils"
/**
@ -38,7 +40,7 @@ fun View.animate(
delay: Long = 0,
execOnEnd: Runnable? = null
) {
if (MainActivity.DEBUG) {
if (DEBUG) {
val id = try {
resources.getResourceEntryName(id)
} catch (e: Exception) {
@ -51,7 +53,7 @@ fun View.animate(
Log.d(TAG, "animate(): $msg")
}
if (isVisible && enterOrExit) {
if (MainActivity.DEBUG) {
if (DEBUG) {
Log.d(TAG, "animate(): view was already visible > view = [$this]")
}
animate().setListener(null).cancel()
@ -60,7 +62,7 @@ fun View.animate(
execOnEnd?.run()
return
} else if ((isGone || isInvisible) && !enterOrExit) {
if (MainActivity.DEBUG) {
if (DEBUG) {
Log.d(TAG, "animate(): view was already gone > view = [$this]")
}
animate().setListener(null).cancel()
@ -89,7 +91,7 @@ fun View.animate(
* @param colorEnd the background color to end with
*/
fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) {
if (MainActivity.DEBUG) {
if (DEBUG) {
Log.d(
TAG,
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
@ -109,7 +111,7 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
}
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
if (MainActivity.DEBUG) {
if (DEBUG) {
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
}
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
@ -127,7 +129,7 @@ fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
}
fun View.animateRotation(duration: Long, targetRotation: Int) {
if (MainActivity.DEBUG) {
if (DEBUG) {
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
}
animate().setListener(null).cancel()

View file

@ -194,9 +194,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override
@ -205,9 +202,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animate(itemsList, true, 200);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), true, 200);
}
}
@Override
@ -253,9 +247,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override

View file

@ -269,7 +269,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
activity?.supportActionBar?.subtitle = null
if (
(groupName != "") &&
(activity?.supportActionBar?.subtitle == groupName)
) {
activity?.supportActionBar?.subtitle = null
}
}
override fun onDestroy() {
@ -281,7 +286,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
}
super.onDestroy()
activity?.supportActionBar?.subtitle = null
if (
(groupName != "") &&
(activity?.supportActionBar?.subtitle == groupName)
) {
activity?.supportActionBar?.subtitle = null
}
}
override fun onDestroyView() {

View file

@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) {
// Show individual stream notifications, set channel icon only if there is actually
// one
showStreamNotifications(newStreams, data.serviceId, bitmap)
showStreamNotifications(newStreams, data.serviceId, data.url, bitmap)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) {
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
// Show individual stream notifications
showStreamNotifications(newStreams, data.serviceId, null)
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
@ -118,10 +118,11 @@ class NotificationHelper(val context: Context) {
private fun showStreamNotifications(
newStreams: List<StreamInfoItem>,
serviceId: Int,
channelUrl: String,
channelIcon: Bitmap?
) {
for (stream in newStreams) {
val notification = createStreamNotification(stream, serviceId, channelIcon)
val notification = createStreamNotification(stream, serviceId, channelUrl, channelIcon)
manager.notify(stream.url.hashCode(), notification)
}
}
@ -129,6 +130,7 @@ class NotificationHelper(val context: Context) {
private fun createStreamNotification(
item: StreamInfoItem,
serviceId: Int,
channelUrl: String,
channelIcon: Bitmap?
): Notification {
return NotificationCompat.Builder(
@ -139,7 +141,7 @@ class NotificationHelper(val context: Context) {
.setLargeIcon(channelIcon)
.setContentTitle(item.name)
.setContentText(item.uploaderName)
.setGroup(item.uploaderUrl)
.setGroup(channelUrl)
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)

View file

@ -1,6 +1,8 @@
package org.schabi.newpipe.local.feed.notifications
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
@ -83,7 +85,9 @@ class NotificationWorker(
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
.build()
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
// ServiceInfo constants are not used below Android Q, so 0 is set here
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType))
}
companion object {

View file

@ -31,6 +31,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable
@ -200,7 +201,7 @@ class FeedLoadService : Service() {
}
}
}
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED)
}
// /////////////////////////////////////////////////////////////////////////

View file

@ -0,0 +1,72 @@
package org.schabi.newpipe.local.playlist
import android.content.Context
import org.schabi.newpipe.R
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
import org.schabi.newpipe.extractor.exceptions.ParsingException
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory
import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS
import org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES
import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST
fun export(
shareMode: PlayListShareMode,
playlist: List<PlaylistStreamEntry>,
context: Context
): String {
return when (shareMode) {
WITH_TITLES -> exportWithTitles(playlist, context)
JUST_URLS -> exportJustUrls(playlist)
YOUTUBE_TEMP_PLAYLIST -> exportAsYoutubeTempPlaylist(playlist)
}
}
fun exportWithTitles(
playlist: List<PlaylistStreamEntry>,
context: Context
): String {
return playlist.asSequence()
.map { it.streamEntity }
.map { entity ->
context.getString(
R.string.video_details_list_item,
entity.title,
entity.url
)
}
.joinToString(separator = "\n")
}
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
return playlist.asSequence()
.map { it.streamEntity.url }
.joinToString(separator = "\n")
}
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
val videoIDs = playlist.asReversed().asSequence()
.map { it.streamEntity.url }
.mapNotNull(::getYouTubeId)
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
.toList()
.asReversed()
.joinToString(separator = ",")
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
}
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
/**
* Gets the video id from a YouTube URL.
*
* @param url YouTube URL
* @return the video id
*/
fun getYouTubeId(url: String): String? {
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
}

View file

@ -2,8 +2,13 @@ package org.schabi.newpipe.local.playlist;
import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export;
import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS;
import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES;
import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
@ -27,7 +32,6 @@ import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding;
import com.evernote.android.state.State;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
@ -385,34 +389,41 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
/**
* Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
* set to {@code false}. Shares the playlist name along with a list of video titles and URLs
* if {@code shouldSharePlaylistDetails} is set to {@code true}.
* Shares the playlist in one of 3 ways, depending on the value of {@code shareMode}:
* <ul>
* <li>{@code JUST_URLS}: shares the URLs only.</li>
* <li>{@code WITH_TITLES}: each entry in the list is accompanied by its title.</li>
* <li>{@code YOUTUBE_TEMP_PLAYLIST}: shares as a YouTube temporary playlist.</li>
* </ul>
*
* @param shouldSharePlaylistDetails Whether the playlist details should be included in the
* shared content.
* @param shareMode The way the playlist should be shared.
*/
private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
private void sharePlaylist(final PlayListShareMode shareMode) {
final Context context = requireContext();
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity)
.map(streamEntity -> {
if (shouldSharePlaylistDetails) {
return context.getString(R.string.video_details_list_item,
streamEntity.getTitle(), streamEntity.getUrl());
} else {
return streamEntity.getUrl();
}
})
.collect(Collectors.joining("\n"))))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(urlsText -> ShareUtils.shareText(
context, name, shouldSharePlaylistDetails
? context.getString(R.string.share_playlist_content_details,
name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
.flatMapSingle(playlist -> Single.just(export(
shareMode,
playlist,
context
)))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
urlsText -> {
final String content = shareMode == WITH_TITLES
? context.getString(R.string.share_playlist_content_details,
name,
urlsText
)
: urlsText;
ShareUtils.shareText(context, name, content);
},
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)
)
);
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
@ -872,13 +883,15 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private void createShareConfirmationDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.share_playlist)
.setMessage(R.string.share_playlist_with_titles_message)
.setCancelable(true)
.setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) ->
sharePlaylist(/* shouldSharePlaylistDetails= */ true)
sharePlaylist(WITH_TITLES)
)
.setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist,
(dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST)
)
.setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
sharePlaylist(/* shouldSharePlaylistDetails= */ false)
sharePlaylist(JUST_URLS)
)
.show();
}

View file

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

View file

@ -26,6 +26,10 @@ public class RemotePlaylistManager {
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
.subscribeOn(Schedulers.io());

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Dialog;
import android.content.Intent;
import android.os.Bundle;
@ -35,7 +33,6 @@ public class ImportConfirmationDialog extends DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
return new AlertDialog.Builder(requireContext())
.setMessage(R.string.import_network_expensive_warning)
.setCancelable(true)

View file

@ -13,6 +13,7 @@ import android.view.MenuItem
import android.view.SubMenu
import android.view.View
import android.view.ViewGroup
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
@ -460,6 +461,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
}
companion object {
const val JSON_MIME_TYPE = "application/json"
val JSON_MIME_TYPE = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension("json") ?: "application/octet-stream"
}
}

View file

@ -89,8 +89,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
ServiceHelper.getNameOfServiceById(currentServiceId),
"Service does not support importing subscriptions",
currentServiceId,
R.string.general_error));
activity.finish();
}

View file

@ -2,7 +2,6 @@ package org.schabi.newpipe.player;
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.ComponentName;
import android.content.Intent;
@ -84,7 +83,6 @@ public final class PlayQueueActivity extends AppCompatActivity
@Override
protected void onCreate(final Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
@ -183,7 +181,10 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
private void bind() {
// Note: this code should not really exist, and PlayerHolder should be used instead, but
// it will be rewritten when NewPlayer will replace the current player.
final Intent bindIntent = new Intent(this, PlayerService.class);
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
if (!success) {
unbindService(serviceConnection);
@ -221,7 +222,7 @@ public final class PlayQueueActivity extends AppCompatActivity
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerService.LocalBinder) {
player = ((PlayerService.LocalBinder) service).getPlayer();
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
}
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {

View file

@ -24,12 +24,12 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
import static com.google.android.exoplayer2.Player.DiscontinuityReason;
import static com.google.android.exoplayer2.Player.Listener;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static com.google.android.exoplayer2.Player.RepeatMode;
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
@ -44,7 +44,6 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.content.BroadcastReceiver;
@ -55,11 +54,14 @@ import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
import android.view.LayoutInflater;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.content.IntentCompat;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
@ -71,6 +73,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.PositionInfo;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Tracks;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.text.CueGroup;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
@ -86,8 +89,8 @@ import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -107,6 +110,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager;
import org.schabi.newpipe.player.playback.PlaybackListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
@ -116,21 +120,25 @@ import org.schabi.newpipe.player.ui.PlayerUiList;
import org.schabi.newpipe.player.ui.PopupPlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.IntStream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.disposables.SerialDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class Player implements PlaybackListener, Listener {
public static final boolean DEBUG = MainActivity.DEBUG;
@ -152,15 +160,13 @@ public final class Player implements PlaybackListener, Listener {
// Intent
//////////////////////////////////////////////////////////////////////////*/
public static final String REPEAT_MODE = "repeat_mode";
public static final String PLAYBACK_QUALITY = "playback_quality";
public static final String PLAY_QUEUE_KEY = "play_queue_key";
public static final String ENQUEUE = "enqueue";
public static final String ENQUEUE_NEXT = "enqueue_next";
public static final String RESUME_PLAYBACK = "resume_playback";
public static final String PLAY_WHEN_READY = "play_when_ready";
public static final String PLAYER_TYPE = "player_type";
public static final String IS_MUTED = "is_muted";
public static final String PLAYER_INTENT_TYPE = "player_intent_type";
public static final String PLAYER_INTENT_DATA = "player_intent_data";
/*//////////////////////////////////////////////////////////////////////////
// Time constants
@ -245,6 +251,8 @@ public final class Player implements PlaybackListener, Listener {
private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
@NonNull
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
@NonNull
private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
// This is the only listener we need for thumbnail loading, since there is always at most only
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
@ -269,7 +277,16 @@ public final class Player implements PlaybackListener, Listener {
//////////////////////////////////////////////////////////////////////////*/
//region Constructor
public Player(@NonNull final PlayerService service) {
/**
* @param service the service this player resides in
* @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and
* could possibly be reused with multiple player instances
* @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service
* and could possibly be reused with multiple player instances
*/
public Player(@NonNull final PlayerService service,
@NonNull final MediaSessionCompat mediaSession,
@NonNull final MediaSessionConnector sessionConnector) {
this.service = service;
context = service;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
@ -302,7 +319,7 @@ public final class Player implements PlaybackListener, Listener {
// notification ui in the UIs list, since the notification depends on the media session in
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
UIs = new PlayerUiList(
new MediaSessionPlayerUi(this),
new MediaSessionPlayerUi(this, mediaSession, sessionConnector),
new NotificationPlayerUi(this)
);
}
@ -336,49 +353,121 @@ public final class Player implements PlaybackListener, Listener {
@SuppressWarnings("MethodLength")
public void handleIntent(@NonNull final Intent intent) {
// fail fast if no play queue was provided
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
if (queueCache == null) {
final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
PlayerIntentType.class);
if (playerIntentType == null) {
return;
}
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
if (newQueue == null) {
return;
// TODO: this should be in the second switch below, but Im not sure whether I
// can move the initUIs stuff without breaking the setup for edge cases somehow.
// when playing from a timestamp, keep the current player as-is.
if (playerIntentType != PlayerIntentType.TimestampChange) {
playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
}
final PlayerType oldPlayerType = playerType;
playerType = PlayerType.retrieveFromIntent(intent);
initUIsForCurrentPlayerType();
// We need to setup audioOnly before super(), see "sourceOf"
isAudioOnly = audioPlayerSelected();
if (intent.hasExtra(PLAYBACK_QUALITY)) {
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
}
// Resolve enqueue intents
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
playQueue.append(newQueue.getStreams());
return;
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
// Resolve enqueue next intents
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
final int currentIndex = playQueue.getIndex();
playQueue.append(newQueue.getStreams());
playQueue.move(playQueue.size() - 1, currentIndex + 1);
switch (playerIntentType) {
case Enqueue -> {
if (playQueue != null) {
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return;
}
playQueue.append(newQueue.getStreams());
return;
}
// TODO: This falls through to the old logic, there was no playQueue
// yet so we should start the player and add the new video
break;
}
case EnqueueNext -> {
if (playQueue != null) {
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return;
}
final PlayQueueItem newItem = newQueue.getStreams().get(0);
newQueue.enqueueNext(newItem, false);
return;
}
// TODO: This falls through to the old logic, there was no playQueue
// yet so we should start the player and add the new video
break;
}
case TimestampChange -> {
final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
PLAYER_INTENT_DATA, TimestampChangeData.class));
final Single<StreamInfo> single =
ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false);
streamItemDisposable.add(single.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(info -> {
final @Nullable PlayQueue oldPlayQueue = playQueue;
info.setStartPosition(data.getSeconds());
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
// If the stream is already playing,
// we can just seek to the appropriate timestamp
if (oldPlayQueue != null
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
if (simpleExoPlayer.getPlaybackState()
== com.google.android.exoplayer2.Player.STATE_IDLE) {
simpleExoPlayer.prepare();
}
simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
data.getSeconds() * 1000L);
simpleExoPlayer.setPlayWhenReady(playWhenReady);
} else {
final PlayQueue newPlayQueue;
// If there is no queue yet, just add our item
if (oldPlayQueue == null) {
newPlayQueue = new SinglePlayQueue(playQueueItem);
// else we add the timestamped stream behind the current video
// and start playing it.
} else {
oldPlayQueue.enqueueNext(playQueueItem, true);
oldPlayQueue.offsetIndex(1);
newPlayQueue = oldPlayQueue;
}
initPlayback(newPlayQueue, playWhenReady);
}
}, throwable -> {
// This will only show a snackbar if the passed context has a root view:
// otherwise it will resort to showing a notification, so we are safe
// here.
final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
data.getUrl(), null, data.getUrl());
ErrorUtil.createNotification(context, info);
}));
return;
}
case AllOthers -> {
// fallthrough; TODO: put other intent data in separate cases
}
}
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return;
}
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
final float playbackSpeed = savedParameters.speed;
final float playbackPitch = savedParameters.pitch;
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
// branching parameters for below
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
/*
* TODO As seen in #7427 this does not work:
@ -393,7 +482,7 @@ public final class Player implements PlaybackListener, Listener {
if (!exoPlayerIsNull()
&& newQueue.size() == 1 && newQueue.getItem() != null
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
&& newQueue.getItem().isSameItem(playQueue.getItem())
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
@ -419,7 +508,8 @@ public final class Player implements PlaybackListener, Listener {
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
&& DependentPreferenceHelper.getResumePlaybackEnabled(context)
&& !samePlayQueue
// !samePlayQueue
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
&& !newQueue.isEmpty()
&& newQueue.getItem() != null
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
@ -435,30 +525,30 @@ public final class Player implements PlaybackListener, Listener {
newQueue.setRecovery(newQueue.getIndex(),
state.getProgressMillis());
}
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
initPlayback(newQueue, playWhenReady);
},
error -> {
if (DEBUG) {
Log.w(TAG, "Failed to start playback", error);
}
// In case any error we can start playback without history
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
initPlayback(newQueue, playWhenReady);
},
() -> {
// Completed but not found in history
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
initPlayback(newQueue, playWhenReady);
}
));
} else {
// Good to go...
// In a case of equal PlayQueues we can re-init old one but only when it is disposed
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady);
}
}
public void handleIntentPost(final PlayerType oldPlayerType) {
if (oldPlayerType != playerType && playQueue != null) {
// If playerType changes from one to another we should reload the player
// (to disable/enable video stream or to set quality)
@ -469,6 +559,19 @@ public final class Player implements PlaybackListener, Listener {
NavigationHelper.sendPlayerStartedEvent(context);
}
@Nullable
private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) {
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
if (queueCache == null) {
return null;
}
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
if (newQueue == null) {
return null;
}
return newQueue;
}
private void initUIsForCurrentPlayerType() {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
@ -502,16 +605,13 @@ public final class Player implements PlaybackListener, Listener {
}
private void initPlayback(@NonNull final PlayQueue queue,
@RepeatMode final int repeatMode,
final float playbackSpeed,
final float playbackPitch,
final boolean playbackSkipSilence,
final boolean playOnReady,
final boolean isMuted) {
final boolean playOnReady) {
destroyPlayer();
initPlayer(playOnReady);
setRepeatMode(repeatMode);
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence);
playQueue = queue;
playQueue.init();
@ -519,7 +619,7 @@ public final class Player implements PlaybackListener, Listener {
UIs.call(PlayerUi::initPlayback);
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
notifyQueueUpdateToListeners();
}
@ -601,6 +701,7 @@ public final class Player implements PlaybackListener, Listener {
databaseUpdateDisposable.clear();
progressUpdateDisposable.set(null);
streamItemDisposable.clear();
cancelLoadingCurrentThumbnail();
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
@ -646,7 +747,7 @@ public final class Player implements PlaybackListener, Listener {
Log.d(TAG, "onPlaybackShutdown() called");
}
// destroys the service, which in turn will destroy the player
service.stopService();
service.destroyPlayerAndStopService();
}
public void smoothStopForImmediateReusing() {
@ -718,7 +819,7 @@ public final class Player implements PlaybackListener, Listener {
pause();
break;
case ACTION_CLOSE:
service.stopService();
service.destroyPlayerAndStopService();
break;
case ACTION_PLAY_PAUSE:
playPause();
@ -742,7 +843,6 @@ public final class Player implements PlaybackListener, Listener {
toggleShuffleModeEnabled();
break;
case Intent.ACTION_CONFIGURATION_CHANGED:
assureCorrectAppLanguage(service);
if (DEBUG) {
Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received");
}
@ -755,7 +855,8 @@ public final class Player implements PlaybackListener, Listener {
private void registerBroadcastReceiver() {
// Try to unregister current first
unregisterBroadcastReceiver();
context.registerReceiver(broadcastReceiver, intentFilter);
ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter,
ContextCompat.RECEIVER_EXPORTED);
}
private void unregisterBroadcastReceiver() {
@ -1167,16 +1268,25 @@ public final class Player implements PlaybackListener, Listener {
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
}
public void setRepeatMode(@RepeatMode final int repeatMode) {
public void cycleNextRepeatMode() {
if (!exoPlayerIsNull()) {
@RepeatMode final int repeatMode;
switch (simpleExoPlayer.getRepeatMode()) {
case REPEAT_MODE_OFF:
repeatMode = REPEAT_MODE_ONE;
break;
case REPEAT_MODE_ONE:
repeatMode = REPEAT_MODE_ALL;
break;
case REPEAT_MODE_ALL:
default:
repeatMode = REPEAT_MODE_OFF;
break;
}
simpleExoPlayer.setRepeatMode(repeatMode);
}
}
public void cycleNextRepeatMode() {
setRepeatMode(nextRepeatMode(getRepeatMode()));
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
if (DEBUG) {
@ -1279,7 +1389,8 @@ public final class Player implements PlaybackListener, Listener {
UserAction.PLAY_STREAM,
"Loading failed for [" + currentMetadata.getTitle()
+ "]: " + currentMetadata.getStreamUrl(),
currentMetadata.getServiceId());
currentMetadata.getServiceId(),
currentMetadata.getStreamUrl());
ErrorUtil.createNotification(context, errorInfo);
}
@ -1375,6 +1486,19 @@ public final class Player implements PlaybackListener, Listener {
public void onCues(@NonNull final CueGroup cueGroup) {
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
}
/**
* To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector}
* receives an {@code onPrepare()} call. This function allows restoring the default behavior
* that would happen if there was no playback preparer set, i.e. to just call
* {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the
* {@link MediaSessionConnector} file.
*/
public void onPrepare() {
if (!exoPlayerIsNull()) {
simpleExoPlayer.prepare();
}
}
//endregion
@ -1482,7 +1606,7 @@ public final class Player implements PlaybackListener, Listener {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
"Player error[type=" + error.getErrorCodeName()
+ "] occurred while playing " + currentMetadata.getStreamUrl(),
currentMetadata.getServiceId());
currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
}
ErrorUtil.createNotification(context, errorInfo);
}

View file

@ -0,0 +1,24 @@
package org.schabi.newpipe.player
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
// We model this as an enum class plus one struct for each enum value
// so we can consume it from Java properly. After converting to Kotlin,
// we could switch to a sealed enum class & a proper Kotlin `when` match.
enum class PlayerIntentType {
Enqueue,
EnqueueNext,
TimestampChange,
AllOthers
}
/**
* A timestamp on the given was clicked and we should switch the playing stream to it.
*/
@Parcelize
data class TimestampChangeData(
val serviceId: Int,
val url: String,
val seconds: Int
) : Parcelable

View file

@ -19,96 +19,162 @@
package org.schabi.newpipe.player;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.IBinder;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.ServiceCompat;
import androidx.media.MediaBrowserServiceCompat;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.ktx.BundleKt;
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
import org.schabi.newpipe.player.notification.NotificationUtil;
import org.schabi.newpipe.util.ThemeHelper;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.function.Consumer;
/**
* One service for all players.
*/
public final class PlayerService extends Service {
public final class PlayerService extends MediaBrowserServiceCompat {
private static final String TAG = PlayerService.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
// These objects are used to cleanly separate the Service implementation (in this file) and the
// media browser and playback preparer implementations. At the moment the playback preparer is
// only used in conjunction with the media browser.
private MediaBrowserImpl mediaBrowserImpl;
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
// these are instantiated in onCreate() as per
// https://developer.android.com/training/cars/media#browser_workflow
private MediaSessionCompat mediaSession;
private MediaSessionConnector sessionConnector;
@Nullable
private Player player;
private final IBinder mBinder = new PlayerService.LocalBinder(this);
/**
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
* stopped.
*/
@Nullable
private Consumer<Player> onPlayerStartedOrStopped = null;
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
//region Service lifecycle
@Override
public void onCreate() {
super.onCreate();
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
player = new Player(this);
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
// see https://developer.android.com/training/cars/media#browser_workflow
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
setSessionToken(mediaSession.getSessionToken());
sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setMetadataDeduplicationEnabled(true);
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
this,
sessionConnector::setCustomErrorMessage,
() -> sessionConnector.setCustomErrorMessage(null),
(playWhenReady) -> {
if (player != null) {
player.onPrepare();
}
}
);
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
// Note: you might be tempted to create the player instance and call startForeground here,
// but be aware that the Android system might start the service just to perform media
// queries. In those cases creating a player instance is a waste of resources, and calling
// startForeground means creating a useless empty notification. In case it's really needed
// the player instance can be created here, but startForeground() should definitely not be
// called here unless the service is actually starting in the foreground, to avoid the
// useless notification.
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
/*
Be sure that the player notification is set and the service is started in foreground,
otherwise, the app may crash on Android 8+ as the service would never be put in the
foreground while we said to the system we would do so
The service is always requested to be started in foreground, so always creating a
notification if there is no one already and starting the service in foreground should
not create any issues
If the service is already started in foreground, requesting it to be started shouldn't
do anything
*/
if (player != null) {
// All internal NewPipe intents used to interact with the player, that are sent to the
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
// to ensure startForeground() is called (otherwise Android will force-crash the app).
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
final boolean playerWasNull = (player == null);
if (playerWasNull) {
// make sure the player exists, in case the service was resumed
player = new Player(this, mediaSession, sessionConnector);
}
// Be sure that the player notification is set and the service is started in foreground,
// otherwise, the app may crash on Android 8+ as the service would never be put in the
// foreground while we said to the system we would do so. The service is always
// requested to be started in foreground, so always creating a notification if there is
// no one already and starting the service in foreground should not create any issues.
// If the service is already started in foreground, requesting it to be started
// shouldn't do anything.
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
if (playerWasNull && onPlayerStartedOrStopped != null) {
// notify that a new player was created (but do it after creating the foreground
// notification just to make sure we don't incur, due to slowness, in
// "Context.startForegroundService() did not then call Service.startForeground()")
onPlayerStartedOrStopped.accept(player);
}
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& (player == null || player.getPlayQueue() == null)) {
/*
No need to process media button's actions if the player is not working, otherwise
the player service would strangely start with nothing to play
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
stopSelf();
if (player == null) {
// No need to process media button's actions or other system intents if the player is
// not running. However, since the current intent might have been issued by the system
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
// a (dummy) foreground notification, otherwise we'd incur in
// "Context.startForegroundService() did not then call Service.startForeground()". Then
// we stop the service again.
Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
NotificationUtil.startForegroundWithDummyNotification(this);
destroyPlayerAndStopService();
return START_NOT_STICKY;
}
if (player != null) {
player.handleIntent(intent);
player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
}
final PlayerType oldPlayerType = player.getPlayerType();
player.handleIntent(intent);
player.handleIntentPost(oldPlayerType);
player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
return START_NOT_STICKY;
}
@ -142,29 +208,84 @@ public final class PlayerService extends Service {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
super.onDestroy();
cleanup();
mediaBrowserPlaybackPreparer.dispose();
mediaSession.release();
mediaBrowserImpl.dispose();
}
private void cleanup() {
if (player != null) {
if (onPlayerStartedOrStopped != null) {
// notify that the player is being destroyed
onPlayerStartedOrStopped.accept(null);
}
player.destroy();
player = null;
}
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
mediaSession.setActive(false);
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
}
public void stopService() {
/**
* Destroys the player and allows the player instance to be garbage collected. Sets the media
* session to inactive. Stops the foreground service and removes the player notification
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
* have no effect in case some service connection still uses the service (e.g. the Android Auto
* system accesses the media browser even when no player is running).
*/
public void destroyPlayerAndStopService() {
if (DEBUG) {
Log.d(TAG, "destroyPlayerAndStopService() called");
}
cleanup();
stopSelf();
// This only really stops the service if there are no other service connections (see docs):
// for example the (Android Auto) media browser binder will block stopService().
// This is why we also stopForeground() above, to make sure the notification is removed.
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
// other service connections), but this would be a waste of resources since the service
// would be immediately restarted by those same connections to perform the queries.
stopService(new Intent(this, PlayerService.class));
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
//endregion
//region Bind
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
if (DEBUG) {
Log.d(TAG, "onBind() called with: intent = [" + intent
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
}
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
// Note that this binder might be reused multiple times while the service is alive, even
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
return mBinder;
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
// MediaBrowserService also uses its own binder, so for actions related to the media
// browser service, pass the onBind to the superclass.
return super.onBind(intent);
} else {
// This is an unknown request, avoid returning any binder to not leak objects.
return null;
}
}
public static class LocalBinder extends Binder {
@ -177,9 +298,51 @@ public final class PlayerService extends Service {
public PlayerService getService() {
return playerService.get();
}
}
public Player getPlayer() {
return playerService.get().player;
/**
* @return the current active player instance. May be null, since the player service can outlive
* the player e.g. to respond to Android Auto media browser queries.
*/
@Nullable
public Player getPlayer() {
return player;
}
/**
* Sets the listener that will be called when the player is started or stopped. If a
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
* by the {@link Consumer} can be null to indicate that the player is stopping.
* @param listener the listener to set or unset
*/
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
this.onPlayerStartedOrStopped = listener;
if (listener != null) {
// if there is no player, then `null` will be sent here, to ensure the state is synced
listener.accept(player);
}
}
//endregion
//region Media browser
@Override
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
final int clientUid,
@Nullable final Bundle rootHints) {
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
}
@Override
public void onLoadChildren(@NonNull final String parentId,
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
mediaBrowserImpl.onLoadChildren(parentId, result);
}
@Override
public void onSearch(@NonNull final String query,
final Bundle extras,
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
mediaBrowserImpl.onSearch(query, result);
}
//endregion
}

View file

@ -1,32 +1,7 @@
package org.schabi.newpipe.player;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import android.content.Intent;
public enum PlayerType {
MAIN,
AUDIO,
POPUP;
/**
* @return an integer representing this {@link PlayerType}, to be used to save it in intents
* @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
* integers from an intent
*/
public int valueForIntent() {
return ordinal();
}
/**
* @param intent the intent to retrieve a player type from
* @return the player type integer retrieved from the intent, converted back into a {@link
* PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
* intent
* @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
* @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
*/
public static PlayerType retrieveFromIntent(final Intent intent) {
return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
}
}

View file

@ -14,10 +14,12 @@ import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Util.castNonNull;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;
import android.net.Uri;
@ -270,6 +272,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
private static final String RN_PARAMETER = "&rn=";
private static final String YOUTUBE_BASE_URL = "https://www.youtube.com";
private static final byte[] POST_BODY = new byte[] {0x78, 0};
private final boolean allowCrossProtocolRedirects;
private final boolean rangeParameterEnabled;
@ -658,8 +661,11 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
}
}
final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);
if (isWebStreamingUrl(requestUrl)
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
|| isTvHtml5StreamingUrl
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
@ -679,6 +685,9 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
} else if (isIosStreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getIosUserAgent(null));
} else if (isTvHtml5StreamingUrl) {
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
getTvHtml5UserAgent());
} else {
// non-mobile user agent
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);
@ -687,22 +696,16 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
allowGzip ? "gzip" : "identity");
httpURLConnection.setInstanceFollowRedirects(followRedirects);
httpURLConnection.setDoOutput(httpBody != null);
// Most clients use POST requests to fetch contents
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length);
httpURLConnection.connect();
// Mobile clients uses POST requests to fetch contents
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
? "POST"
: DataSpec.getStringForHttpMethod(httpMethod));
final OutputStream os = httpURLConnection.getOutputStream();
os.write(POST_BODY);
os.close();
if (httpBody != null) {
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
httpURLConnection.connect();
final OutputStream os = httpURLConnection.getOutputStream();
os.write(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
return httpURLConnection;
}

View file

@ -1,11 +1,48 @@
package org.schabi.newpipe.player.event;
import androidx.annotation.NonNull;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
/**
* In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player
* connections and disconnections. "Connected" here means that the service (resp. the
* player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}.
* "Disconnected" means that either the service (resp. the player) was stopped completely, or that
* {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound.
*/
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
void onServiceConnected(Player player,
PlayerService playerService,
boolean playAfterConnect);
/**
* The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder},
* but the player may not be active at this moment, e.g. in case the service is running to
* respond to Android Auto media browser queries without playing anything.
* {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there
* is a player.
*
* @param playerService the newly connected player service
*/
void onServiceConnected(@NonNull PlayerService playerService);
/**
* The player service is already connected and the player was just started.
*
* @param player the newly connected or started player
* @param playAfterConnect whether to open the video player in the video details fragment
*/
void onPlayerConnected(@NonNull Player player, boolean playAfterConnect);
/**
* The player got disconnected, for one of these reasons: the player is getting closed while
* leaving the service open for future media browser queries, the service is stopping
* completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding.
*/
void onPlayerDisconnected();
/**
* The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder},
* either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because
* the service is stopping completely.
*/
void onServiceDisconnected();
}

View file

@ -154,9 +154,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
notifyAudioSessionUpdate(true, audioSessionId);
}
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
if (!PlayerHelper.isUsingDSP()) {
return;
}
final Intent intent = new Intent(active
? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
: AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);

View file

@ -2,7 +2,6 @@ package org.schabi.newpipe.player.helper;
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
import static org.schabi.newpipe.player.Player.DEBUG;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable;
import android.app.Dialog;
@ -145,7 +144,6 @@ public class PlaybackParameterDialog extends DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
Bridge.restoreInstanceState(this, savedInstanceState);
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());

View file

@ -1,8 +1,5 @@
package org.schabi.newpipe.player.helper;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@ -25,7 +22,6 @@ import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
@ -33,11 +29,9 @@ import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
import com.google.android.exoplayer2.util.MimeTypes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
@ -47,13 +41,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Formatter;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@ -62,11 +57,7 @@ import java.util.Set;
import java.util.concurrent.TimeUnit;
public final class PlayerHelper {
private static final StringBuilder STRING_BUILDER = new StringBuilder();
private static final Formatter STRING_FORMATTER =
new Formatter(STRING_BUILDER, Locale.getDefault());
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider();
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
@ -89,9 +80,11 @@ public final class PlayerHelper {
private PlayerHelper() {
}
////////////////////////////////////////////////////////////////////////////
// Exposed helpers
////////////////////////////////////////////////////////////////////////////
// region Exposed helpers
public static void resetFormat() {
FORMATTERS_PROVIDER.reset();
}
@NonNull
public static String getTimeString(final int milliSeconds) {
@ -100,35 +93,24 @@ public final class PlayerHelper {
final int hours = (milliSeconds % 86400000) / 3600000;
final int days = (milliSeconds % (86400000 * 7)) / 86400000;
STRING_BUILDER.setLength(0);
return (days > 0
? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
: hours > 0
? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds)
: STRING_FORMATTER.format("%02d:%02d", minutes, seconds)
).toString();
final Formatters formatters = FORMATTERS_PROVIDER.formatters();
if (days > 0) {
return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds);
}
return hours > 0
? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds)
: formatters.stringFormat("%02d:%02d", minutes, seconds);
}
@NonNull
public static String formatSpeed(final double speed) {
return SPEED_FORMATTER.format(speed);
return FORMATTERS_PROVIDER.formatters().speed().format(speed);
}
@NonNull
public static String formatPitch(final double pitch) {
return PITCH_FORMATTER.format(pitch);
}
@NonNull
public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) {
switch (format) {
case VTT:
return MimeTypes.TEXT_VTT;
case TTML:
return MimeTypes.APPLICATION_TTML;
default:
throw new IllegalArgumentException("Unrecognized mime type: " + format.name());
}
return FORMATTERS_PROVIDER.formatters().pitch().format(pitch);
}
@NonNull
@ -219,9 +201,8 @@ public final class PlayerHelper {
? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0));
}
////////////////////////////////////////////////////////////////////////////
// Settings Resolution
////////////////////////////////////////////////////////////////////////////
// endregion
// region Resolution
public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) {
return getPreferences(context)
@ -315,10 +296,6 @@ public final class PlayerHelper {
AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION);
}
public static boolean isUsingDSP() {
return true;
}
@NonNull
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
@ -405,9 +382,8 @@ public final class PlayerHelper {
return Integer.parseInt(preferredIntervalBytes) * 1024;
}
////////////////////////////////////////////////////////////////////////////
// Private helpers
////////////////////////////////////////////////////////////////////////////
// endregion
// region Private helpers
@NonNull
private static SharedPreferences getPreferences(@NonNull final Context context) {
@ -426,23 +402,8 @@ public final class PlayerHelper {
return singlePlayQueue;
}
////////////////////////////////////////////////////////////////////////////
// Utils used by player
////////////////////////////////////////////////////////////////////////////
@RepeatMode
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
switch (repeatMode) {
case REPEAT_MODE_OFF:
return REPEAT_MODE_ONE;
case REPEAT_MODE_ONE:
return REPEAT_MODE_ALL;
case REPEAT_MODE_ALL:
default:
return REPEAT_MODE_OFF;
}
}
// endregion
// region Utils used by player
@ResizeMode
public static int retrieveResizeModeFromPrefs(final Player player) {
@ -503,4 +464,43 @@ public final class PlayerHelper {
player.getContext().getString(R.string.seek_duration_key),
player.getContext().getString(R.string.seek_duration_default_value))));
}
// endregion
// region Format
static class FormattersProvider {
private Formatters formatters;
public Formatters formatters() {
if (formatters == null) {
formatters = Formatters.create();
}
return formatters;
}
public void reset() {
formatters = null;
}
}
record Formatters(
Locale locale,
NumberFormat speed,
NumberFormat pitch) {
static Formatters create() {
final Locale locale = Localization.getAppLocale();
final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale);
return new Formatters(
locale,
new DecimalFormat("0.##x", dfs),
new DecimalFormat("##%", dfs));
}
String stringFormat(final String format, final Object... args) {
return String.format(locale, format, args);
}
}
// endregion
}

View file

@ -22,6 +22,10 @@ import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.Optional;
import java.util.function.Consumer;
public final class PlayerHolder {
@ -44,7 +48,16 @@ public final class PlayerHolder {
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private PlayerService playerService;
@Nullable private Player player;
private Optional<Player> getPlayer() {
return Optional.ofNullable(playerService)
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
}
private Optional<PlayQueue> getPlayQueue() {
// player play queue might be null e.g. while player is starting
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
}
/**
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
@ -54,21 +67,15 @@ public final class PlayerHolder {
*/
@Nullable
public PlayerType getType() {
if (player == null) {
return null;
}
return player.getPlayerType();
return getPlayer().map(Player::getPlayerType).orElse(null);
}
public boolean isPlaying() {
if (player == null) {
return false;
}
return player.isPlaying();
return getPlayer().map(Player::isPlaying).orElse(false);
}
public boolean isPlayerOpen() {
return player != null;
return getPlayer().isPresent();
}
/**
@ -77,7 +84,7 @@ public final class PlayerHolder {
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
*/
public boolean isPlayQueueReady() {
return player != null && player.getPlayQueue() != null;
return getPlayQueue().isPresent();
}
public boolean isBound() {
@ -85,18 +92,11 @@ public final class PlayerHolder {
}
public int getQueueSize() {
if (player == null || player.getPlayQueue() == null) {
// player play queue might be null e.g. while player is starting
return 0;
}
return player.getPlayQueue().size();
return getPlayQueue().map(PlayQueue::size).orElse(0);
}
public int getQueuePosition() {
if (player == null || player.getPlayQueue() == null) {
return 0;
}
return player.getPlayQueue().getIndex();
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
}
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
@ -107,9 +107,10 @@ public final class PlayerHolder {
}
// Force reload data from service
if (player != null) {
listener.onServiceConnected(player, playerService, false);
if (playerService != null) {
listener.onServiceConnected(playerService);
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
}
}
@ -121,6 +122,9 @@ public final class PlayerHolder {
public void startService(final boolean playAfterConnect,
final PlayerServiceExtendedEventListener newListener) {
if (DEBUG) {
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
}
final Context context = getCommonContext();
setListener(newListener);
if (bound) {
@ -130,14 +134,24 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
final Intent intent = new Intent(context, PlayerService.class);
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
ContextCompat.startForegroundService(context, intent);
serviceConnection.doPlayAfterConnect(playAfterConnect);
bind(context);
}
public void stopService() {
if (DEBUG) {
Log.d(TAG, "stopService() called");
}
if (playerService != null) {
playerService.destroyPlayerAndStopService();
}
final Context context = getCommonContext();
unbind(context);
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
// to make sure to stop the service even if playerService is null by any chance.
context.stopService(new Intent(context, PlayerService.class));
}
@ -145,6 +159,11 @@ public final class PlayerHolder {
private boolean playAfterConnect = false;
/**
* @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
* PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
* is called. The value of `playAfterConnect` will be reset to false after that.
*/
public void doPlayAfterConnect(final boolean playAfterConnection) {
this.playAfterConnect = playAfterConnection;
}
@ -167,11 +186,17 @@ public final class PlayerHolder {
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
player = localBinder.getPlayer();
if (listener != null) {
listener.onServiceConnected(player, playerService, playAfterConnect);
listener.onServiceConnected(playerService);
}
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
if (playerService != null && playerService.getPlayer() != null) {
// notify the main activity that binding the service has completed and that there is
// a player, so that it can open the bottom mini-player
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
}
}
}
@ -179,15 +204,28 @@ public final class PlayerHolder {
if (DEBUG) {
Log.d(TAG, "bind() called");
}
final Intent serviceIntent = new Intent(context, PlayerService.class);
bound = context.bindService(serviceIntent, serviceConnection,
Context.BIND_AUTO_CREATE);
// BIND_AUTO_CREATE starts the service if it's not already running
bound = bind(context, Context.BIND_AUTO_CREATE);
if (!bound) {
context.unbindService(serviceConnection);
}
}
public void tryBindIfNeeded(final Context context) {
if (!bound) {
// flags=0 means the service will not be started if it does not already exist. In this
// case the return value is not useful, as a value of "true" does not really indicate
// that the service is going to be bound.
bind(context, 0);
}
}
private boolean bind(final Context context, final int flags) {
final Intent serviceIntent = new Intent(context, PlayerService.class);
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
return context.bindService(serviceIntent, serviceConnection, flags);
}
private void unbind(final Context context) {
if (DEBUG) {
Log.d(TAG, "unbind() called");
@ -198,25 +236,32 @@ public final class PlayerHolder {
bound = false;
stopPlayerListener();
playerService = null;
player = null;
if (listener != null) {
listener.onPlayerDisconnected();
listener.onServiceDisconnected();
}
}
}
private void startPlayerListener() {
if (player != null) {
player.setFragmentListener(internalListener);
if (playerService != null) {
// setting the player listener will take care of calling relevant callbacks if the
// player in the service is (not) already active, also see playerStateListener below
playerService.setPlayerListener(playerStateListener);
}
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
}
private void stopPlayerListener() {
if (player != null) {
player.removeFragmentListener(internalListener);
if (playerService != null) {
playerService.setPlayerListener(null);
}
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
}
/**
* This listener will be held by the players created by {@link PlayerService}.
*/
private final PlayerServiceEventListener internalListener =
new PlayerServiceEventListener() {
@Override
@ -303,4 +348,25 @@ public final class PlayerHolder {
unbind(getCommonContext());
}
};
/**
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
* Auto media browser queries.
*/
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
if (listener != null) {
if (player == null) {
// player.fragmentListener=null is already done by player.stopActivityBinding(),
// which is called by player.destroy(), which is in turn called by PlayerService
// before setting its player to null
listener.onPlayerDisconnected();
} else {
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
serviceConnection.playAfterConnect = false;
player.setFragmentListener(internalListener);
}
}
};
}

View file

@ -0,0 +1,40 @@
package org.schabi.newpipe.player.mediabrowser
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.extractor.InfoItem.InfoType
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
internal const val ID_ROOT = "//$ID_AUTHORITY"
internal const val ID_BOOKMARKS = "playlists"
internal const val ID_HISTORY = "history"
internal const val ID_INFO_ITEM = "item"
internal const val ID_LOCAL = "local"
internal const val ID_REMOTE = "remote"
internal const val ID_URL = "url"
internal const val ID_STREAM = "stream"
internal const val ID_PLAYLIST = "playlist"
internal const val ID_CHANNEL = "channel"
internal fun infoItemTypeToString(type: InfoType): String {
return when (type) {
InfoType.STREAM -> ID_STREAM
InfoType.PLAYLIST -> ID_PLAYLIST
InfoType.CHANNEL -> ID_CHANNEL
else -> throw IllegalStateException("Unexpected value: $type")
}
}
internal fun infoItemTypeFromString(type: String): InfoType {
return when (type) {
ID_STREAM -> InfoType.STREAM
ID_PLAYLIST -> InfoType.PLAYLIST
ID_CHANNEL -> InfoType.CHANNEL
else -> throw IllegalStateException("Unexpected value: $type")
}
}
internal fun parseError(mediaId: String): ContentNotAvailableException {
return ContentNotAvailableException("Failed to parse media ID $mediaId")
}

View file

@ -0,0 +1,421 @@
package org.schabi.newpipe.player.mediabrowser
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.core.net.toUri
import androidx.media.MediaBrowserServiceCompat
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media.MediaBrowserServiceCompat.Result
import androidx.media.utils.MediaConstants
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.InfoItem.InfoType
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.image.ImageStrategy
import java.util.function.Consumer
/**
* This class is used to cleanly separate the Service implementation (in
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
*
* @param notifyChildrenChanged takes the parent id of the children that changed
*/
class MediaBrowserImpl(
private val context: Context,
notifyChildrenChanged: Consumer<String>, // parentId
) {
private val packageValidator = PackageValidator(context)
private val database = NewPipeDatabase.getInstance(context)
private var disposables = CompositeDisposable()
init {
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
disposables.add(
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
)
}
//region Cleanup
fun dispose() {
disposables.dispose()
}
//endregion
//region onGetRoot
fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot? {
if (DEBUG) {
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
}
if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) {
// this is a caller we can't trust (see PackageValidator's rules taken from uamp)
return null
}
if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) {
// the system is asking for a root to do media resumption, but we can't handle that yet,
// see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation
return null
}
val extras = Bundle()
extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
)
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
}
//endregion
//region onLoadChildren
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
if (DEBUG) {
Log.d(TAG, "onLoadChildren($parentId)")
}
result.detach() // allows sendResult() to happen later
disposables.add(
onLoadChildren(parentId)
.subscribe(
{ result.sendResult(it) },
{ throwable ->
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
result.sendResult(null)
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
}
)
)
}
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
try {
val parentIdUri = parentId.toUri()
val path = ArrayList(parentIdUri.pathSegments)
if (path.isEmpty()) {
return Single.just(
listOf(
createRootMediaItem(
ID_BOOKMARKS,
context.resources.getString(R.string.tab_bookmarks_short),
R.drawable.ic_bookmark_white
),
createRootMediaItem(
ID_HISTORY,
context.resources.getString(R.string.action_history),
R.drawable.ic_history_white
)
)
)
}
when (/*val uriType = */path.removeAt(0)) {
ID_BOOKMARKS -> {
if (path.isEmpty()) {
return populateBookmarks()
}
if (path.size == 2) {
val localOrRemote = path[0]
val playlistId = path[1].toLong()
if (localOrRemote == ID_LOCAL) {
return populateLocalPlaylist(playlistId)
} else if (localOrRemote == ID_REMOTE) {
return populateRemotePlaylist(playlistId)
}
}
Log.w(TAG, "Unknown playlist URI: $parentId")
throw parseError(parentId)
}
ID_HISTORY -> return populateHistory()
else -> throw parseError(parentId)
}
} catch (e: ContentNotAvailableException) {
return Single.error(e)
}
}
private fun createRootMediaItem(
mediaId: String?,
folderName: String?,
@DrawableRes iconResId: Int
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(mediaId)
builder.setTitle(folderName)
val resources = context.resources
builder.setIconUri(
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(resources.getResourcePackageName(iconResId))
.appendPath(resources.getResourceTypeName(iconResId))
.appendPath(resources.getResourceEntryName(iconResId))
.build()
)
val extras = Bundle()
extras.putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.getString(R.string.app_name)
)
builder.setExtras(extras)
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
)
}
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
.setTitle(playlist.orderingName)
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
val extras = Bundle()
extras.putString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
context.resources.getString(R.string.tab_bookmarks),
)
builder.setExtras(extras)
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
)
}
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForInfoItem(item))
.setTitle(item.name)
when (item.infoType) {
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
else -> return null
}
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
builder.setIconUri(imageUriOrNullIfDisabled(it))
}
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun buildMediaId(): Uri.Builder {
return Uri.Builder().authority(ID_AUTHORITY)
}
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
return buildMediaId()
.appendPath(ID_BOOKMARKS)
.appendPath(playlistType)
}
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
.appendPath(playlistId.toString())
}
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
return buildMediaId()
.appendPath(ID_INFO_ITEM)
.appendPath(infoItemTypeToString(item.infoType))
.appendPath(item.serviceId.toString())
.appendQueryParameter(ID_URL, item.url)
}
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
.build().toString()
}
private fun createLocalPlaylistStreamMediaItem(
playlistId: Long,
item: PlaylistStreamEntry,
index: Int,
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
.setTitle(item.streamEntity.title)
.setSubtitle(item.streamEntity.uploader)
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun createRemotePlaylistStreamMediaItem(
playlistId: Long,
item: StreamInfoItem,
index: Int,
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
.setTitle(item.name)
.setSubtitle(item.uploaderName)
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
builder.setIconUri(imageUriOrNullIfDisabled(it))
}
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun createMediaIdForPlaylistIndex(
isRemote: Boolean,
playlistId: Long,
index: Int,
): String {
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
.appendPath(index.toString())
.build().toString()
}
private fun createMediaIdForInfoItem(item: InfoItem): String {
return buildInfoItemMediaId(item).build().toString()
}
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
val history = database.streamHistoryDAO().getHistory().firstOrError()
return history.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
}
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
val mediaId = buildMediaId()
.appendPath(ID_HISTORY)
.appendPath(streamHistoryEntry.streamId.toString())
.build().toString()
builder.setMediaId(mediaId)
.setTitle(streamHistoryEntry.streamEntity.title)
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
.setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl))
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
)
}
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
return MergedPlaylistManager.getMergedOrderedPlaylists(
LocalPlaylistManager(database),
RemotePlaylistManager(database)
)
}
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
val playlists = getMergedPlaylists().firstOrError()
return playlists.map { playlist ->
playlist.map { this.createPlaylistMediaItem(it) }
}
}
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
return playlist.map { items ->
items.mapIndexed { index, item ->
createLocalPlaylistStreamMediaItem(playlistId, item, index)
}
}
}
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
.map {
// ignore it.errors, i.e. ignore errors about specific items, since there would
// be no way to show the error properly in Android Auto anyway
it.relatedItems.mapIndexed { index, item ->
createRemotePlaylistStreamMediaItem(playlistId, item, index)
}
}
}
//endregion
//region Search
fun onSearch(
query: String,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
if (DEBUG) {
Log.d(TAG, "onSearch($query)")
}
result.detach() // allows sendResult() to happen later
disposables.add(
searchMusicBySongTitle(query)
// ignore it.errors, i.e. ignore errors about specific items, since there would
// be no way to show the error properly in Android Auto anyway
.map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
.subscribeOn(Schedulers.io())
.subscribe(
{ result.sendResult(it) },
{ throwable ->
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
result.sendResult(null)
Log.e(TAG, "Search error for query=\"$query\": $throwable")
}
)
)
}
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
val serviceId = ServiceHelper.getSelectedServiceId(context)
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
}
//endregion
companion object {
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
fun imageUriOrNullIfDisabled(url: String?): Uri? {
return if (ImageStrategy.shouldLoadImages()) {
url?.toUri()
} else {
null
}
}
}
}

View file

@ -0,0 +1,262 @@
package org.schabi.newpipe.player.mediabrowser
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.extractor.InfoItem.InfoType
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue
import org.schabi.newpipe.player.playqueue.PlayQueue
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.NavigationHelper
import java.util.function.BiConsumer
import java.util.function.Consumer
/**
* This class is used to cleanly separate the Service implementation (in
* [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this
* file). We currently use the playback preparer only in conjunction with the media browser: the
* playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start
* playback of the corresponding streams or playlists.
*
* @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat],
* calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)`
* @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)`
* @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because
* `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of
* `player.prepare()` if the playback preparer is not null, but we want the original behavior
*/
class MediaBrowserPlaybackPreparer(
private val context: Context,
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
private val clearMediaSessionError: Runnable,
private val onPrepare: Consumer<Boolean>,
) : PlaybackPreparer {
private val database = NewPipeDatabase.getInstance(context)
private var disposable: Disposable? = null
fun dispose() {
disposable?.dispose()
}
//region Overrides
override fun getSupportedPrepareActions(): Long {
return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
}
override fun onPrepare(playWhenReady: Boolean) {
onPrepare.accept(playWhenReady)
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
if (MainActivity.DEBUG) {
Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)")
}
disposable?.dispose()
disposable = extractPlayQueueFromMediaId(mediaId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ playQueue ->
clearMediaSessionError.run()
NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady)
},
{ throwable ->
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
onPrepareError(throwable)
}
)
}
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
onUnsupportedError()
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
onUnsupportedError()
}
override fun onCommand(
player: Player,
command: String,
extras: Bundle?,
cb: ResultReceiver?
): Boolean {
return false
}
//endregion
//region Errors
private fun onUnsupportedError() {
setMediaSessionError.accept(
ContextCompat.getString(context, R.string.content_not_supported),
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
)
}
private fun onPrepareError(throwable: Throwable) {
setMediaSessionError.accept(
ErrorInfo.getMessage(throwable, null, null).getString(context),
PlaybackStateCompat.ERROR_CODE_APP_ERROR
)
}
//endregion
//region Building play queues from playlists and history
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
.map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
}
private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
// ignore info.errors, i.e. ignore errors about specific items, since there would
// be no way to show the error properly in Android Auto anyway
.map { info -> PlaylistPlayQueue(info, index) }
}
private fun extractPlayQueueFromMediaId(mediaId: String): Single<PlayQueue> {
try {
val mediaIdUri = mediaId.toUri()
val path = ArrayList(mediaIdUri.pathSegments)
if (path.isEmpty()) {
throw parseError(mediaId)
}
return when (/*val uriType = */path.removeAt(0)) {
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
mediaId,
path,
mediaIdUri.getQueryParameter(ID_URL)
)
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
mediaId,
path,
mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
)
else -> throw parseError(mediaId)
}
} catch (e: ContentNotAvailableException) {
return Single.error(e)
}
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromPlaylistMediaId(
mediaId: String,
path: MutableList<String>,
url: String?,
): Single<PlayQueue> {
if (path.isEmpty()) {
throw parseError(mediaId)
}
when (val playlistType = path.removeAt(0)) {
ID_LOCAL, ID_REMOTE -> {
if (path.size != 2) {
throw parseError(mediaId)
}
val playlistId = path[0].toLong()
val index = path[1].toInt()
return if (playlistType == ID_LOCAL)
extractLocalPlayQueue(playlistId, index)
else
extractRemotePlayQueue(playlistId, index)
}
ID_URL -> {
if (path.size != 1 || url == null) {
throw parseError(mediaId)
}
val serviceId = path[0].toInt()
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
.map { PlaylistPlayQueue(it) }
}
else -> throw parseError(mediaId)
}
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromHistoryMediaId(
mediaId: String,
path: List<String>,
): Single<PlayQueue> {
if (path.size != 1) {
throw parseError(mediaId)
}
val streamId = path[0].toLong()
return database.streamHistoryDAO().getHistory()
.firstOrError()
.map { items ->
val infoItems = items
.filter { it.streamId == streamId }
.map { it.toStreamInfoItem() }
SinglePlayQueue(infoItems, 0)
}
}
@Throws(ContentNotAvailableException::class)
private fun extractPlayQueueFromInfoItemMediaId(
mediaId: String,
path: List<String>,
url: String,
): Single<PlayQueue> {
if (path.size != 2) {
throw parseError(mediaId)
}
val serviceId = path[1].toInt()
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
.map { SinglePlayQueue(it) }
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
.map { PlaylistPlayQueue(it) }
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
.map { info ->
val playableTab = info.tabs
.firstOrNull { ChannelTabHelper.isStreamsTab(it) }
?: throw ContentNotAvailableException("No streams tab found")
return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
}
else -> throw parseError(mediaId)
}
}
//endregion
companion object {
private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
}
}

View file

@ -0,0 +1,240 @@
/*
* Copyright 2018 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED.
// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt
package org.schabi.newpipe.player.mediabrowser
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
import android.content.pm.PackageManager
import android.os.Process
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.media.MediaBrowserServiceCompat
import org.schabi.newpipe.BuildConfig
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
/**
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
*
* The list of allowed signing certificates and their corresponding package names is defined in
* res/xml/allowed_media_browser_callers.xml.
*
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
* its signature, this class will print to logcat (INFO level) a message with the proper
* xml tags to add to allow the caller.
*
* For more information, see res/xml/allowed_media_browser_callers.xml.
*/
internal class PackageValidator(context: Context) {
private val context: Context = context.applicationContext
private val packageManager: PackageManager = this.context.packageManager
private val platformSignature: String = getSystemSignature()
private val callerChecked = mutableMapOf<String, Pair<Int, Boolean>>()
/**
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
* See [MusicService.onGetRoot] for where this is utilized.
*
* @param callingPackage The package name of the caller.
* @param callingUid The user id of the caller.
* @return `true` if the caller is known, `false` otherwise.
*/
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
// If the caller has already been checked, return the previous result here.
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
if (checkedUid == callingUid) {
return checkResult
}
/**
* Because some of these checks can be slow, we save the results in [callerChecked] after
* this code is run.
*
* In particular, there's little reason to recompute the calling package's certificate
* signature (SHA-256) each call.
*
* This is safe to do as we know the UID matches the package's UID (from the check above),
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
* be constant until a reboot. (After a reboot then a previously assigned UID could be
* reassigned.)
*/
// Build the caller info for the rest of the checks here.
val callerPackageInfo = buildCallerInfo(callingPackage)
?: throw IllegalStateException("Caller wasn't found in the system?")
// Verify that things aren't ... broken. (This test should always pass.)
if (callerPackageInfo.uid != callingUid) {
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
}
val callerSignature = callerPackageInfo.signature
val isCallerKnown = when {
// If it's our own app making the call, allow it.
callingUid == Process.myUid() -> true
// If the system is making the call, allow it.
callingUid == Process.SYSTEM_UID -> true
// If the app was signed by the same certificate as the platform itself, also allow it.
callerSignature == platformSignature -> true
/**
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
* while it isn't required to allow these apps to connect to a
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
* such as Android TV and the Google Assistant.
*/
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
/**
* If the calling app has a notification listener it is able to retrieve notifications
* and can connect to an active [MediaSessionCompat].
*
* It's not required to allow apps with a notification listener to
* connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility
* with apps such as Wear OS.
*/
NotificationManagerCompat.getEnabledListenerPackages(this.context)
.contains(callerPackageInfo.packageName) -> true
// If none of the previous checks succeeded, then the caller is unrecognized.
else -> false
}
if (!isCallerKnown) {
logUnknownCaller(callerPackageInfo)
}
// Save our work for next time.
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
return isCallerKnown
}
/**
* Logs an info level message with details of how to add a caller to the allowed callers list
* when the app is debuggable.
*/
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
if (BuildConfig.DEBUG) {
Log.w(TAG, "Unknown caller $callerPackageInfo")
}
}
/**
* Builds a [CallerPackageInfo] for a given package that can be used for all the
* various checks that are performed before allowing an app to connect to a
* [MediaBrowserServiceCompat].
*/
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
val packageInfo = getPackageInfo(callingPackage) ?: return null
val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString()
val uid = packageInfo.applicationInfo?.uid ?: -1
val signature = getSignature(packageInfo)
val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty()
val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty()
val activePermissions = (requestedPermissions zip permissionFlags)
.filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 }
.mapTo(mutableSetOf()) { (permission, flag) -> permission }
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
}
/**
* Looks up the [PackageInfo] for a package name.
* This requests both the signatures (for checking if an app is on the allow list) and
* the app's permissions, which allow for more flexibility in the allow list.
*
* @return [PackageInfo] for the package name or null if it's not found.
*/
@Suppress("deprecation")
@SuppressLint("PackageManagerGetSignatures")
private fun getPackageInfo(callingPackage: String): PackageInfo? =
packageManager.getPackageInfo(
callingPackage,
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
)
/**
* Gets the signature of a given package's [PackageInfo].
*
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
* the app.
*
* If the app is not found, or if the app does not have exactly one signature, this method
* returns `null` as the signature.
*/
@Suppress("deprecation")
private fun getSignature(packageInfo: PackageInfo): String? =
if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
null
} else {
val certificate = packageInfo.signatures!![0].toByteArray()
getSignatureSha256(certificate)
}
/**
* Finds the Android platform signing key signature. This key is never null.
*/
private fun getSystemSignature(): String =
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
getSignature(platformInfo)
} ?: throw IllegalStateException("Platform signature not found")
/**
* Creates a SHA-256 signature given a certificate byte array.
*/
private fun getSignatureSha256(certificate: ByteArray): String {
val md: MessageDigest
try {
md = MessageDigest.getInstance("SHA256")
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
}
md.update(certificate)
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
// to a string, applying the string format `%02x` on each digit before it's appended, with
// a colon (':') between each of the items.
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
return md.digest().joinToString(":") { String.format("%02x", it) }
}
/**
* Convenience class to hold all of the information about an app that's being checked
* to see if it's a known caller.
*/
private data class CallerPackageInfo(
val name: String,
val packageName: String,
val uid: Int,
val signature: String?,
val permissions: Set<String>
)
}
private const val TAG = "PackageValidator"
private const val ANDROID_PLATFORM = "android"

View file

@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "MediaSessUi";
@Nullable
private MediaSessionCompat mediaSession;
@Nullable
private MediaSessionConnector sessionConnector;
@NonNull
private final MediaSessionCompat mediaSession;
@NonNull
private final MediaSessionConnector sessionConnector;
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
@ -50,9 +50,13 @@ public class MediaSessionPlayerUi extends PlayerUi
private List<NotificationActionData> prevNotificationActions = List.of();
public MediaSessionPlayerUi(@NonNull final Player player) {
public MediaSessionPlayerUi(@NonNull final Player player,
@NonNull final MediaSessionCompat mediaSession,
@NonNull final MediaSessionConnector sessionConnector) {
super(player);
ignoreHardwareMediaButtonsKey =
this.mediaSession = mediaSession;
this.sessionConnector = sessionConnector;
this.ignoreHardwareMediaButtonsKey =
context.getString(R.string.ignore_hardware_media_buttons_key);
}
@ -61,10 +65,8 @@ public class MediaSessionPlayerUi extends PlayerUi
super.initPlayer();
destroyPlayer(); // release previously used resources
mediaSession = new MediaSessionCompat(context, TAG);
mediaSession.setActive(true);
sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
sessionConnector.setPlayer(getForwardingPlayer());
@ -89,27 +91,18 @@ public class MediaSessionPlayerUi extends PlayerUi
public void destroyPlayer() {
super.destroyPlayer();
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
if (sessionConnector != null) {
sessionConnector.setMediaButtonEventHandler(null);
sessionConnector.setPlayer(null);
sessionConnector.setQueueNavigator(null);
sessionConnector = null;
}
if (mediaSession != null) {
mediaSession.setActive(false);
mediaSession.release();
mediaSession = null;
}
sessionConnector.setMediaButtonEventHandler(null);
sessionConnector.setPlayer(null);
sessionConnector.setQueueNavigator(null);
mediaSession.setActive(false);
prevNotificationActions = List.of();
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
if (sessionConnector != null) {
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
}
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
}
@ -200,8 +193,8 @@ public class MediaSessionPlayerUi extends PlayerUi
return;
}
if (sessionConnector == null) {
// sessionConnector will be null after destroyPlayer is called
if (!mediaSession.isActive()) {
// mediaSession will be inactive after destroyPlayer is called
return;
}

View file

@ -5,7 +5,9 @@ import static androidx.media.app.NotificationCompat.MediaStyle;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import android.annotation.SuppressLint;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
@ -23,6 +25,8 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
@ -89,12 +93,9 @@ public final class NotificationUtil {
Log.d(TAG, "createNotification()");
}
notificationManager = NotificationManagerCompat.from(player.getContext());
final NotificationCompat.Builder builder =
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
final MediaStyle mediaStyle = new MediaStyle();
// setup media style (compact notification slots and media session)
final MediaStyle mediaStyle = new MediaStyle();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
@ -107,18 +108,9 @@ public final class NotificationUtil {
.ifPresent(mediaStyle::setMediaSession);
// setup notification builder
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setShowWhen(false)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setColor(ContextCompat.getColor(player.getContext(),
R.color.dark_background_color))
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
.setColorized(player.getPrefs().getBoolean(
player.getContext().getString(R.string.notification_colorize_key), true))
.setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
player.getContext().getString(R.string.notification_colorize_key), true));
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
setLargeIcon(builder);
@ -167,19 +159,17 @@ public final class NotificationUtil {
&& notificationBuilder.mActions.get(2).actionIntent != null);
}
public static void startForegroundWithDummyNotification(final PlayerService service) {
final var builder = setupNotificationBuilder(service, new MediaStyle());
startForeground(service, builder.build());
}
public void createNotificationAndStartForeground() {
if (notificationBuilder == null) {
notificationBuilder = createNotification();
}
updateNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
}
startForeground(player.getService(), notificationBuilder.build());
}
public void cancelNotificationAndStopForeground() {
@ -193,6 +183,34 @@ public final class NotificationUtil {
}
/////////////////////////////////////////////////////
// STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION
/////////////////////////////////////////////////////
private static NotificationCompat.Builder setupNotificationBuilder(final Context context,
final MediaStyle style) {
return new NotificationCompat.Builder(context,
context.getString(R.string.notification_channel_id))
.setStyle(style)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setShowWhen(false)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setColor(ContextCompat.getColor(context, R.color.dark_background_color))
.setDeleteIntent(PendingIntentCompat.getBroadcast(context,
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
}
private static void startForeground(final PlayerService service,
final Notification notification) {
// ServiceInfo constants are not used below Android Q, so 0 is set here
final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType);
}
/////////////////////////////////////////////////////
// ACTIONS
/////////////////////////////////////////////////////
@ -256,7 +274,9 @@ public final class NotificationUtil {
} else {
// We are playing in fragment. Don't open another activity just show fragment. That's it
final Intent intent = NavigationHelper.getPlayerIntent(
player.getContext(), MainActivity.class, null, true);
player.getContext(), MainActivity.class, null,
PlayerIntentType.AllOthers);
intent.putExtra(Player.RESUME_PLAYBACK, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER);

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe.player.playqueue;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
@ -23,18 +24,23 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
final int serviceId;
final String baseUrl;
@Nullable
Page nextPage;
private transient Disposable fetchReactor;
protected AbstractInfoPlayQueue(final T info) {
this(info, 0);
}
protected AbstractInfoPlayQueue(final T info, final int index) {
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
info.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList()),
0);
index);
}
protected AbstractInfoPlayQueue(final int serviceId,

View file

@ -291,6 +291,22 @@ public abstract class PlayQueue implements Serializable {
broadcast(new AppendEvent(itemList.size()));
}
/**
* Add the given item after the current stream.
*
* @param item item to add.
* @param skipIfSame if set, skip adding if the next stream is the same stream.
*/
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
final int currentIndex = getIndex();
// if the next item is the same item as the one we want to enqueue, skip if flag is true
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
return;
}
append(List.of(item));
move(size() - 1, currentIndex + 1);
}
/**
* Removes the item at the given index from the play queue.
* <p>
@ -529,8 +545,7 @@ public abstract class PlayQueue implements Serializable {
final PlayQueueItem stream = streams.get(i);
final PlayQueueItem otherStream = other.streams.get(i);
// Check is based on serviceId and URL
if (stream.getServiceId() != otherStream.getServiceId()
|| !stream.getUrl().equals(otherStream.getUrl())) {
if (!stream.isSameItem(otherStream)) {
return false;
}
}

View file

@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
private long recoveryPosition;
private Throwable error;
PlayQueueItem(@NonNull final StreamInfo info) {
public PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType());
@ -71,6 +71,22 @@ public class PlayQueueItem implements Serializable {
this.recoveryPosition = RECOVERY_UNSET;
}
/** Whether these two items should be treated as the same stream
* for the sake of keeping the same player running when e.g. jumping between timestamps.
*
* @param other the {@link PlayQueueItem} to compare against.
* @return whether the two items are the same so the stream can be re-used.
*/
public boolean isSameItem(@Nullable final PlayQueueItem other) {
if (other == null) {
return false;
}
// We assume that the same service & URL uniquely determines
// that we can keep the same stream running.
return serviceId == other.serviceId
&& url.equals(other.url);
}
@NonNull
public String getTitle() {
return title;

View file

@ -16,6 +16,10 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue<PlaylistInfo>
super(info);
}
public PlaylistPlayQueue(final PlaylistInfo info, final int index) {
super(info, index);
}
public PlaylistPlayQueue(final int serviceId,
final String url,
final Page nextPage,

View file

@ -16,7 +16,9 @@ public final class SinglePlayQueue extends PlayQueue {
public SinglePlayQueue(final StreamInfo info) {
super(0, List.of(new PlayQueueItem(info)));
}
public SinglePlayQueue(final PlayQueueItem item) {
super(0, List.of(item));
}
public SinglePlayQueue(final StreamInfo info, final long startPosition) {
super(0, List.of(new PlayQueueItem(info)));
getItem().setRecoveryPosition(startPosition);

View file

@ -25,9 +25,7 @@ import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
@ -44,6 +42,7 @@ import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.ui.SubtitleView;
import com.google.android.exoplayer2.video.VideoSize;
import org.schabi.newpipe.R;
@ -522,11 +521,8 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
@Override
protected void setupSubtitleView(final float captionScale) {
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels);
final float captionRatioInverse = 20f + 4f * (1.0f - captionScale);
binding.subtitleView.setFixedTextSize(
TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse);
binding.subtitleView.setFractionalTextSize(
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale);
}
//endregion

View file

@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
private void end() {
windowManager.removeView(closeOverlayBinding.getRoot());
closeOverlayBinding = null;
player.getService().stopService();
player.getService().destroyPlayerAndStopService();
}
}).start();
}
@ -424,9 +424,8 @@ public final class PopupPlayerUi extends VideoPlayerUi {
@Override
protected void setupSubtitleView(final float captionScale) {
final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f;
binding.subtitleView.setFractionalTextSize(
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio);
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale);
}
@Override

View file

@ -1414,6 +1414,10 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
binding.subtitleView.setStyle(captionStyle);
}
/**
*
* @param captionScale Value returned by {@link PlayerHelper#getCaptionScale}.
*/
protected abstract void setupSubtitleView(float captionScale);
//endregion

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.app.AlertDialog;
@ -126,7 +125,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
}
private void requestExportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(requireContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastExportDataUri = result.getData().getData();
@ -139,7 +137,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
}
private void requestImportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(requireContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastImportDataUri = result.getData().getData();

View file

@ -1,57 +1,89 @@
package org.schabi.newpipe.settings;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.preference.Preference;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.io.IOException;
import java.util.Locale;
public class ContentSettingsFragment extends BasePreferenceFragment {
private String youtubeRestrictedModeEnabledKey;
private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry;
private String initialLanguage;
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
addPreferencesFromResourceRegistry();
initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
.getPreferredContentCountry(requireContext());
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
setupAppLanguagePreferences();
setupImageQualityPref();
}
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
imageQualityPreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
.fromPreferenceKey(requireContext(), (String) newValue));
try {
PicassoHelper.clearCache(preference.getContext());
Toast.makeText(preference.getContext(),
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
.show();
} catch (final IOException e) {
Log.e(TAG, "Unable to clear Picasso cache", e);
}
return true;
});
private void setupAppLanguagePreferences() {
final Preference appLanguagePref = requirePreference(R.string.app_language_key);
// Android 13+ allows to set app specific languages
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
appLanguagePref.setVisible(false);
final Preference newAppLanguagePref =
requirePreference(R.string.app_language_android_13_and_up_key);
newAppLanguagePref.setSummaryProvider(preference -> {
final Locale loc = AppCompatDelegate.getApplicationLocales().get(0);
return loc != null ? loc.getDisplayName() : getString(R.string.systems_language);
});
newAppLanguagePref.setOnPreferenceClickListener(preference -> {
final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS)
.setData(Uri.fromParts("package", requireContext().getPackageName(), null));
startActivity(intent);
return true;
});
newAppLanguagePref.setVisible(true);
return;
}
appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> {
final String language = (String) newValue;
final String systemLang = getString(R.string.default_localization_key);
final String tag = systemLang.equals(language) ? null : language;
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag));
return true;
});
}
private void setupImageQualityPref() {
requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener(
(preference, newValue) -> {
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
.fromPreferenceKey(requireContext(), (String) newValue));
try {
PicassoHelper.clearCache(preference.getContext());
Toast.makeText(preference.getContext(),
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
.show();
} catch (final IOException e) {
Log.e(TAG, "Unable to clear Picasso cache", e);
}
return true;
});
}
@Override
@ -72,20 +104,10 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
public void onDestroy() {
super.onDestroy();
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
.getPreferredContentCountry(requireContext());
final String selectedLanguage =
defaultPreferences.getString(getString(R.string.app_language_key), "en");
if (!selectedLocalization.equals(initialSelectedLocalization)
|| !selectedContentCountry.equals(initialSelectedContentCountry)
|| !selectedLanguage.equals(initialLanguage)) {
Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart,
Toast.LENGTH_LONG).show();
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
}
final Context context = requireContext();
NewPipe.setupLocalization(
Localization.getPreferredLocalization(context),
Localization.getPreferredContentCountry(context));
PlayerHelper.resetFormat();
}
}

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
@ -209,8 +207,6 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
}
private void requestDownloadPathResult(final ActivityResult result, final String key) {
assureCorrectAppLanguage(getContext());
if (result.getResultCode() != Activity.RESULT_OK) {
return;
}

View file

@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.settings.migration.MigrationManager;
import org.schabi.newpipe.util.DeviceUtils;
import java.io.File;
@ -46,7 +47,7 @@ public final class NewPipeSettings {
public static void initSettings(final Context context) {
// first run migrations, then setDefaultValues, since the latter requires the correct types
SettingMigrations.runMigrationsIfNeeded(context);
MigrationManager.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
@ -102,12 +103,12 @@ public final class NewPipeSettings {
}
public static boolean useStorageAccessFramework(final Context context) {
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a
// remote (see #6455).
if (DeviceUtils.isFireTv()) {
return false;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return true;
} else if (DeviceUtils.isFireTv()) {
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with
// a remote (see #6455).
return false;
}
final String key = context.getString(R.string.storage_use_saf);

View file

@ -0,0 +1,214 @@
package org.schabi.newpipe.settings;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.util.ThemeHelper;
import java.util.List;
import java.util.Vector;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observer;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Created by Christian Schabesberger on 26.09.17.
* SelectChannelFragment.java is part of NewPipe.
* <p>
* 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.
* </p>
* <p>
* 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.
* </p>
* <p>
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
* </p>
*/
public class SelectFeedGroupFragment extends DialogFragment {
private OnSelectedListener onSelectedListener = null;
private OnCancelListener onCancelListener = null;
private ProgressBar progressBar;
private TextView emptyView;
private RecyclerView recyclerView;
private List<FeedGroupEntity> feedGroups = new Vector<>();
public void setOnSelectedListener(final OnSelectedListener listener) {
onSelectedListener = listener;
}
public void setOnCancelListener(final OnCancelListener listener) {
onCancelListener = listener;
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()));
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
final View v = inflater.inflate(R.layout.select_feed_group_fragment, container, false);
recyclerView = v.findViewById(R.id.items_list);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
final SelectFeedGroupAdapter feedGroupAdapter = new SelectFeedGroupAdapter();
recyclerView.setAdapter(feedGroupAdapter);
progressBar = v.findViewById(R.id.progressBar);
emptyView = v.findViewById(R.id.empty_state_view);
progressBar.setVisibility(View.VISIBLE);
recyclerView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);
final AppDatabase database = NewPipeDatabase.getInstance(requireContext());
database.feedGroupDAO().getAll().toObservable()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getFeedGroupObserver());
return v;
}
/*//////////////////////////////////////////////////////////////////////////
// Handle actions
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCancel(@NonNull final DialogInterface dialogInterface) {
super.onCancel(dialogInterface);
if (onCancelListener != null) {
onCancelListener.onCancel();
}
}
private void clickedItem(final int position) {
if (onSelectedListener != null) {
final FeedGroupEntity entry = feedGroups.get(position);
onSelectedListener
.onFeedGroupSelected(entry.getUid(), entry.getName(),
entry.getIcon().getDrawableResource());
}
dismiss();
}
/*//////////////////////////////////////////////////////////////////////////
// Item handling
//////////////////////////////////////////////////////////////////////////*/
private void displayFeedGroups(final List<FeedGroupEntity> newFeedGroups) {
this.feedGroups = newFeedGroups;
progressBar.setVisibility(View.GONE);
if (newFeedGroups.isEmpty()) {
emptyView.setVisibility(View.VISIBLE);
return;
}
recyclerView.setVisibility(View.VISIBLE);
}
private Observer<List<FeedGroupEntity>> getFeedGroupObserver() {
return new Observer<List<FeedGroupEntity>>() {
@Override
public void onSubscribe(@NonNull final Disposable disposable) { }
@Override
public void onNext(@NonNull final List<FeedGroupEntity> newGroups) {
displayFeedGroups(newGroups);
}
@Override
public void onError(@NonNull final Throwable exception) {
ErrorUtil.showUiErrorSnackbar(SelectFeedGroupFragment.this,
"Loading Feed Groups", exception);
}
@Override
public void onComplete() { }
};
}
/*//////////////////////////////////////////////////////////////////////////
// Interfaces
//////////////////////////////////////////////////////////////////////////*/
public interface OnSelectedListener {
void onFeedGroupSelected(Long groupId, String name, int icon);
}
public interface OnCancelListener {
void onCancel();
}
private class SelectFeedGroupAdapter
extends RecyclerView.Adapter<SelectFeedGroupAdapter.SelectFeedGroupItemHolder> {
@NonNull
@Override
public SelectFeedGroupItemHolder onCreateViewHolder(final ViewGroup parent,
final int viewType) {
final View item = LayoutInflater.from(parent.getContext())
.inflate(R.layout.select_feed_group_item, parent, false);
return new SelectFeedGroupItemHolder(item);
}
@Override
public void onBindViewHolder(final SelectFeedGroupItemHolder holder, final int position) {
final FeedGroupEntity entry = feedGroups.get(position);
holder.titleView.setText(entry.getName());
holder.view.setOnClickListener(view -> clickedItem(position));
holder.thumbnailView.setImageResource(entry.getIcon().getDrawableResource());
}
@Override
public int getItemCount() {
return feedGroups.size();
}
public class SelectFeedGroupItemHolder extends RecyclerView.ViewHolder {
public final View view;
final ImageView thumbnailView;
final TextView titleView;
SelectFeedGroupItemHolder(final View v) {
super(v);
this.view = v;
thumbnailView = v.findViewById(R.id.itemThumbnailView);
titleView = v.findViewById(R.id.itemTitleView);
}
}
}
}

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
@ -89,7 +87,6 @@ public class SettingsActivity extends AppCompatActivity implements
@Override
protected void onCreate(final Bundle savedInstanceBundle) {
setTheme(ThemeHelper.getSettingsThemeStyle(this));
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceBundle);
Bridge.restoreInstanceState(this, savedInstanceBundle);
@ -228,7 +225,6 @@ public class SettingsActivity extends AppCompatActivity implements
// Build search items
final Context searchContext = getApplicationContext();
assureCorrectAppLanguage(searchContext);
final PreferenceParser parser = new PreferenceParser(searchContext, config);
final PreferenceSearcher searcher = new PreferenceSearcher(config);

View file

@ -0,0 +1,103 @@
package org.schabi.newpipe.settings.migration;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.util.Consumer;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import java.util.ArrayList;
import java.util.List;
/**
* MigrationManager is responsible for running migrations and showing the user information about
* the migrations that were applied.
*/
public final class MigrationManager {
private static final String TAG = MigrationManager.class.getSimpleName();
/**
* List of UI actions that are performed after the UI is initialized (e.g. showing alert
* dialogs) to inform the user about changes that were applied by migrations.
*/
private static final List<Consumer<Context>> MIGRATION_INFO = new ArrayList<>();
private MigrationManager() {
// MigrationManager is a utility class that is completely static
}
/**
* Run all migrations that are needed for the current version of NewPipe.
* This method should be called at the start of the application, before any other operations
* that depend on the settings.
*
* @param context Context that can be used to run migrations
*/
public static void runMigrationsIfNeeded(@NonNull final Context context) {
SettingMigrations.runMigrationsIfNeeded(context);
}
/**
* Perform UI actions informing about migrations that took place if they are present.
* @param context Context that can be used to show dialogs/snackbars/toasts
*/
public static void showUserInfoIfPresent(@NonNull final Context context) {
if (MIGRATION_INFO.isEmpty()) {
return;
}
try {
MIGRATION_INFO.get(0).accept(context);
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e);
// Remove the migration that caused the error and continue with the next one
MIGRATION_INFO.remove(0);
showUserInfoIfPresent(context);
}
}
/**
* Add a migration info action that will be executed after the UI is initialized.
* This can be used to show dialogs/snackbars/toasts to inform the user about changes that
* were applied by migrations.
*
* @param info the action to be executed
*/
public static void addMigrationInfo(final Consumer<Context> info) {
MIGRATION_INFO.add(info);
}
/**
* This method should be called when the user dismisses the migration info
* to check if there are any more migration info actions to be shown.
* @param context Context that can be used to show dialogs/snackbars/toasts
*/
public static void onMigrationInfoDismissed(@NonNull final Context context) {
MIGRATION_INFO.remove(0);
showUserInfoIfPresent(context);
}
/**
* Creates a dialog to inform the user about the migration.
* @param uiContext Context that can be used to show dialogs/snackbars/toasts
* @param title the title of the dialog
* @param message the message of the dialog
* @return the dialog that can be shown to the user with a custom dismiss listener
*/
static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext,
@NonNull final String title,
@NonNull final String message) {
return new AlertDialog.Builder(uiContext)
.setTitle(title)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.setOnDismissListener(dialog ->
MigrationManager.onMigrationInfoDismissed(uiContext))
.setCancelable(false) // prevents the dialog from being dismissed accidentally
.create();
}
}

View file

@ -1,10 +1,15 @@
package org.schabi.newpipe.settings;
package org.schabi.newpipe.settings.migration;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
@ -12,20 +17,32 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager;
import org.schabi.newpipe.util.DeviceUtils;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.schabi.newpipe.MainActivity.DEBUG;
import java.util.stream.Collectors;
/**
* In order to add a migration, follow these steps, given P is the previous version:<br>
* - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
* the {@code migrate()} method the code that need to be run when migrating from P to P+1<br>
* - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}<br>
* - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1)
* This class contains the code to migrate the settings from one version to another.
* Migrations are run automatically when the app is started and the settings version changed.
* <br>
* In order to add a migration, follow these steps, given {@code P} is the previous version:
* <ul>
* <li>in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put
* in the {@code migrate()} method the code that need to be run
* when migrating from {@code P} to {@code P+1}</li>
* <li>add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}</li>
* <li>increment {@link SettingMigrations#VERSION}'s value by 1
* (so it becomes {@code P+1})</li>
* </ul>
* Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)}
* that will be performed after the UI is initialized to inform the user about changes
* that were applied by migrations.
*/
public final class SettingMigrations {
@ -129,7 +146,7 @@ public final class SettingMigrations {
}
};
public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
private static final Migration MIGRATION_5_6 = new Migration(5, 6) {
@Override
protected void migrate(@NonNull final Context context) {
final boolean loadImages = sp.getBoolean("download_thumbnail_key", true);
@ -143,6 +160,67 @@ public final class SettingMigrations {
}
};
private static final Migration MIGRATION_6_7 = new Migration(6, 7) {
@Override
protected void migrate(@NonNull final Context context) {
// The SoundCloud Top 50 Kiosk was removed in the extractor,
// so we remove the corresponding tab if it exists.
final TabsManager tabsManager = TabsManager.getManager(context);
final List<Tab> tabs = tabsManager.getTabs();
final List<Tab> cleanedTabs = tabs.stream()
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
&& kioskTab.getKioskServiceId() == SoundCloud.getServiceId()
&& kioskTab.getKioskId().equals("Top 50")))
.collect(Collectors.toUnmodifiableList());
if (tabs.size() != cleanedTabs.size()) {
tabsManager.saveTabs(cleanedTabs);
// create an AlertDialog to inform the user about the change
MigrationManager.addMigrationInfo(uiContext ->
MigrationManager.createMigrationInfoDialog(
uiContext,
uiContext.getString(R.string.migration_info_6_7_title),
uiContext.getString(R.string.migration_info_6_7_message))
.show());
}
}
};
private static final Migration MIGRATION_7_8 = new Migration(7, 8) {
@Override
protected void migrate(@NonNull final Context context) {
// YouTube remove the combined Trending kiosk, see
// https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information.
// If the user has a dedicated YouTube/Trending kiosk tab,
// it is removed and replaced with the new live kiosk tab.
// The default trending kiosk tab is not touched
// because it uses the default kiosk provided by the extractor
// and is thus updated automatically.
final TabsManager tabsManager = TabsManager.getManager(context);
final List<Tab> tabs = tabsManager.getTabs();
final List<Tab> cleanedTabs = tabs.stream()
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
&& kioskTab.getKioskServiceId() == YouTube.getServiceId()
&& kioskTab.getKioskId().equals("Trending")))
.collect(Collectors.toUnmodifiableList());
if (tabs.size() != cleanedTabs.size()) {
tabsManager.saveTabs(cleanedTabs);
}
final boolean hasDefaultTrendingTab = tabs.stream()
.anyMatch(tab -> tab instanceof Tab.DefaultKioskTab);
if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) {
// User is informed about the change
MigrationManager.addMigrationInfo(uiContext ->
MigrationManager.createMigrationInfoDialog(
uiContext,
uiContext.getString(R.string.migration_info_7_8_title),
uiContext.getString(R.string.migration_info_7_8_message))
.show());
}
}
};
/**
* List of all implemented migrations.
* <p>
@ -156,15 +234,17 @@ public final class SettingMigrations {
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
};
/**
* Version number for preferences. Must be incremented every time a migration is necessary.
*/
private static final int VERSION = 6;
private static final int VERSION = 8;
public static void runMigrationsIfNeeded(@NonNull final Context context) {
static void runMigrationsIfNeeded(@NonNull final Context context) {
// setup migrations and check if there is something to do
sp = PreferenceManager.getDefaultSharedPreferences(context);
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);

View file

@ -34,6 +34,7 @@ import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.settings.SelectChannelFragment;
import org.schabi.newpipe.settings.SelectKioskFragment;
import org.schabi.newpipe.settings.SelectPlaylistFragment;
import org.schabi.newpipe.settings.SelectFeedGroupFragment;
import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem;
import org.schabi.newpipe.util.ThemeHelper;
@ -203,6 +204,14 @@ public class ChooseTabsFragment extends Fragment {
});
selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist");
return;
case FEEDGROUP:
final SelectFeedGroupFragment selectFeedGroupFragment =
new SelectFeedGroupFragment();
selectFeedGroupFragment.setOnSelectedListener(
(groupId, name, iconId) ->
addTab(new Tab.FeedGroupTab(groupId, name, iconId)));
selectFeedGroupFragment.show(getParentFragmentManager(), "select_feed_group");
return;
default:
addTab(type.getTab());
break;
@ -244,6 +253,11 @@ public class ChooseTabsFragment extends Fragment {
getString(R.string.playlist_page_summary),
tab.getTabIconRes(context)));
break;
case FEEDGROUP:
returnList.add(new ChooseTabListItem(tab.getTabId(),
getString(R.string.feed_group_page_summary),
tab.getTabIconRes(context)));
break;
default:
if (!tabList.contains(tab)) {
returnList.add(new ChooseTabListItem(context, tab));
@ -396,6 +410,9 @@ public class ChooseTabsFragment extends Fragment {
? getString(R.string.local)
: getNameOfServiceById(serviceId);
return serviceName + "/" + tab.getTabName(requireContext());
case FEEDGROUP:
return getString(R.string.feed_groups_header_title)
+ "/" + ((Tab.FeedGroupTab) tab).getFeedGroupName();
default:
return tab.getTabName(requireContext());
}

View file

@ -93,6 +93,8 @@ public abstract class Tab {
return new ChannelTab(jsonObject);
case PLAYLIST:
return new PlaylistTab(jsonObject);
case FEEDGROUP:
return new FeedGroupTab(jsonObject);
}
}
@ -162,7 +164,8 @@ public abstract class Tab {
HISTORY(new HistoryTab()),
KIOSK(new KioskTab()),
CHANNEL(new ChannelTab()),
PLAYLIST(new PlaylistTab());
PLAYLIST(new PlaylistTab()),
FEEDGROUP(new FeedGroupTab());
private final Tab tab;
@ -458,7 +461,7 @@ public abstract class Tab {
final ChannelTab other = (ChannelTab) obj;
return super.equals(obj)
&& channelServiceId == other.channelServiceId
&& channelUrl.equals(other.channelName)
&& channelUrl.equals(other.channelUrl)
&& channelName.equals(other.channelName);
}
@ -652,4 +655,93 @@ public abstract class Tab {
return playlistType;
}
}
public static class FeedGroupTab extends Tab {
public static final int ID = 9;
private static final String JSON_FEED_GROUP_ID_KEY = "feed_group_id";
private static final String JSON_FEED_GROUP_NAME_KEY = "feed_group_name";
private static final String JSON_FEED_GROUP_ICON_KEY = "feed_group_icon";
private Long feedGroupId;
private String feedGroupName;
private int iconId;
private FeedGroupTab() {
this((long) -1, NO_NAME, R.drawable.ic_asterisk);
}
public FeedGroupTab(final Long feedGroupId, final String feedGroupName,
final int iconId) {
this.feedGroupId = feedGroupId;
this.feedGroupName = feedGroupName;
this.iconId = iconId;
}
public FeedGroupTab(final JsonObject jsonObject) {
super(jsonObject);
}
@Override
public int getTabId() {
return ID;
}
@Override
public String getTabName(final Context context) {
return context.getString(R.string.fragment_feed_title);
}
@DrawableRes
@Override
public int getTabIconRes(final Context context) {
return this.iconId;
}
@Override
public FeedFragment getFragment(final Context context) {
return FeedFragment.newInstance(feedGroupId, feedGroupName);
}
@Override
protected void writeDataToJson(final JsonStringWriter writerSink) {
writerSink.value(JSON_FEED_GROUP_ID_KEY, feedGroupId)
.value(JSON_FEED_GROUP_NAME_KEY, feedGroupName)
.value(JSON_FEED_GROUP_ICON_KEY, iconId);
}
@Override
protected void readDataFromJson(final JsonObject jsonObject) {
feedGroupId = jsonObject.getLong(JSON_FEED_GROUP_ID_KEY, -1);
feedGroupName = jsonObject.getString(JSON_FEED_GROUP_NAME_KEY, NO_NAME);
iconId = jsonObject.getInt(JSON_FEED_GROUP_ICON_KEY, R.drawable.ic_asterisk);
}
@Override
public boolean equals(final Object obj) {
if (!(obj instanceof FeedGroupTab)) {
return false;
}
final FeedGroupTab other = (FeedGroupTab) obj;
return super.equals(obj)
&& feedGroupId.equals(other.feedGroupId)
&& feedGroupName.equals(other.feedGroupName)
&& iconId == other.iconId;
}
@Override
public int hashCode() {
return Objects.hash(getTabId(), feedGroupId, feedGroupName, iconId);
}
public Long getFeedGroupId() {
return feedGroupId;
}
public String getFeedGroupName() {
return feedGroupName;
}
public int getIconId() {
return iconId;
}
}
}

View file

@ -24,7 +24,11 @@ public class SrtFromTtmlWriter {
private final boolean ignoreEmptyFrames;
private final Charset charset = StandardCharsets.UTF_8;
private int frameIndex = 0;
// According to the SubRip (.srt) specification, subtitle
// numbering must start from 1.
// Some players accept 0 or even negative indices,
// but to ensure compliance we start at 1.
private int frameIndex = 1;
public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) {
this.out = out;
@ -39,7 +43,8 @@ public class SrtFromTtmlWriter {
private void writeFrame(final String begin, final String end, final StringBuilder text)
throws IOException {
writeString(String.valueOf(frameIndex++));
writeString(String.valueOf(frameIndex));
frameIndex += 1;
writeString(NEW_LINE);
writeString(begin);
writeString(" --> ");

View file

@ -24,6 +24,7 @@ public final class ChannelTabHelper {
switch (tab) {
case ChannelTabs.VIDEOS:
case ChannelTabs.TRACKS:
case ChannelTabs.LIKES:
case ChannelTabs.SHORTS:
case ChannelTabs.LIVESTREAMS:
return true;
@ -62,6 +63,8 @@ public final class ChannelTabHelper {
return R.string.show_channel_tabs_playlists;
case ChannelTabs.ALBUMS:
return R.string.show_channel_tabs_albums;
case ChannelTabs.LIKES:
return R.string.show_channel_tabs_likes;
default:
return -1;
}
@ -78,6 +81,8 @@ public final class ChannelTabHelper {
return R.string.fetch_channel_tabs_shorts;
case ChannelTabs.LIVESTREAMS:
return R.string.fetch_channel_tabs_livestreams;
case ChannelTabs.LIKES:
return R.string.fetch_channel_tabs_likes;
default:
return -1;
}
@ -100,6 +105,8 @@ public final class ChannelTabHelper {
return R.string.channel_tab_playlists;
case ChannelTabs.ALBUMS:
return R.string.channel_tab_albums;
case ChannelTabs.LIKES:
return R.string.channel_tab_likes;
default:
return R.string.unknown_content;
}

View file

@ -17,6 +17,7 @@ import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.webkit.CookieManager;
import androidx.annotation.Dimension;
import androidx.annotation.NonNull;
@ -335,4 +336,17 @@ public final class DeviceUtils {
&& !TX_50JXW834
&& !HMB9213NW;
}
/**
* @return whether the device has support for WebView, see
* <a href="https://stackoverflow.com/a/69626735">https://stackoverflow.com/a/69626735</a>
*/
public static boolean supportsWebView() {
try {
CookieManager.getInstance();
return true;
} catch (final Throwable ignored) {
return false;
}
}
}

View file

@ -52,6 +52,14 @@ public final class KioskTranslator {
return c.getString(R.string.featured);
case "Radio":
return c.getString(R.string.radio);
case "trending_gaming":
return c.getString(R.string.trending_gaming);
case "trending_music":
return c.getString(R.string.trending_music);
case "trending_movies_and_shows":
return c.getString(R.string.trending_movies);
case "trending_podcasts_episodes":
return c.getString(R.string.trending_podcasts);
default:
return kioskId;
}
@ -77,6 +85,14 @@ public final class KioskTranslator {
return R.drawable.ic_stars;
case "Radio":
return R.drawable.ic_radio;
case "trending_gaming":
return R.drawable.ic_videogame_asset;
case "trending_music":
return R.drawable.ic_music_note;
case "trending_movies_and_shows":
return R.drawable.ic_movie;
case "trending_podcasts_episodes":
return R.drawable.ic_podcasts;
default:
return 0;
}

View file

@ -322,7 +322,7 @@ public final class ListHelper {
}
// Sort collected streams by name
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator())
.collect(Collectors.toList());
}
@ -359,7 +359,7 @@ public final class ListHelper {
}
// Sort tracks alphabetically, sort track streams by quality
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator();
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
return collectedStreams.values().stream()
@ -867,12 +867,10 @@ public final class ListHelper {
* Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types
* for alphabetical sorting.
*
* @param context app context for localization
* @return Comparator
*/
private static Comparator<AudioStream> getAudioTrackNameComparator(
@NonNull final Context context) {
final Locale appLoc = Localization.getAppLocale(context);
private static Comparator<AudioStream> getAudioTrackNameComparator() {
final Locale appLoc = Localization.getAppLocale();
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))

View file

@ -5,19 +5,21 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.icu.text.CompactDecimalFormat;
import android.os.Build;
import android.text.BidiFormatter;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.DisplayMetrics;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.math.MathUtils;
import androidx.core.os.LocaleListCompat;
import androidx.preference.PreferenceManager;
import org.ocpsoft.prettytime.PrettyTime;
@ -63,6 +65,7 @@ import java.util.stream.Collectors;
*/
public final class Localization {
private static final String TAG = Localization.class.toString();
public static final String DOT_SEPARATOR = "";
private static PrettyTime prettyTime;
@ -80,6 +83,20 @@ public final class Localization {
.collect(Collectors.joining(delimiter));
}
/**
* Localize a user name like <code>@foobar</code>.
*
* Will correctly handle right-to-left usernames by using a {@link BidiFormatter}.
* For right-to-left usernames, it will put the @ on the right side to read more naturally.
*
* @param plainName username, with an optional leading <code>@</code>
* @return a usernames that can include RTL-characters
*/
@NonNull
public static String localizeUserName(final String plainName) {
return BidiFormatter.getInstance().unicodeWrap(plainName);
}
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
final Context context) {
return org.schabi.newpipe.extractor.localization.Localization
@ -100,35 +117,34 @@ public final class Localization {
return getLocaleFromPrefs(context, R.string.content_language_key);
}
public static Locale getAppLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.app_language_key);
public static Locale getAppLocale() {
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
return customLocale != null ? customLocale : Locale.getDefault();
}
public static String localizeNumber(@NonNull final Context context, final long number) {
return localizeNumber(context, (double) number);
public static String localizeNumber(final long number) {
return localizeNumber((double) number);
}
public static String localizeNumber(@NonNull final Context context, final double number) {
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
return nf.format(number);
public static String localizeNumber(final double number) {
return NumberFormat.getInstance(getAppLocale()).format(number);
}
public static String formatDate(@NonNull final Context context,
@NonNull final OffsetDateTime offsetDateTime) {
public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(getAppLocale(context)).format(offsetDateTime
.atZoneSameInstant(ZoneId.systemDefault()));
.withLocale(getAppLocale())
.format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()));
}
@SuppressLint("StringFormatInvalid")
public static String localizeUploadDate(@NonNull final Context context,
@NonNull final OffsetDateTime offsetDateTime) {
return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
return context.getString(R.string.upload_date_text, formatDate(offsetDateTime));
}
public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
localizeNumber(context, viewCount));
localizeNumber(viewCount));
}
public static String localizeStreamCount(@NonNull final Context context,
@ -142,7 +158,7 @@ public final class Localization {
return context.getResources().getString(R.string.more_than_100_videos);
default:
return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount,
localizeNumber(context, streamCount));
localizeNumber(streamCount));
}
}
@ -163,27 +179,33 @@ public final class Localization {
public static String localizeWatchingCount(@NonNull final Context context,
final long watchingCount) {
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
localizeNumber(context, watchingCount));
localizeNumber(watchingCount));
}
public static String shortCount(@NonNull final Context context, final long count) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return CompactDecimalFormat.getInstance(getAppLocale(context),
return CompactDecimalFormat.getInstance(getAppLocale(),
CompactDecimalFormat.CompactStyle.SHORT).format(count);
}
final double value = (double) count;
if (count >= 1000000000) {
return localizeNumber(context, round(value / 1000000000))
+ context.getString(R.string.short_billion);
final double shortenedValue = value / 1000000000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_billion,
localizeNumber(round(shortenedValue, scale)));
} else if (count >= 1000000) {
return localizeNumber(context, round(value / 1000000))
+ context.getString(R.string.short_million);
final double shortenedValue = value / 1000000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_million,
localizeNumber(round(shortenedValue, scale)));
} else if (count >= 1000) {
return localizeNumber(context, round(value / 1000))
+ context.getString(R.string.short_thousand);
final double shortenedValue = value / 1000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_thousand,
localizeNumber(round(shortenedValue, scale)));
} else {
return localizeNumber(context, value);
return localizeNumber(value);
}
}
@ -308,7 +330,7 @@ public final class Localization {
* <ul>
* <li>English (original)</li>
* <li>English (descriptive)</li>
* <li>Spanish (dubbed)</li>
* <li>Spanish (Spain) (dubbed)</li>
* </ul>
*
* @param context the context used to get the app language
@ -318,7 +340,7 @@ public final class Localization {
public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
final String name;
if (track.getAudioLocale() != null) {
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
name = track.getAudioLocale().getDisplayName();
} else if (track.getAudioTrackName() != null) {
name = track.getAudioTrackName();
} else {
@ -353,8 +375,8 @@ public final class Localization {
prettyTime.removeUnit(Decade.class);
}
public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
return new PrettyTime(getAppLocale(context));
public static PrettyTime resolvePrettyTime() {
return new PrettyTime(getAppLocale());
}
public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
@ -372,9 +394,10 @@ public final class Localization {
* {@code parsed != null} and the relevant setting is enabled, {@code textual} will
* be appended to the returned string for debugging purposes.
*/
@Nullable
public static String relativeTimeOrTextual(@Nullable final Context context,
@Nullable final DateWrapper parsed,
final String textual) {
@Nullable final String textual) {
if (parsed == null) {
return textual;
} else if (DEBUG && context != null && PreferenceManager
@ -386,14 +409,6 @@ public final class Localization {
}
}
public static void assureCorrectAppLanguage(final Context c) {
final Resources res = c.getResources();
final DisplayMetrics dm = res.getDisplayMetrics();
final Configuration conf = res.getConfiguration();
conf.setLocale(getAppLocale(c));
res.updateConfiguration(conf, dm);
}
private static Locale getLocaleFromPrefs(@NonNull final Context context,
@StringRes final int prefKey) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
@ -407,8 +422,8 @@ public final class Localization {
}
}
private static double round(final double value) {
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
private static double round(final double value, final int scale) {
return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
}
private static String getQuantity(@NonNull final Context context,
@ -427,4 +442,35 @@ public final class Localization {
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
}
// Starting with pull request #12093, NewPipe exclusively uses Android's
// public per-app language APIs to read and set the UI language for NewPipe.
// The following code will migrate any existing custom app language in SharedPreferences to
// use the public per-app language APIs instead.
// For reference, see
// https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html
public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) {
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
final String appLanguageKey = context.getString(R.string.app_language_key);
final String appLanguageValue = sp.getString(appLanguageKey, null);
if (appLanguageValue != null) {
// The app language key is used on Android versions < 33
// for more info, see ContentSettingsFragment
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
sp.edit().remove(appLanguageKey).apply();
}
final String appLanguageDefaultValue =
context.getString(R.string.default_localization_key);
if (!appLanguageValue.equals(appLanguageDefaultValue)) {
try {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(appLanguageValue));
} catch (final RuntimeException e) {
Log.e(TAG, "Failed to migrate previous custom app language "
+ "setting to public per-app language APIs"
);
}
}
}
}
}

View file

@ -57,8 +57,10 @@ import org.schabi.newpipe.local.subscription.SubscriptionFragment;
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
import org.schabi.newpipe.player.PlayQueueActivity;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.TimestampChangeData;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -67,6 +69,7 @@ import org.schabi.newpipe.settings.SettingsActivity;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.List;
import java.util.Optional;
public final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
@ -85,53 +88,32 @@ public final class NavigationHelper {
public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue,
final boolean resumePlayback) {
final Intent intent = new Intent(context, targetClazz);
if (playQueue != null) {
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class);
if (cacheKey != null) {
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
}
}
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
return intent;
@NonNull final PlayerIntentType playerIntentType) {
final String cacheKey = Optional.ofNullable(playQueue)
.map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class))
.orElse(null);
return new Intent(context, targetClazz)
.putExtra(Player.PLAY_QUEUE_KEY, cacheKey)
.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN)
.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
.putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType);
}
@NonNull
public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue,
final boolean resumePlayback,
final boolean playWhenReady) {
return getPlayerIntent(context, targetClazz, playQueue, resumePlayback)
.putExtra(Player.PLAY_WHEN_READY, playWhenReady);
}
@NonNull
public static <T> Intent getPlayerEnqueueIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) {
// when enqueueing `resumePlayback` is always `false` since:
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
// any difference.
// - if there is nothing already playing, it is useful for the enqueue action to have a
// slightly different behaviour than the normal play action: the latter resumes playback,
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
// by long pressing the video detail fragment, playlist or channel controls
return getPlayerIntent(context, targetClazz, playQueue, false)
.putExtra(Player.ENQUEUE, true);
public static Intent getPlayerTimestampIntent(@NonNull final Context context,
@NonNull final TimestampChangeData data) {
return new Intent(context, PlayerService.class)
.putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange)
.putExtra(Player.PLAYER_INTENT_DATA, data);
}
@NonNull
public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) {
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
return getPlayerIntent(context, targetClazz, playQueue, false)
.putExtra(Player.ENQUEUE_NEXT, true);
return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext)
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
.putExtra(Player.RESUME_PLAYBACK, false);
}
/* PLAY */
@ -165,8 +147,10 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
final var intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
ContextCompat.startForegroundService(context, intent);
}
@ -176,8 +160,10 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
.show();
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
ContextCompat.startForegroundService(context, intent);
}
@ -190,9 +176,18 @@ public final class NavigationHelper {
}
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
// when enqueueing `resumePlayback` is always `false` since:
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
// any difference.
// - if there is nothing already playing, it is useful for the enqueue action to have a
// slightly different behaviour than the normal play action: the latter resumes playback,
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
// by long pressing the video detail fragment, playlist or channel controls
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.Enqueue)
.putExtra(Player.RESUME_PLAYBACK, false)
.putExtra(Player.PLAYER_TYPE, playerType);
ContextCompat.startForegroundService(context, intent);
}
@ -214,9 +209,8 @@ public final class NavigationHelper {
playerType = PlayerType.AUDIO;
}
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue)
.putExtra(Player.PLAYER_TYPE, playerType);
ContextCompat.startForegroundService(context, intent);
}
@ -452,8 +446,12 @@ public final class NavigationHelper {
if (fragment instanceof VideoDetailFragment && fragment.isVisible()) {
onVideoDetailFragmentReady.run((VideoDetailFragment) fragment);
} else {
// Specify no url here, otherwise the VideoDetailFragment will start loading the
// stream automatically if it's the first time it is being opened, but then
// onVideoDetailFragmentReady will kick in and start another loading process.
// See VideoDetailFragment.wasCleared() and its usage in doInitialLoadLogic().
final VideoDetailFragment instance = VideoDetailFragment
.getInstance(serviceId, url, title, playQueue);
.getInstance(serviceId, null, title, playQueue);
instance.setAutoPlay(autoPlay);
defaultTransaction(fragmentManager)

View file

@ -9,9 +9,11 @@ import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.text.Html;
import android.widget.Toast;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
@ -113,14 +115,47 @@ public final class PermissionHelper {
@RequiresApi(api = Build.VERSION_CODES.M)
public static boolean checkSystemAlertWindowPermission(final Context context) {
if (!Settings.canDrawOverlays(context)) {
final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + context.getPackageName()));
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(i);
} catch (final ActivityNotFoundException ignored) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + context.getPackageName()));
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(i);
} catch (final ActivityNotFoundException ignored) {
}
return false;
// from Android R the ACTION_MANAGE_OVERLAY_PERMISSION will only point to the menu,
// so lets add a dialog that points the user to the right setting.
} else {
final String appName = context.getApplicationInfo()
.loadLabel(context.getPackageManager()).toString();
final String title = context.getString(R.string.permission_display_over_apps);
final String permissionName =
context.getString(R.string.permission_display_over_apps_permission_name);
final String appNameItalic = "<i>" + appName + "</i>";
final String permissionNameItalic = "<i>" + permissionName + "</i>";
final String message =
context.getString(R.string.permission_display_over_apps_message,
appNameItalic,
permissionNameItalic
);
new AlertDialog.Builder(context)
.setTitle(title)
.setMessage(Html.fromHtml(message, Html.FROM_HTML_MODE_COMPACT))
.setPositiveButton("OK", (dialog, which) -> {
// we dont need the package name here, since it wont do anything on >R
final Intent intent =
new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
try {
context.startActivity(intent);
} catch (final ActivityNotFoundException ignored) {
}
})
.setCancelable(true)
.show();
return false;
}
return false;
} else {
return true;
}

View file

@ -121,7 +121,7 @@ public final class SparseItemUtil {
callback.accept(result);
}, throwable -> ErrorUtil.createNotification(context,
new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
"Loading stream info: " + url, serviceId)
"Loading stream info: " + url, serviceId, url)
));
}
}

View file

@ -5,10 +5,9 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Build;
@ -23,6 +22,7 @@ import androidx.core.content.FileProvider;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.R;
import org.schabi.newpipe.RouterActivity;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
@ -62,8 +62,9 @@ public final class ShareUtils {
}
/**
* Open the url with the system default browser. If no browser is set as default, falls back to
* {@link #openAppChooser(Context, Intent, boolean)}.
* Open the url with the system default browser. If no browser is installed, falls back to
* {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available
* to handle the action, or possible OEM-related edge cases).
* <p>
* This function selects the package to open based on which apps respond to the {@code http://}
* schema alone, which should exclude special non-browser apps that are can handle the url (e.g.
@ -77,44 +78,26 @@ public final class ShareUtils {
* @param url the url to browse
**/
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Target a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Note that this requires the `http` schema to be added to `<queries>` in the manifest.
final ResolveInfo defaultBrowserInfo;
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
} else {
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
PackageManager.MATCH_DEFAULT_ONLY);
}
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (defaultBrowserInfo == null) {
// No app installed to open a web URL, but it may be handled by other apps so try
// opening a system chooser for the link in this case (it could be bypassed by the
// system if there is only one app which can open the link or a default app associated
// with the link domain on Android 12 and higher)
// See https://stackoverflow.com/a/58801285 and `setSelector` documentation
intent.setSelector(browserIntent);
try {
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// No browser is available. This should, in the end, yield a nice AOSP error message
// indicating that no app is available to handle this action.
//
// Note: there are some situations where modified OEM ROMs have apps that appear
// to be browsers but are actually app choosers. If starting the Activity fails
// related to this, opening the system app chooser is still the correct behavior.
intent.setSelector(null);
openAppChooser(context, intent, true);
return;
}
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
if (defaultBrowserPackage.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
try {
intent.setPackage(defaultBrowserPackage);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
}
@ -190,6 +173,18 @@ public final class ShareUtils {
chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with));
}
// Avoid opening in NewPipe
// (Implementation note: if the URL is one for which NewPipe itself
// is set as handler on Android >= 12, we actually remove the only eligible app
// for this link, and browsers will not be offered to the user. For that, use
// `openUrlInBrowser`.)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
chooserIntent.putExtra(
Intent.EXTRA_EXCLUDE_COMPONENTS,
new ComponentName[]{new ComponentName(context, RouterActivity.class)}
);
}
// Migrate any clip data and flags from the original intent.
final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION

View file

@ -0,0 +1,113 @@
package org.schabi.newpipe.util.potoken
import com.grack.nanojson.JsonObject
import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonWriter
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
/**
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
* embedded in a JavaScript snippet.
*/
fun parseChallengeData(rawChallengeData: String): String {
val scrambled = JsonParser.array().from(rawChallengeData)
val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) {
val descrambled = descramble(scrambled.getString(1))
JsonParser.array().from(descrambled)
} else {
scrambled.getArray(0)
}
val messageId = challengeData.getString(0)
val interpreterHash = challengeData.getString(3)
val program = challengeData.getString(4)
val globalName = challengeData.getString(5)
val clientExperimentsStateBlob = challengeData.getString(7)
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String }
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String }
return JsonWriter.string(
JsonObject.builder()
.value("messageId", messageId)
.`object`("interpreterJavascript")
.value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue)
.value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)
.end()
.value("interpreterHash", interpreterHash)
.value("program", program)
.value("globalName", globalName)
.value("clientExperimentsStateBlob", clientExperimentsStateBlob)
.done()
)
}
/**
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
* duration of this token in seconds.
*/
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData)
return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1)
}
/**
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
* `Uint8Array` that can be embedded directly in JavaScript code.
*/
fun stringToU8(identifier: String): String {
return newUint8Array(identifier.toByteArray())
}
/**
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
* and converts it to the specific base64 representation for poTokens.
*/
fun u8ToBase64(poToken: String): String {
return poToken.split(",")
.map { it.toUByte().toByte() }
.toByteArray()
.toByteString()
.base64()
.replace("+", "-")
.replace("/", "_")
}
/**
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
*/
private fun descramble(scrambledChallenge: String): String {
return base64ToByteString(scrambledChallenge)
.map { (it + 97).toByte() }
.toByteArray()
.decodeToString()
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
*/
private fun base64ToU8(base64: String): String {
return newUint8Array(base64ToByteString(base64))
}
private fun newUint8Array(contents: ByteArray): String {
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
}
/**
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
*/
private fun base64ToByteString(base64: String): ByteArray {
val base64Mod = base64
.replace('-', '+')
.replace('_', '/')
.replace('.', '=')
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
.toByteArray()
}

View file

@ -0,0 +1,13 @@
package org.schabi.newpipe.util.potoken
class PoTokenException(message: String) : Exception(message)
// to be thrown if the WebView provided by the system is broken
class BadWebViewException(message: String) : Exception(message)
fun buildExceptionForJsError(error: String): Exception {
return if (error.contains("SyntaxError"))
BadWebViewException(error)
else
PoTokenException(error)
}

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