Compare commits

..
444 Commits
Author SHA1 Message Date
Isira Seneviratne 52886c235a [Refactor] Convert playback buttons to segmented button (#13669)
Add system overlay permission check
2026-08-01 16:20:44 +00:00
TobiGr 80d86b2b51 Delete translations of removed string resources 2026-08-01 18:09:01 +02:00
TobiGr 6f7276759b Update NewPipe Extractor to v0.26.4 2026-08-01 17:57:53 +02:00
tobigr 8065b6cc3f Move string resource to comments section 2026-07-30 16:15:28 +02:00
Abhinav kumar 271edd826e Feature: Add "(edited)" indicator to modified comments (#13604) 2026-07-30 07:10:05 -07:00
Thompson3142 3a3eac0cf2 Improve look of search menu "titles" (#13605) 2026-07-30 05:26:26 -07:00
Isira Seneviratne fe084598ba Merge pull request #11947 from Isira-Seneviratne/History-Compose
Migrate watch history to Jetpack Compose
2026-07-10 05:49:58 +05:30
Isira Seneviratne 9ddfb81bf4 Fix compile errors 2026-06-21 11:54:29 +05:30
Isira Seneviratne 01c565e4f7 Merge branch 'refactor' into History-Compose
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
#	app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
#	app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
#	app/src/main/res/values-ca/strings.xml
#	app/src/main/res/values-eu/strings.xml
#	app/src/main/res/values-in/strings.xml
#	app/src/main/res/values-lv/strings.xml
2026-06-18 07:49:10 +05:30
Isira Seneviratne 079f5f1d70 Merge pull request #13598 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2026-06-13 21:43:35 +05:30
Isira Seneviratne 0334074931 Fix compile error 2026-06-13 21:17:36 +05:30
Isira Seneviratne c24cc85743 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	.gitignore
#	app/build.gradle.kts
#	app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
#	app/src/main/res/values-in/strings.xml
#	app/src/main/res/values-nl/strings.xml
#	build.gradle.kts
#	gradle/libs.versions.toml
2026-06-13 21:09:35 +05:30
Isira Seneviratne 84142c1468 Merge pull request #13596 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2026-06-13 12:43:21 +05:30
Isira Seneviratne 2bbd439e9a Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	build.gradle.kts
#	gradle/libs.versions.toml
2026-06-13 06:52:40 +05:30
Aayush Gupta a44e65e709 Merge pull request #13448 from TeamNewPipe/comment-crash
fix app crash on loading comment section with no internet
2026-04-24 17:48:39 +08:00
Ida Delphine 188ba0a462 refactor if statements to when block 2026-04-24 07:44:34 +01:00
Aayush Gupta 7b98b4058a Merge pull request #13447 from TeamNewPipe/about-activity
remove AboutActivity
2026-04-24 10:23:25 +08:00
Ida Delphine 78f9fbadb7 fix app crash on loading comment with no internet 2026-04-24 00:22:03 +01:00
Ida Delphine 2ebb0dc114 remove AboutActivity 2026-04-23 16:28:08 +01:00
Ida Delphine 9bd9a5c6e9 Convert ErrorActivity to compose (#13434)
* add error screen compasable

* add error activity

* create separate privacy policy dialog

* move base activity

* refactor error activity and tiny tweaks

* add new compose activity and navdisplay

* refactor app to utilize new compose activity

* clean up arguments in the ErrorReportScreen

* get rid of redundant colors

* tackle review issues

* rename getErrorActivityIntent
2026-04-23 07:16:06 +00:00
Aayush Gupta ee25b44170 Merge pull request #13445 from TeamNewPipe/mergeDev
Merge dev into refactor
2026-04-23 14:56:52 +08:00
Aayush Gupta 43f40c485d gradle: Build with JDK 21
Checkstyle was already requiring JDK 21 and now about libraries need it too

Use the kotlin extension method as it will also configure it for java code

Ref: https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-04-23 14:31:45 +08:00
Aayush Gupta c992b85b25 libs: Update compose-related libraries to latest release
The about libraries gradle plugin does merge of duplicate by defaults.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-04-23 12:09:16 +08:00
Aayush Gupta 49354a3c04 fixup! Relocate toml lint task to buildSrc and extend against default task
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-04-21 10:42:21 +08:00
Aayush Gupta bfefd43700 Merge branch 'dev' into refactor 2026-04-21 10:42:09 +08:00
Tobi dd917ca240 Merge pull request #13418 from Ecomont/fix/offline-comments-crash
Fix crash when loading offline comments in CommentRepliesSource
2026-04-14 23:21:00 +02:00
Tobi ae03b4f2af Merge pull request #13420 from TeamNewPipe/mergeDev
Merge dev into refactor
2026-04-14 23:17:57 +02:00
Aayush Gupta 53d8d4d4da Merge branch 'dev' into refactor 2026-04-14 22:23:49 +08:00
h 064458cccd Fix crash when loading offline comments in CommentRepliesSource 2026-04-14 06:35:35 +02:00
Ida Delphine 44bf345ba9 Merge pull request #13333 from evermind-zz/rework-video-quality-representation-and-stream-selection_refactor
Rework video quality representation and stream selection refactor
Added unit tests for ListHelper
2026-04-03 16:22:27 +01:00
Ida Delphine 0e2e94dcb9 add unit tests for ListHelper 2026-04-03 14:34:47 +01:00
Ida Delphine 67b6f0a10b refactor ListHelper Kotlin methods 2026-04-03 09:58:17 +01:00
evermind 274cb7c170 ListHelper: rework video quality representation and VideoStream selection
Replace the previous string-based resolution/fps/etc. handling for VideoStreams
by parsing the 'getResolution()'-string to a data class to improve maintainability.

Video resolutions (e.g. "720p60") were previously compared and matched using
regex-based string manipulation all over the place. This was fragile,
required repeated parsing and made it difficult to extend the matching logic.

It can handle all the strings like before: "720p", "720p60"

Additionally this patch can also handle strings like: "720p60@123k", or "720p@2m"

Main changes:
- Introduce internal data class VideoQuality (resolution, fps, bitrate, formatRank).
- Introduce internal VideoStreamWithQuality to associate streams with parsed quality.
- Parse quality only once.
- Replace resolution string sorting with a comparator based on
  resolution -> fps -> formatRank -> bitrate.
- Rework stream selection to use a priority-based matching algorithm
  (format, resolution, fps, bitrate).
- Prefer candidates with bitrate closest to the requested one when multiple
  streams match the same priority class.
2026-04-02 10:29:40 +01:00
evermind 62e66326a5 convert ListHelper to kotlin 2026-04-02 10:29:40 +01:00
Ida Delphine 25c12ef81e Merge pull request #12803 from HatakeKakashri/debug_screen_migration
Complete the debug screen migration to Compose
2026-03-30 09:27:48 +01:00
Ida Delphine d33ba8ac75 refactor settings screen, debug, viewmodels, etc 2026-03-30 03:06:31 +01:00
Ida Delphine fbfa1fd2b1 refactor navigation and settings screens 2026-03-30 01:17:14 +01:00
Ida Delphine 881792ebda add navigation keys 2026-03-29 23:57:01 +01:00
Hatake Kakashri de344100db Complete the debug screen migration to Compose with Nav3
- Completed the UI and logic for pending items of Debug screen using Jetpack Compose.
- Implemented a new navigation system for settings using the navigation3
  lib as it is now stable.
- Reuses the `ScaffoldWithToolbar` composable and removed the previous
  `Toolbar` composable to avoid redundancy in code.
- Refactored the `SettingsViewModel` to use a `BooleanPreference` helper
  class to reuse and reducing boilerplate for state management.
- Created a shared `TextBase` composable to remove duplicated UI logic
  between `SwitchPreference` and `TextPreference`.
- Move the build-variant-dependent logic for LeakCanary and reused it in
  Compose and Fragment, this logic is used for ensuring the leak canary
  fields are only enabled in debug variants.
- Fixed a layout bug in `SwitchPreference` where long summary text could
  misalign the switch component and also adjusted the paddings for consistency.
2026-03-28 20:08:07 +00:00
Ida Delphine 5fafccba33 Merge pull request #12862 from guymarshall/change-2k-to-2.5k-to-fix-common-mistake
UPDATE: Replace 2k with 1440p
2026-03-27 11:30:22 +00:00
Ida Delphine 5e9c24a7b3 fix duplicate PreferenceSearchItem and extractor hash issue 2026-03-27 11:08:35 +00:00
Guy Marshall c85655f5bb UPDATE: Replace 2.5k and 2.5K with 1440p to match desktop website 2026-03-27 09:59:39 +00:00
Guy Marshall dfe12222ff UPDATE: Replace 2k and 2K with 2.5k and 2.5K to fix common mistake 2026-03-27 09:58:42 +00:00
Tobi d0a0b4a541 Merge pull request #13357 from WSU-CptS-481-Spring-2026/bug/refactor/13248-fix-no-comments-scroll
[Refactor] bugfix: make comment section take up full vertical space
2026-03-17 17:04:29 +01:00
Aayush Gupta 00208fa8bd Merge pull request #13250 from dustdfg/refactor_tablet_related_items
VideoDetailFragment: hide relatedItemsLayout in tablet mode after fullscreen
2026-03-15 19:22:53 +08:00
Evan Glasscock e0a69e8840 Make comment section take up full vertical space 2026-03-08 18:05:24 -07:00
Tobi 4c82e8bafd Merge pull request #13278 from theimpulson/merge
Merge dev into refactor
2026-02-22 11:43:04 -08:00
Aayush Gupta 064e53221e Merge branch 'dev' into refactor 2026-02-22 19:45:09 +08:00
Clippy 8b28bd1a18 Merge pull request #13258 from dustdfg/regression-detailfragment-flickers-refactor
Backport #13132 to refactor branch
2026-02-19 08:58:17 +00:00
Yevhen Babiichuk (DustDFG) b6c50ccca5 Partial revert: fix VideoDetailFragment flickering
Refacor port of: 5155b24ed6
Look at #13132 for more details
2026-02-16 15:16:58 +02:00
Tobi 3a42827320 Merge pull request #13245 from evermind-zz/add-subscription-import-export-to-backup-restore-settings_refactor
BackupRestoreSettingsFragment: add UI options to import/export subsciptions
2026-02-15 04:03:32 -08:00
Yevhen Babiichuk (DustDFG) dd651ecc45 VideoDetailFragment: hide relatedItemsLayout in tablet mode after fullscreen
Fixes: #7617
2026-02-14 08:11:23 +02:00
evermind 941edd7aef BackupRestoreSettingsFragment: add UI options to import/export subscriptions
* create SubscriptionsImportExportHelper to share common code used in
  SubscriptionFragment and BackupRestoreSettingsFragment
* Add UI options for import/export in BackupRestoreSettingsFragment
2026-02-13 09:56:23 +01:00
Stypox c2b698491b Merge pull request #13202 from TobiGr/update-refactor
Merge dev into refactor
2026-02-07 11:58:12 +01:00
tobigr aa8aa6a241 Update hilt for compatibility with newer kotlin version 2026-02-06 12:00:02 +01:00
tobigr a430517189 Re-add PlayQueueItem.RECOVERY_UNSET
This was removed in #12347 / 31f8dd05a7
2026-02-06 11:51:15 +01:00
tobigr e006328bb1 Merge branch 'dev' into refactor 2026-02-06 11:48:59 +01:00
Stypox d859a5edc8 Merge pull request #13138 from Isira-Seneviratne/Merge-dev-to-refactor 2026-02-02 20:20:28 +01:00
Stypox f63ea4aa93 Fix ErrorInfoTest after changes to exception classifications 2026-02-02 19:54:55 +01:00
Stypox abf9a80715 Fix ErrorPanelTest after changes to exception classifications 2026-02-02 19:52:17 +01:00
Isira Seneviratne 60615e6b9e Fix compile errors 2026-02-02 05:48:22 +05:30
Isira Seneviratne 51a749a7f1 Merge branch 'refactor' into History-Compose
# Conflicts:
#	app/src/androidTest/java/org/schabi/newpipe/database/FeedDAOTest.kt
#	app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
#	app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
#	app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
#	app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseDialog.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt
2026-01-29 19:56:59 +05:30
Isira Seneviratne 35315d02ef Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/player/Player.java
#	app/src/main/res/values-eu/strings.xml
#	gradle/libs.versions.toml
2026-01-29 19:39:21 +05:30
Tobi 50b9a7b7f6 Merge pull request #13121 from theimpulson/forwardport
Merge dev into refactor
2026-01-28 08:43:36 -08:00
Tobi 4b424bc6bd Fix comment 2026-01-28 16:56:22 +01:00
Aayush Gupta 2376a83e0c Fix all ktlint violations and merge issues
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-26 23:08:33 +08:00
Aayush Gupta 394a7f68cd Merge branch 'dev' into refactor 2026-01-26 22:53:17 +08:00
Aayush Gupta 61c25d4589 Merge pull request #13056 from HatakeKakashri/bump_min_sdk_23
Bump minimum SDK version to API 23
2026-01-21 11:32:46 +08:00
Aayush Gupta b84395dea0 Remove non-required API M version checks
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
2026-01-19 14:39:33 +08:00
Hatake Kakashri 0b43dd2a62 Bump minimum SDK version to API 23
- Updated the `minSdk` in `app/build.gradle.kts`.
- Adjusted the api-level for the test-android min sdk CI workflow
  in `.github/workflows/ci.yml`.
- Simplified the `getOsString()` method in `ErrorActivity.java` by removing
  the conditional check for `Build.VERSION_CODES.M`, as API 23 is now the minimum.
2026-01-13 22:12:50 +05:30
tobigr f5245eac91 Merge branch 'dev' into refactor 2026-01-09 14:09:14 +01:00
Mira 3369607b74 Feat: opus metadata encoding (#12974)
Feat: Downloading: Add opus audio metadata tags for title, author, date, and a comment tag with the originating URL

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

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

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

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

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

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

Adds a helper class `GuardedByMutex` that can be wrapped around a
property to force all use-sites to acquire the lock before doing
anything with the data.
2025-05-11 15:23:03 +02:00
Profpatsch 3d069cdf5b PlayerUIList: rename get to getOpt and make get nullable
In Kotlin, dealing with nulls works better so we don’t need optional.
2025-05-11 15:12:37 +02:00
Profpatsch eccedc0ab0 PlayerUIList: transform to kotlin
And simplify the code a little
2025-05-11 15:06:52 +02:00
Stypox 7cecda5713 Merge branch 'dev' into refactor
Had to make some adjustments to make https://github.com/TeamNewPipe/NewPipe/pull/12188 work
2025-05-08 15:34:00 +02:00
Isira Seneviratne b6144d01f3 Merge branch 'refactor' into Import-export-worker 2025-04-29 10:18:13 +05:30
Su TT df11e53a74 Refactor EmptyStateComposable to remove modifier from EmptyStateSpec and fix modifier usage ... 2025-04-28 17:24:11 -04:00
Isira Seneviratne 1d94fd1582 Merge pull request #12195 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-04-27 08:01:33 +05:30
Isira Seneviratne c9542ad6fd Update extractor 2025-04-27 07:43:52 +05:30
Isira Seneviratne cbe598182a Merge branch 'refactor' into Import-export-worker 2025-04-20 07:10:07 +05:30
Isira Seneviratne 27a29aefc2 Update Jetpack Compose and Room 2025-04-17 07:32:41 +05:30
Isira Seneviratne 7615f79aca Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
2025-04-14 07:29:30 +05:30
Isira Seneviratne 451c89b8e2 Merge branch 'refactor' into History-Compose
# Conflicts:
#	app/src/main/res/values-be/strings.xml
2025-04-10 11:29:02 +05:30
Stypox 47299c9184 Merge pull request #12164 from Isira-Seneviratne/Merge-dev-to-refactor
Merge dev to refactor
2025-04-08 10:55:28 +02:00
Isira Seneviratne 6486f2de56 Merge branch 'dev' into Merge-dev-to-refactor
# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
#	app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
#	app/src/main/java/org/schabi/newpipe/player/PlayerService.java
#	app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java
#	app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
#	app/src/main/res/values-is/strings.xml
2025-04-08 05:42:31 +05:30
Isira Seneviratne 287db90f62 Sort using last played by default 2025-03-31 07:15:36 +05:30
Isira Seneviratne 6f9af2638b Update dependencies 2025-03-26 07:57:30 +05:30
Isira Seneviratne fe563f3307 Improve DAO method calls 2025-03-26 07:57:20 +05:30
Isira Seneviratne e00e2562fb Rename .java to .kt 2025-03-26 07:57:19 +05:30
Isira Seneviratne a58b0da2c1 Fix actions 2025-03-25 07:42:10 +05:30
Isira Seneviratne 54c2b499af Restore clear menu 2025-03-25 05:48:42 +05:30
Isira Seneviratne 2bafa2c715 Add loading indicator in ItemList 2025-03-24 07:10:18 +05:30
Isira Seneviratne 033d288077 Use "Sort By" label, remove outdated translations 2025-03-16 12:49:16 +05:30
Isira Seneviratne 851ba4bf1d Use segmented button for sort options 2025-03-11 11:04:58 +05:30
Isira Seneviratne 975ba3c067 Reduce button size, fix alignment 2025-03-08 19:13:31 +05:30
Isira Seneviratne d805679a5e Use workDataOf 2025-03-08 09:18:30 +05:30
Isira Seneviratne e877997603 Improve constructors 2025-03-06 06:11:49 +05:30
Stypox c1bdffd917 Merge pull request #11978 from Profpatsch/fix-back-button-on-remaining-stack
MainActivity: Fix onBackPressed handling for open player
2025-02-26 16:56:04 +01:00
Isira Seneviratne abaf16e12b Merge branch 'refactor' into Import-export-worker
# Conflicts:
#	gradle/libs.versions.toml
2025-02-23 05:10:34 +05:30
Isira Seneviratne 491b433f32 Merge branch 'refactor' into History-Compose 2025-02-23 05:06:57 +05:30
Isira Seneviratne dbe30116e3 Remove Unknown class 2025-02-09 20:38:34 +05:30
Stypox 99aae7eb28 Merge branch 'dev' into refactor 2025-02-05 15:15:41 +01:00
Isira Seneviratne 695c05bd8e Add confirmation dialog for clearing watch history 2025-02-05 08:52:28 +05:30
Isira Seneviratne ac34ada7c9 Make DropdownTextMenuItem non-restartable 2025-02-01 05:41:02 +05:30
Isira Seneviratne 212c67838a Remove manual text color specification 2025-02-01 05:29:14 +05:30
Isira Seneviratne 61bb81f2d6 Fix black theme, remove manual background definition 2025-02-01 05:25:48 +05:30
Isira Seneviratne 6de5ce77c8 Merge branch 'refactor' into History-Compose
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
2025-02-01 05:05:07 +05:30
Profpatsch fd99c5e461 MainActivity: Fix onBackPressed handling for open player
The change
b9dd7078ad
accidentally moved the `return` into the `{}`, so the logic would fall
through to

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

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

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

This should keep the player open and go back to the first 2-video
queue, but it actually closes the app before this fix.
2025-01-30 19:40:44 +01:00
Stypox 407d2d768d Merge pull request #11539 from Isira-Seneviratne/Compose-theme-improvements
Compose theme improvements
2025-01-28 14:02:50 +01:00
Profpatsch b109e4d3cc Merge pull request #11867 from Profpatsch/player-holder-refactor
PlayerHolder refactor
2025-01-27 13:29:53 +01:00
Profpatsch 137ade24ff Adjust javadoc format 2025-01-27 12:45:30 +01:00
Isira Seneviratne 5abc03c4cf Extract thumbnail into common composable 2025-01-27 07:36:48 +05:30
Isira Seneviratne 48d682016e Rm ViewPager version 2025-01-25 11:21:58 +05:30
Isira Seneviratne 21caa6ce13 Update Compose BOM 2025-01-25 11:19:28 +05:30
Isira Seneviratne b78e0b2da8 Merge branch 'refactor' into Compose-theme-improvements 2025-01-25 09:41:29 +05:30
Isira Seneviratne 32a88ab890 Truncate existing file in export 2025-01-25 07:57:01 +05:30
Isira Seneviratne af3ed992e5 Add error handling for import 2025-01-25 05:40:00 +05:30
Isira Seneviratne a79516dfff Use fragment arguments 2025-01-25 05:30:14 +05:30
Isira Seneviratne 59a748d0ba Merge branch 'refactor' into History-Compose
# Conflicts:
#	app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
2025-01-24 09:40:28 +05:30
Profpatsch dbd11a6a8d SubscriptionImportWorker: inputs as sealed class 2025-01-22 15:34:04 +01:00
Isira Seneviratne 21973b362a Use Kotlin Pair 2025-01-22 13:03:57 +01:00
Isira Seneviratne 60586c90d6 Improve subscription upsert methods 2025-01-22 13:03:57 +01:00
Isira Seneviratne 095155d35f Only get subscription extractor when needed 2025-01-22 13:03:57 +01:00
Isira Seneviratne af8e5646a6 Remove LocalBroadcastManager 2025-01-22 13:03:57 +01:00
Isira Seneviratne c9d155a335 Combine notification and ForegroundInfo creation methods 2025-01-22 13:03:57 +01:00
Isira Seneviratne 4e31ccebf8 Moved Kotlinx Serialization to library catalog 2025-01-22 13:03:57 +01:00
Isira Seneviratne c0965a42a1 Added success toasts 2025-01-22 13:03:57 +01:00
Isira Seneviratne fc7f1b0af0 Convert subscription import service to a worker 2025-01-22 13:03:57 +01:00
Isira Seneviratne dfb035dfa5 Improve import/export tests 2025-01-22 13:03:57 +01:00
Isira Seneviratne 8e9503cfe4 Convert subscription export service to a worker 2025-01-22 13:03:55 +01:00
Isira Seneviratne 82516dd75c Rename .java to .kt 2025-01-22 13:02:36 +01:00
Stypox 3e6e980362 Merge branch 'dev' into refactor 2025-01-22 11:12:51 +01:00
Profpatsch 1890fbb19a Merge pull request #11809 from Isira-Seneviratne/Merge-dev
Merge dev to refactor
2025-01-21 17:56:00 +01:00
Isira Seneviratne bf0692377c Reuse dropdown composable 2025-01-21 11:05:33 +05:30
Isira Seneviratne 3943a87966 Fix preview issue 2025-01-21 06:23:23 +05:30
Isira Seneviratne aeb4548a5a Add delete button for history items 2025-01-21 05:59:01 +05:30
Isira Seneviratne 6621b7feaa Fix NPE 2025-01-20 11:36:53 +05:30
Isira Seneviratne fa98a92415 Cache history in view model 2025-01-20 11:08:57 +05:30
Isira Seneviratne 38645b02df Implement playback action buttons 2025-01-20 10:51:00 +05:30
Isira Seneviratne 48ad12375b Implement clear history functionality 2025-01-15 08:56:42 +05:30
Isira Seneviratne 3f1bd58751 Rm unused file 2025-01-15 08:13:29 +05:30
Isira Seneviratne 38a533f9a8 Combine ItemList code 2025-01-15 07:48:56 +05:30
Isira Seneviratne 4ec5492814 Update dependencies 2025-01-15 07:35:21 +05:30
Isira Seneviratne f99fc13326 Reuse ItemList composable 2025-01-15 07:28:42 +05:30
Isira Seneviratne 3d9394e69b Reuse stream composables 2025-01-14 14:45:11 +05:30
Isira Seneviratne 77e254b2be Remove unused classes 2025-01-14 12:44:30 +05:30
Isira Seneviratne 169eff809e Rewrite history fragment using Compose 2025-01-14 10:17:45 +05:30
Isira Seneviratne efb3aa530d Merge branch 'dev' into Merge-dev 2025-01-11 18:45:51 +05:30
Profpatsch ce919215fb PlayerHolder: Separate holder and service event interface
Should make it easier to seperate the two further later, both of them
are only implemented by VideoDetailFragment anyway, which is kind of a
code smell!
2024-12-26 01:31:17 +01:00
Profpatsch 6a4aaba431 PlayerHolder: add some more docstrings 2024-12-26 01:02:59 +01:00
Profpatsch 83d93e16e7 PlayerHolder: move unbind right next to stopService 2024-12-26 00:36:49 +01:00
Profpatsch 8d15a141b1 PlayerHolder: invert isBound 2024-12-26 00:26:59 +01:00
Profpatsch a78bed700a PlayerHolder: inline bind
Only used once. Now the code looks weird … why is the service started
twice??
2024-12-26 00:26:22 +01:00
Profpatsch ef3c76645f PlayerHolder/PlayerService: inline & remove duplicate player passing
The player in playerHolder is exactly the player inside the
`PlayerService`, which in turn is exactly passed through the IBinder
interface. Thus we don’t have to pass both.

Instead add `PlayerService.getPlayer()`.

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

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

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

* Migrate related items fragment to Jetpack Compose

* Specify mode parameter explicitly

* Rm unused class

* Fix list item size

* Added stream progress bar, separate stream and playlist thumbnails

* Display message if no related streams are available

* Dispose of related items when closing the video player

* Add modifiers for no items message function

* Implement remaining stream menu items

* Improved stream composables

* Use view model lifecycle scope

* Make live color solid red

* Use nested scroll modifier

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

* Fix NPE in MediaSessionPlayerUi while destroying player

* Update NewPipeExtractor to v0.24.1

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

* Hotfix release v0.27.1 (998)

* Update README.pt_BR.md (#11275)

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

* Update Matrix room link, and prioritise it

* Update Matrix room link in CONTRIBUTING.md

* Prioritise Matrix in contribution doc too

* Update NewPipeExtractor to v0.24.2

* Hotfix release v0.27.2 (999)

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

* Don't warn about rhino class in proguard

Likely related to https://github.com/mozilla/rhino/commit/01a7b20655602f7e2df59af744b47b77f678b6cf but I am not completely sure.
I tested the app and it works well, so I think that org.mozilla.javascript.JavaToJSONConverters is not used really.

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

* Remove code committed accidentally

---------

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

* Migrate to Coil from Picasso

* Clean up Picasso leftovers

* Enable RGB-565 for low-end devices

* Added Coil helper method

* Add annotation

* Simplify newImageLoader implementation

* Use Coil's default disk and memory cache config

* Enable crossfade animation

* Correct method name

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

Note:
- Theme colors are generated using the Material Theme builder (https://m3.material.io/styles/color/overview).
2024-05-13 03:53:35 +05:30
441 changed files with 14759 additions and 11311 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
### Creating a Pull Request (PR)
* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub against the `dev` branch.
* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub.
* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged!
* Respond if someone requests changes or otherwise raises issues about your PRs.
* Try to figure out yourself why builds on our CI fail.
+2 -10
View File
@@ -2,7 +2,7 @@
#### What is it?
- [ ] Bugfix (user facing)
- [ ] Feature (user facing)
- [ ] Feature (user facing) ⚠️ **Your PR must target the [`refactor`](https://github.com/TeamNewPipe/NewPipe/tree/refactor) branch**
- [ ] Codebase improvement (dev facing)
- [ ] Meta improvement to the project (dev facing)
@@ -24,16 +24,8 @@
#### Relies on the following changes
<!-- Delete this if it doesn't apply to your PR. -->
-
#### Testing/QA
##### Devices
- tested on device/emulator(phone/tablet/TV/...) running Android XX / API level by doing ...
##### Steps for testing
<!-- Explain which steps you made to test that your changes fix the bug or implement the feature correctly and do not introduce regressions. This is mandatory to ensure that reviewers can quickly identify if you missed a crucial step. Link unit or integration tests here, if created or changed. --->
-
##### APK for testing
#### APK testing
<!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) -->
<!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.-->
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. You can find more info and a video demonstration [on this wiki page](https://github.com/TeamNewPipe/NewPipe/wiki/Download-APK-for-PR).
+4
View File
@@ -12,6 +12,7 @@ captures/
*.class
app/debug/
app/release/
.kotlin/
# vscode / eclipse files
*.classpath
@@ -32,3 +33,6 @@ node_modules/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
# logs
*.log
+4
View File
@@ -1,3 +1,6 @@
<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>
@@ -7,6 +10,7 @@
<p align="center">
<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/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>
+62 -9
View File
@@ -2,17 +2,19 @@
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import com.android.build.api.dsl.ApplicationExtension
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import java.util.regex.Pattern
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.android.legacy.kapt)
alias(libs.plugins.google.ksp)
alias(libs.plugins.jetbrains.kotlin.compose)
alias(libs.plugins.jetbrains.kotlin.parcelize)
alias(libs.plugins.jetbrains.kotlinx.serialization)
alias(libs.plugins.sonarqube)
alias(libs.plugins.hilt)
alias(libs.plugins.about.libraries)
checkstyle
}
@@ -28,6 +30,12 @@ val normalizedWorkingBranch = workingBranch
kotlin {
jvmToolchain(21)
compilerOptions {
// TODO: Drop annotation default target when it is stable
freeCompilerArgs.addAll(
"-Xannotation-default-target=param-property"
)
}
}
configure<ApplicationExtension> {
@@ -123,6 +131,7 @@ configure<ApplicationExtension> {
buildFeatures {
viewBinding = true
compose = true
buildConfig = true
resValues = true
}
@@ -215,6 +224,12 @@ sonar {
}
}
aboutLibraries {
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
// harmful for reproducible builds
offlineMode = true
}
dependencies {
// Desugaring
coreLibraryDesugaring(libs.android.desugar)
@@ -229,32 +244,64 @@ dependencies {
checkstyle(libs.puppycrawl.checkstyle)
ktlint(libs.pinterest.ktlint)
// AndroidX
/** Kotlin **/
implementation(libs.kotlin.stdlib)
/** AndroidX **/
implementation(libs.androidx.appcompat)
implementation(libs.androidx.cardview)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.fragment)
implementation(libs.androidx.fragment.compose)
implementation(libs.androidx.lifecycle.livedata)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.media)
implementation(libs.androidx.preference)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.rxjava3)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.work.rxjava3)
implementation(libs.google.android.material)
implementation(libs.androidx.webkit)
/** Compose & other modern patterns **/
// Jetpack Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.adaptive)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
implementation(libs.androidx.compose.material.icons.extended)
// Jetpack navigatio3
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.viewmodel)
// Jetpack Compose related dependencies
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.hilt.navigation.compose)
// Coroutines interop
implementation(libs.kotlinx.coroutines.rx3)
// Library loading for About screen
implementation(libs.about.libraries.compose.m3)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Scroll
implementation(libs.lazy.column.scrollbar)
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
@@ -316,7 +363,10 @@ dependencies {
debugImplementation(libs.facebook.stetho.core)
debugImplementation(libs.facebook.stetho.okhttp3)
// Testing
// Jetpack Compose
debugImplementation(libs.androidx.compose.ui.tooling)
/** Testing **/
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
@@ -324,6 +374,9 @@ dependencies {
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.assertj.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
aboutLibraries {
+15
View File
@@ -35,6 +35,21 @@
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
-keep class org.schabi.newpipe.settings.notifications.** { *; }
## Keep Kotlinx Serialization classes
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; }
-keepclassmembers class org.schabi.newpipe.** {
*** Companion;
}
-keepclasseswithmembers class org.schabi.newpipe.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Prevent R8 from stripping or renaming Protobuf internal fields
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
<fields>;
@@ -6,6 +6,9 @@ import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.core.Single
import java.io.IOException
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.rx3.await
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -106,14 +109,10 @@ class FeedDAOTest {
)
}
private fun setupUnlinkDelete(time: String) {
private fun setupUnlinkDelete(time: String) = runBlocking(Dispatchers.IO) {
clearAndFillTables()
Single.fromCallable {
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
}.blockingSubscribe()
Single.fromCallable {
streamDAO.deleteOrphans()
}.blockingSubscribe()
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
streamDAO.deleteOrphans().await()
}
private fun clearAndFillTables() {
@@ -1,62 +0,0 @@
package org.schabi.newpipe.error;
import android.os.Parcel;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import java.util.Arrays;
import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* Instrumented tests for {@link ErrorInfo}.
*/
@RunWith(AndroidJUnit4.class)
@LargeTest
public class ErrorInfoTest {
/**
* @param errorInfo the error info to access
* @return the private field errorInfo.message.stringRes using reflection
*/
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
throws NoSuchFieldException, IllegalAccessException {
final var message = ErrorInfo.class.getDeclaredField("message");
message.setAccessible(true);
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
stringRes.setAccessible(true);
return (int) Objects.requireNonNull(stringRes.get(messageValue));
}
@Test
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
// Obtain a Parcel object and write the parcelable object to it:
final Parcel parcel = Parcel.obtain();
info.writeToParcel(parcel, 0);
parcel.setDataPosition(0);
final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel);
assertTrue(Arrays.toString(infoFromParcel.getStackTraces())
.contains(ErrorInfoTest.class.getSimpleName()));
assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction());
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
parcel.recycle();
}
}
@@ -0,0 +1,127 @@
package org.schabi.newpipe.error
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import java.io.IOException
import java.net.SocketTimeoutException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.exceptions.ParsingException
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
/**
* Instrumented tests for {@link ErrorInfo}.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class ErrorInfoTest {
private val context: Context by lazy { ApplicationProvider.getApplicationContext() }
/**
* @param errorInfo the error info to access
* @return the private field errorInfo.message.stringRes using reflection
*/
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int {
val message = ErrorInfo::class.java.getDeclaredField("message")
message.isAccessible = true
val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage
val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes")
stringRes.isAccessible = true
return stringRes.get(messageValue) as Int
}
@Test
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
fun errorInfoTestParcelable() {
val info = ErrorInfo(
ParsingException("Hello"),
UserAction.USER_REPORT,
"request",
ServiceList.YouTube.serviceId
)
// Obtain a Parcel object and write the parcelable object to it:
val parcel = Parcel.obtain()
info.writeToParcel(parcel, 0)
parcel.setDataPosition(0)
val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR")
val creator = creatorField.get(null)
check(creator is Parcelable.Creator<*>)
val infoFromParcel = requireNotNull(
creator.createFromParcel(parcel) as? ErrorInfo
)
assertTrue(
infoFromParcel.stackTraces.contentToString()
.contains(ErrorInfoTest::class.java.simpleName)
)
assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction)
assertEquals(
ServiceList.YouTube.serviceInfo.name,
infoFromParcel.getServiceName()
)
assertEquals("request", infoFromParcel.request)
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel))
parcel.recycle()
}
/**
* Test: Network error on initial load (Resource.Error)
*/
@Test
fun testInitialCommentNetworkError() {
val errorInfo = ErrorInfo(
throwable = SocketTimeoutException("Connection timeout"),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
assertNull(errorInfo.recaptchaUrl)
}
/**
* Test: Network error on paging (LoadState.Error)
*/
@Test
fun testPagingNetworkError() {
val errorInfo = ErrorInfo(
throwable = IOException("Paging failed"),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
assertNull(errorInfo.recaptchaUrl)
}
/**
* Test: ReCaptcha during comments load
*/
@Test
fun testReCaptchaDuringComments() {
val url = "https://www.google.com/recaptcha/api/fallback?k=test"
val errorInfo = ErrorInfo(
throwable = ReCaptchaException("ReCaptcha needed", url),
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
)
assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context))
assertEquals(url, errorInfo.recaptchaUrl)
assertTrue(errorInfo.isReportable)
assertTrue(errorInfo.isRetryable)
}
}
@@ -0,0 +1,126 @@
package org.schabi.newpipe.ui.components.common
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.net.UnknownHostException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
import org.schabi.newpipe.ui.theme.AppTheme
@RunWith(AndroidJUnit4::class)
class ErrorPanelTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) {
composeRule.setContent {
AppTheme {
ErrorPanel(errorInfo = errorInfo, onRetry = onRetry)
}
}
}
private fun text(@StringRes id: Int) = composeRule.activity.getString(id)
/**
* Test Network Error
*/
@Test
fun testNetworkErrorShowsRetryWithoutReportButton() {
val networkErrorInfo = ErrorInfo(
throwable = UnknownHostException("offline"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=foo"
)
setErrorPanel(networkErrorInfo, onRetry = {})
composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
.assertDoesNotExist()
}
/**
* Test Unexpected Error, Shows Report and Retry buttons
*/
@Test
fun unexpectedErrorShowsReportAndRetryButtons() {
val unexpectedErrorInfo = ErrorInfo(
throwable = RuntimeException("Unexpected error"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=bar"
)
setErrorPanel(unexpectedErrorInfo, onRetry = {})
composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertIsDisplayed()
}
/**
* Test Recaptcha Error shows all buttons: solve, retry, open in browser, report
*/
@Test
fun recaptchaErrorShowsAllButtons() {
var retryClicked = false
val recaptchaErrorInfo = ErrorInfo(
throwable = ReCaptchaException(
"Recaptcha required",
"https://example.com/captcha"
),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=baz",
openInBrowserUrl = "https://example.com/watch?v=baz"
)
setErrorPanel(
errorInfo = recaptchaErrorInfo,
onRetry = { retryClicked = true }
)
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
.assertIsDisplayed()
.performClick()
composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true)
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertIsDisplayed()
assert(retryClicked) { "onRetry callback should have been invoked" }
}
/**
* Test Content Not Available Error hides retry button
*/
@Test
fun testNonRetryableErrorHidesRetryAndReportButtons() {
val contentNotAvailable = ErrorInfo(
throwable = UnsupportedContentInCountryException("Not available here"),
userAction = UserAction.REQUESTED_STREAM,
request = "https://example.com/watch?v=qux"
)
setErrorPanel(contentNotAvailable)
composeRule.onNodeWithText(text(R.string.unsupported_content_in_country))
.assertIsDisplayed()
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
.assertDoesNotExist()
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
.assertDoesNotExist()
}
}
@@ -0,0 +1,364 @@
package org.schabi.newpipe.ui.components.video.comment
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.LoadStates
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import java.net.UnknownHostException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.ui.components.common.ErrorPanel
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.LoadingIndicator
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.viewmodels.util.Resource
class CommentSectionInstrumentedTest {
@get:Rule
val composeRule = createAndroidComposeRule<androidx.activity.ComponentActivity>()
private val uiStateFlow = MutableStateFlow<Resource<CommentInfo>>(Resource.Loading)
private val pagingFlow = MutableStateFlow(PagingData.empty<CommentsInfoItem>())
private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId)
@Before
fun setUp() {
composeRule.setContent {
AppTheme {
TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow)
}
}
}
private fun successState(commentCount: Int) = Resource.Success(
CommentInfo(
serviceId = 0,
url = "",
comments = emptyList(),
nextPage = null,
commentCount = commentCount,
isCommentsDisabled = false
)
)
@Test
fun commentListLoadsAndScrolls() {
val comments = (1..25).map { index ->
CommentsInfoItem(
commentText = Description("Comment $index", Description.PLAIN_TEXT),
uploaderName = "Uploader $index",
replies = Page(""),
replyCount = 0
)
}
uiStateFlow.value = successState(comments.size)
pagingFlow.value = PagingData.from(comments)
composeRule.waitForIdle()
composeRule.onNodeWithText("Comment 1").assertIsDisplayed()
composeRule.onNodeWithTag("comment_list")
.performScrollToNode(hasText("Comment 25"))
composeRule.onNodeWithText("Comment 25").assertIsDisplayed()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun pagingErrorShowsErrorPanelAndAllowsRetry() {
uiStateFlow.value = successState(10)
pagingFlow.value = PagingData.from(
data = emptyList(),
sourceLoadStates = LoadStates(
refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true)
.and(hasClickAction())
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val recoveredComment = CommentsInfoItem(
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(recoveredComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("Recovered comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
composeRule.onNode(solveMatcher).assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun resourceErrorShowsErrorPanelAndRetry() {
uiStateFlow.value = Resource.Error(UnknownHostException("offline"))
composeRule.waitForIdle()
composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed()
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val recoveredComment = CommentsInfoItem(
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(recoveredComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("Recovered comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.network_error))
.assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
@OptIn(ExperimentalTestApi::class)
@Test
fun retryAfterErrorRecoversList() {
uiStateFlow.value = Resource.Error(RuntimeException("boom"))
composeRule.waitForIdle()
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
.and(hasClickAction())
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNode(retryMatcher)
.performScrollTo()
.performClick()
val firstComment = CommentsInfoItem(
commentText = Description("First comment", Description.PLAIN_TEXT),
uploaderName = "Uploader",
replies = Page(""),
replyCount = 0
)
uiStateFlow.value = successState(1)
pagingFlow.value = PagingData.from(
data = listOf(firstComment),
sourceLoadStates = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(true)
)
)
composeRule.waitForIdle()
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodes(hasText("First comment"))
.fetchSemanticsNodes()
.isNotEmpty()
}
composeRule.onNodeWithText("First comment").assertIsDisplayed()
composeRule.onNodeWithText(string(R.string.network_error))
.assertDoesNotExist()
composeRule.onNode(retryMatcher).assertDoesNotExist()
}
}
@Composable
private fun TestCommentSection(
uiStateFlow: StateFlow<Resource<CommentInfo>>,
commentsFlow: Flow<PagingData<CommentsInfoItem>>
) {
val uiState by uiStateFlow.collectAsState()
val comments = commentsFlow.collectAsLazyPagingItems()
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val listState = rememberLazyListState()
val COMMENT_LIST_TAG = "comment_list"
LazyColumnThemedScrollbar(state = listState) {
LazyColumn(
modifier = Modifier
.testTag(COMMENT_LIST_TAG)
.nestedScroll(nestedScrollInterop),
state = listState
) {
when (uiState) {
is Resource.Loading -> item {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
}
is Resource.Success -> {
val commentInfo = (uiState as Resource.Success<CommentInfo>).data
val count = commentInfo.commentCount
when {
commentInfo.isCommentsDisabled -> item {
EmptyStateComposable(
spec = EmptyStateSpec.DisabledComments,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
count == 0 -> item {
EmptyStateComposable(
spec = EmptyStateSpec.NoComments,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 128.dp)
)
}
else -> {
if (count >= 0) {
item {
Text(
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
text = pluralStringResource(R.plurals.comments, count, count),
maxLines = 1,
style = MaterialTheme.typography.titleMedium
)
}
}
when (val refresh = comments.loadState.refresh) {
is LoadState.Loading -> item {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
}
is LoadState.Error -> item {
Box(
modifier = Modifier.fillMaxWidth()
) {
ErrorPanel(
errorInfo = ErrorInfo(
throwable = refresh.error,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
),
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> items(comments.itemCount) { index ->
Comment(comment = comments[index]!!) {}
}
}
}
}
}
is Resource.Error -> item {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ErrorPanel(
errorInfo = ErrorInfo(
throwable = (uiState as Resource.Error).throwable,
userAction = UserAction.REQUESTED_COMMENTS,
request = "comments"
),
onRetry = { comments.retry() },
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
}
}
@@ -1,20 +0,0 @@
package org.schabi.newpipe.settings;
import android.content.Intent;
import leakcanary.LeakCanary;
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
*/
@SuppressWarnings("unused") // Class is used but loaded via reflection
public class DebugSettingsBVDLeakCanary
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
@Override
public Intent getNewLeakDisplayActivityIntent() {
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
}
}
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
import android.content.Intent
import leakcanary.LeakCanary.newLeakDisplayActivityIntent
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* [DebugSettingsBVDLeakCanaryAPI].
*/
@Suppress("unused") // Class is used but loaded via reflection
class DebugSettingsBVDLeakCanary :
DebugSettingsBVDLeakCanaryAPI {
override fun getNewLeakDisplayActivityIntent(): Intent {
return newLeakDisplayActivityIntent()
}
}
+10 -14
View File
@@ -80,6 +80,10 @@
</intent-filter>
</service>
<activity
android:name=".ComposeActivity"
android:exported="false" />
<activity
android:name=".player.PlayQueueActivity"
android:exported="false"
@@ -91,20 +95,15 @@
android:exported="false"
android:label="@string/settings" />
<activity
android:name=".about.AboutActivity"
android:exported="false"
android:label="@string/title_activity_about" />
<service
android:name=".local.feed.service.FeedLoadService"
android:foregroundServiceType="dataSync" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name=".local.feed.service.FeedLoadService"
android:foregroundServiceType="dataSync" />
<activity
android:name=".PanicResponderActivity"
android:exported="true"
@@ -124,10 +123,6 @@
android:label="@string/general_error"
android:theme="@android:style/Theme.NoDisplay" />
<activity
android:name=".error.ErrorActivity"
android:exported="false" />
<!-- giga get related -->
<activity
android:name=".download.DownloadActivity"
@@ -135,7 +130,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
@@ -15,6 +15,7 @@ import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.jakewharton.processphoenix.ProcessPhoenix
import dagger.hilt.android.HiltAndroidApp
import io.reactivex.rxjava3.exceptions.CompositeException
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
@@ -58,6 +59,7 @@ import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
@HiltAndroidApp
open class App :
Application(),
SingletonImageLoader.Factory {
@@ -0,0 +1,22 @@
package org.schabi.newpipe
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
@Singleton
fun providesSharedPreference(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
}
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.navigation3.runtime.NavKey
import dagger.hilt.android.AndroidEntryPoint
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.navigation.NavDisplay
import org.schabi.newpipe.navigation.Screen
import org.schabi.newpipe.ui.theme.AppTheme
/**
* Single host activity for all Compose-based screens.
* Other parts of the app (including legacy View-based code) launch this activity
* via Intent with extras specifying which screen to display.
*/
@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
super.onCreate(savedInstanceState)
val startDestination: NavKey = resolveStartDestination(intent)
setContent {
AppTheme {
NavDisplay(startDestination)
}
}
}
private fun resolveStartDestination(intent: Intent): NavKey {
return when (intent.getStringExtra(EXTRA_SCREEN)) {
SCREEN_ABOUT -> Screen.About
SCREEN_ERROR -> Screen.Error
SCREEN_SETTINGS -> Screen.Settings.Home
else -> throw IllegalArgumentException(
"Unknown screen: ${intent.getStringExtra(EXTRA_SCREEN)}"
)
}
}
companion object {
const val SCREEN_ABOUT = "about"
const val EXTRA_SCREEN = "extra_screen"
const val EXTRA_ERROR_INFO = "extra_error_info"
const val SCREEN_ERROR = "error"
const val SCREEN_SETTINGS = "settings"
fun aboutIntent(context: Context): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(EXTRA_SCREEN, SCREEN_ABOUT)
}
}
fun errorIntent(context: Context, errorInfo: ErrorInfo): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(EXTRA_SCREEN, SCREEN_ERROR)
putExtra(EXTRA_ERROR_INFO, errorInfo)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
fun settingsIntent(context: Context): Intent {
return Intent(context, ComposeActivity::class.java).apply {
putExtra(EXTRA_SCREEN, SCREEN_SETTINGS)
}
}
}
}
@@ -45,7 +45,6 @@ import android.widget.FrameLayout;
import android.widget.Spinner;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
@@ -54,7 +53,6 @@ import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
@@ -68,13 +66,11 @@ import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player;
@@ -610,39 +606,27 @@ public class MainActivity extends AppCompatActivity {
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
// interacts with a fragment inside fragment_holder so all back presses should be
// handled by it
if (bottomSheetHiddenOrCollapsed()) {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragment instanceof BackPressable) {
if (((BackPressable) fragment).onBackPressed()) {
return;
}
} else if (fragment instanceof CommentRepliesFragment) {
// expand DetailsFragment if CommentRepliesFragment was opened
// to show the top level comments again
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, false);
}
final var fragmentManager = getSupportFragmentManager();
} else {
final Fragment fragmentPlayer = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
if (bottomSheetHiddenOrCollapsed()) {
final var fragment = fragmentManager.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragmentPlayer instanceof BackPressable) {
if (!((BackPressable) fragmentPlayer).onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) {
return;
}
} else {
final var player = fragmentManager.findFragmentById(R.id.fragment_player_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
return;
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
if (fragmentManager.getBackStackEntryCount() == 1) {
finish();
} else {
super.onBackPressed();
@@ -701,15 +685,9 @@ public class MainActivity extends AppCompatActivity {
* </pre>
*/
private void onHomeButtonPressed() {
final FragmentManager fm = getSupportFragmentManager();
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
final var fm = getSupportFragmentManager();
if (fragment instanceof CommentRepliesFragment) {
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, true);
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
if (!NavigationHelper.tryGotoSearchFragment(fm)) {
// If search fragment wasn't found in the backstack go to the main fragment
NavigationHelper.gotoMainFragment(fm);
}
@@ -883,7 +861,7 @@ public class MainActivity extends AppCompatActivity {
return;
}
if (PlayerHolder.getInstance().isPlayerOpen()) {
if (PlayerHolder.INSTANCE.isPlayerOpen()) {
// if the player is already open, no need for a broadcast receiver
openMiniPlayerIfMissing();
} else {
@@ -893,7 +871,7 @@ public class MainActivity extends AppCompatActivity {
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)
&& PlayerHolder.getInstance().isPlayerOpen()) {
&& PlayerHolder.INSTANCE.isPlayerOpen()) {
openMiniPlayerIfMissing();
// At this point the player is added 100%, we can unregister. Other actions
// are useless since the fragment will not be removed after that.
@@ -909,72 +887,10 @@ public class MainActivity extends AppCompatActivity {
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
PlayerHolder.getInstance().tryBindIfNeeded(this);
PlayerHolder.INSTANCE.tryBindIfNeeded(this);
}
}
private void openDetailFragmentFromCommentReplies(
@NonNull final FragmentManager fm,
final boolean popBackStack
) {
// obtain the name of the fragment under the replies fragment that's going to be popped
@Nullable final String fragmentUnderEntryName;
if (fm.getBackStackEntryCount() < 2) {
fragmentUnderEntryName = null;
} else {
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
.getName();
}
// the root comment is the comment for which the user opened the replies page
@Nullable final CommentRepliesFragment repliesFragment =
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
@Nullable final CommentsInfoItem rootComment =
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
// sometimes this function pops the backstack, other times it's handled by the system
if (popBackStack) {
fm.popBackStackImmediate();
}
// only expand the bottom sheet back if there are no more nested comment replies fragments
// stacked under the one that is currently being popped
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
return;
}
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
.from(mainBinding.fragmentPlayerHolder);
// do not return to the comment if the details fragment was closed
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
return;
}
// scroll to the root comment once the bottom sheet expansion animation is finished
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull final View bottomSheet,
final int newState) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
final Fragment detailFragment = fm.findFragmentById(
R.id.fragment_player_holder);
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
// should always be the case
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
}
behavior.removeBottomSheetCallback(this);
}
}
@Override
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
// not needed, listener is removed once the sheet is expanded
}
});
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}
private boolean bottomSheetHiddenOrCollapsed() {
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
@@ -360,15 +360,9 @@ public class RouterActivity extends AppCompatActivity {
// Default / Ask always
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
switch (availableChoices.size()) {
case 1:
handleChoice(availableChoices.get(0).key);
break;
case 0:
handleChoice(getString(R.string.show_info_key));
break;
default:
showDialog(availableChoices);
break;
case 1 -> handleChoice(availableChoices.get(0).key);
case 0 -> handleChoice(getString(R.string.show_info_key));
default -> showDialog(availableChoices);
}
}
@@ -541,7 +535,7 @@ public class RouterActivity extends AppCompatActivity {
// Enqueue is only shown if the current queue is not empty.
// However, if the playqueue or the player is cleared after this item was chosen and
// while the item is extracted, it will automatically fall back to background player.
if (PlayerHolder.getInstance().getQueueSize() > 0) {
if (PlayerHolder.INSTANCE.getQueueSize() > 0) {
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
getString(R.string.enqueue_stream), R.drawable.ic_add));
}
@@ -684,7 +678,7 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
final PlayerType playerType = PlayerHolder.getInstance().getType();
final PlayerType playerType = PlayerHolder.INSTANCE.getType();
return playerType == null || playerType == PlayerType.MAIN;
}
@@ -119,7 +119,7 @@ abstract class FeedDAO {
AND s.upload_date <> max_upload_date))
"""
)
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
abstract suspend fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
@Query(
"""
@@ -17,7 +17,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = [StreamEntity.STREAM_ID],
parentColumns = ["uid"],
childColumns = [STREAM_ID],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE,
@@ -1,61 +1,66 @@
/*
* SPDX-FileCopyrightText: 2018-2022 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.database.history.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.RawQuery
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import org.schabi.newpipe.database.BasicDAO
import io.reactivex.rxjava3.core.Single
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.local.history.SortKey
@Dao
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
interface StreamHistoryDAO {
@Insert
fun insert(entity: StreamHistoryEntity): Long
@Query("SELECT * FROM stream_history")
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>>
@Delete
fun delete(entity: StreamHistoryEntity)
@Query("DELETE FROM stream_history")
abstract override fun deleteAll(): Int
fun deleteAll(): Completable
override fun listByService(serviceId: Int): Flowable<List<StreamHistoryEntity>> {
throw UnsupportedOperationException()
}
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
abstract val history: Flowable<MutableList<StreamHistoryEntry>>
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
fun getHistorySortedById(): Flowable<List<StreamHistoryEntry>>
@Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
fun getLatestEntry(streamId: Long): StreamHistoryEntity?
@Query("DELETE FROM stream_history WHERE stream_id = :streamId")
abstract fun deleteStreamHistory(streamId: Long): Int
fun deleteStreamHistory(streamId: Long): Completable
// Select the latest entry and watch count for each stream id on history table
@RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT * FROM streams
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
fun getHistory(): Flowable<List<StreamHistoryEntry>>
INNER JOIN (
SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
FROM stream_history
GROUP BY stream_id
)
ON uid = stream_id
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
ON uid = stream_id_alias
"""
@RawQuery(
observedEntities = [StreamStatisticsEntry::class, StreamEntity::class, StreamHistoryEntity::class]
)
abstract fun getStatistics(): Flowable<MutableList<StreamStatisticsEntry>>
fun getOrderedHistoryByRaw(query: SupportSQLiteQuery): PagingSource<Int, StreamStatisticsEntry>
fun getOrderedHistory(key: SortKey): PagingSource<Int, StreamStatisticsEntry> {
val orderBy = when (key) {
SortKey.LAST_PLAYED -> "latestAccess"
SortKey.MOST_PLAYED -> "watchCount"
}
return getOrderedHistoryByRaw(
SimpleSQLiteQuery(
"""SELECT * FROM streams INNER JOIN
(SELECT stream_id, MAX(access_date) AS latestAccess,
SUM(repeat_count) AS watchCount FROM stream_history GROUP BY stream_id)
ON uid = stream_id LEFT JOIN
(SELECT stream_id AS stream_id_alias, progress_time FROM stream_state)
ON uid = stream_id_alias
ORDER BY $orderBy DESC
""".trimIndent()
)
)
}
}
@@ -12,11 +12,7 @@ import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import androidx.room.Index
import java.time.OffsetDateTime
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
/**
* @param streamUid the stream id this history item will refer to
@@ -24,33 +20,26 @@ import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
* @param repeatCount the total number of views this stream received
*/
@Entity(
tableName = STREAM_HISTORY_TABLE,
primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
indices = [Index(value = [JOIN_STREAM_ID])],
tableName = "stream_history",
primaryKeys = ["stream_id", "access_date"],
indices = [Index(value = ["stream_id"])],
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(STREAM_ID),
childColumns = arrayOf(JOIN_STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf("stream_id"),
onDelete = CASCADE,
onUpdate = CASCADE
)
]
)
data class StreamHistoryEntity(
@ColumnInfo(name = JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamUid: Long,
@ColumnInfo(name = STREAM_ACCESS_DATE)
@ColumnInfo(name = "access_date")
var accessDate: OffsetDateTime,
@ColumnInfo(name = STREAM_REPEAT_COUNT)
@ColumnInfo(name = "repeat_count")
var repeatCount: Long
) {
companion object {
const val STREAM_HISTORY_TABLE: String = "stream_history"
const val STREAM_ACCESS_DATE: String = "access_date"
const val JOIN_STREAM_ID: String = "stream_id"
const val STREAM_REPEAT_COUNT: String = "repeat_count"
}
}
)
@@ -11,13 +11,13 @@ data class StreamHistoryEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamId: Long,
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
@ColumnInfo(name = "access_date")
val accessDate: OffsetDateTime,
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
@ColumnInfo(name = "repeat_count")
val repeatCount: Long
) {
@@ -11,7 +11,6 @@ import androidx.room.Embedded
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
@@ -19,7 +18,7 @@ data class PlaylistStreamEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
@ColumnInfo(name = "progress_time", defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
@@ -37,7 +37,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
),
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(StreamEntity.STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf(JOIN_STREAM_ID),
onDelete = CASCADE,
onUpdate = CASCADE,
@@ -11,9 +11,7 @@ import androidx.room.Embedded
import androidx.room.Ignore
import java.time.OffsetDateTime
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
@@ -21,16 +19,16 @@ data class StreamStatisticsEntry(
@Embedded
val streamEntity: StreamEntity,
@ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
@ColumnInfo(name = "progress_time", defaultValue = "0")
val progressMillis: Long,
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamId: Long,
@ColumnInfo(name = STREAM_LATEST_DATE)
@ColumnInfo(name = "latestAccess")
val latestAccessDate: OffsetDateTime,
@ColumnInfo(name = STREAM_WATCH_COUNT)
@ColumnInfo(name = "watchCount")
val watchCount: Long
) : LocalItem {
@@ -51,9 +49,4 @@ data class StreamStatisticsEntry(
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
}
}
companion object {
const val STREAM_LATEST_DATE = "latestAccess"
const val STREAM_WATCH_COUNT = "watchCount"
}
}
@@ -3,12 +3,11 @@ package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity
data class StreamWithState(
@Embedded
val stream: StreamEntity,
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
@ColumnInfo(name = "progress_time")
val stateProgressMillis: Long?
)
@@ -8,10 +8,10 @@ import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import java.time.OffsetDateTime
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.StreamTypeUtil
@@ -27,10 +27,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertInternal(stream: StreamEntity): Long
@@ -121,28 +118,28 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
WHERE f.stream_id = streams.uid)
"""
)
abstract fun deleteOrphans(): Int
abstract fun deleteOrphans(): Completable
/**
* Minimal entry class used when comparing/updating an existent stream.
*/
internal data class StreamCompareFeed(
@ColumnInfo(name = STREAM_ID)
@ColumnInfo(name = "uid")
var uid: Long = 0,
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
@ColumnInfo(name = "stream_type")
var streamType: StreamType,
@ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
@ColumnInfo(name = "textual_upload_date")
var textualUploadDate: String? = null,
@ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
@ColumnInfo(name = "upload_date")
var uploadDate: OffsetDateTime? = null,
@ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
@ColumnInfo(name = "is_upload_date_approximation")
var isUploadDateApproximation: Boolean? = null,
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
@ColumnInfo(name = "duration")
var duration: Long
)
}
@@ -11,35 +11,39 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@Dao
interface StreamStateDAO : BasicDAO<StreamStateEntity> {
interface StreamStateDAO {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(entity: StreamStateEntity): Long
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun getAll(): Flowable<List<StreamStateEntity>>
@Update
fun update(entity: StreamStateEntity)
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
override fun deleteAll(): Int
@Query("SELECT * FROM stream_state")
fun getAll(): Flowable<List<StreamStateEntity>>
override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity>> {
throw UnsupportedOperationException()
}
@Query("DELETE FROM stream_state")
fun deleteAll(): Completable
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
@Query("SELECT * FROM stream_state WHERE stream_id = :streamId")
fun getState(streamId: Long): Maybe<StreamStateEntity>
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
fun deleteState(streamId: Long): Int
@Query("DELETE FROM stream_state WHERE stream_id = :streamId")
fun deleteState(streamId: Long): Completable
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun silentInsertInternal(streamState: StreamStateEntity)
@Transaction
fun upsert(stream: StreamStateEntity): Long {
fun upsert(stream: StreamStateEntity) {
silentInsertInternal(stream)
return update(stream).toLong()
update(stream)
}
}
@@ -7,9 +7,6 @@ import androidx.room.Index
import androidx.room.PrimaryKey
import java.io.Serializable
import java.time.OffsetDateTime
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
import org.schabi.newpipe.extractor.localization.DateWrapper
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
@@ -18,50 +15,50 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.image.ImageStrategy
@Entity(
tableName = STREAM_TABLE,
tableName = "streams",
indices = [
Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
Index(value = ["service_id", "url"], unique = true)
]
)
data class StreamEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = STREAM_ID)
@ColumnInfo(name = "uid")
var uid: Long = 0,
@ColumnInfo(name = STREAM_SERVICE_ID)
@ColumnInfo(name = "service_id")
var serviceId: Int,
@ColumnInfo(name = STREAM_URL)
@ColumnInfo(name = "url")
var url: String,
@ColumnInfo(name = STREAM_TITLE)
@ColumnInfo(name = "title")
var title: String,
@ColumnInfo(name = STREAM_TYPE)
@ColumnInfo(name = "stream_type")
var streamType: StreamType,
@ColumnInfo(name = STREAM_DURATION)
@ColumnInfo(name = "duration")
var duration: Long,
@ColumnInfo(name = STREAM_UPLOADER)
@ColumnInfo(name = "uploader")
var uploader: String,
@ColumnInfo(name = STREAM_UPLOADER_URL)
@ColumnInfo(name = "uploader_url")
var uploaderUrl: String? = null,
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
@ColumnInfo(name = "thumbnail_url")
var thumbnailUrl: String? = null,
@ColumnInfo(name = STREAM_VIEWS)
@ColumnInfo(name = "view_count")
var viewCount: Long? = null,
@ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
@ColumnInfo(name = "textual_upload_date")
var textualUploadDate: String? = null,
@ColumnInfo(name = STREAM_UPLOAD_DATE)
@ColumnInfo(name = "upload_date")
var uploadDate: OffsetDateTime? = null,
@ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
@ColumnInfo(name = "is_upload_date_approximation")
var isUploadDateApproximation: Boolean? = null
) : Serializable {
@Ignore
@@ -111,22 +108,4 @@ data class StreamEntity(
return item
}
companion object {
const val STREAM_TABLE = "streams"
const val STREAM_ID = "uid"
const val STREAM_SERVICE_ID = "service_id"
const val STREAM_URL = "url"
const val STREAM_TITLE = "title"
const val STREAM_TYPE = "stream_type"
const val STREAM_DURATION = "duration"
const val STREAM_UPLOADER = "uploader"
const val STREAM_UPLOADER_URL = "uploader_url"
const val STREAM_THUMBNAIL_URL = "thumbnail_url"
const val STREAM_VIEWS = "view_count"
const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
const val STREAM_UPLOAD_DATE = "upload_date"
const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
}
}
@@ -10,29 +10,26 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.ForeignKey.Companion.CASCADE
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
@Entity(
tableName = STREAM_STATE_TABLE,
primaryKeys = [JOIN_STREAM_ID],
tableName = "stream_state",
primaryKeys = ["stream_id"],
foreignKeys = [
ForeignKey(
entity = StreamEntity::class,
parentColumns = arrayOf(STREAM_ID),
childColumns = arrayOf(JOIN_STREAM_ID),
parentColumns = arrayOf("uid"),
childColumns = arrayOf("stream_id"),
onDelete = CASCADE,
onUpdate = CASCADE
)
]
)
data class StreamStateEntity(
@ColumnInfo(name = JOIN_STREAM_ID)
@ColumnInfo(name = "stream_id")
val streamUid: Long,
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
@ColumnInfo(name = "progress_time")
val progressMillis: Long
) {
/**
@@ -61,14 +58,6 @@ data class StreamStateEntity(
}
companion object {
const val STREAM_STATE_TABLE = "stream_state"
const val JOIN_STREAM_ID = "stream_id"
// This additional field is required for the SQL query because 'stream_id' is used
// for some other joins already
const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
const val STREAM_PROGRESS_MILLIS = "progress_time"
/**
* Playback state will not be saved, if playback time is less than this threshold
* (5000ms = 5s).
@@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
@Transaction
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
open fun upsertAll(entities: List<SubscriptionEntity>) {
val insertUidList = silentInsertAllInternal(entities)
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
@@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
update(entity)
}
}
return entities
}
}
@@ -9,7 +9,6 @@ import com.google.auto.service.AutoService;
import org.acra.config.CoreConfiguration;
import org.acra.sender.ReportSender;
import org.acra.sender.ReportSenderFactory;
import org.schabi.newpipe.App;
/*
* Created by Christian Schabesberger on 13.09.16.
@@ -1,282 +0,0 @@
/*
* SPDX-FileCopyrightText: 2015-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.error
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import com.grack.nanojson.JsonWriter
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityErrorBinding
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.util.text.setTextWithLinks
/**
* This activity is used to show error details and allow reporting them in various ways.
* Use [ErrorUtil.openActivity] to correctly open this activity.
*/
class ErrorActivity : AppCompatActivity() {
private lateinit var errorInfo: ErrorInfo
private lateinit var currentTimeStamp: String
private lateinit var binding: ActivityErrorBinding
private val contentCountryString: String
get() = Localization.getPreferredContentCountry(this).countryCode
private val contentLanguageString: String
get() = Localization.getPreferredLocalization(this).localizationCode
private val appLanguage: String
get() = Localization.getAppLocale().toString()
private val osString: String
get() {
val name = System.getProperty("os.name")!!
val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Build.VERSION.BASE_OS.ifEmpty { "Android" }
} else {
"Android"
}
return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}"
}
private val errorEmailSubject: String
get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}"
// /////////////////////////////////////////////////////////////////////
// Activity lifecycle
// /////////////////////////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ThemeHelper.setDayNightMode(this)
ThemeHelper.setTheme(this)
binding = ActivityErrorBinding.inflate(layoutInflater)
setContentView(binding.getRoot())
setSupportActionBar(binding.toolbarLayout.toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
setTitle(R.string.error_report_title)
setDisplayShowTitleEnabled(true)
}
errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!!
// important add guru meditation
addGuruMeditation()
// print current time, as zoned ISO8601 timestamp
currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
binding.errorReportEmailButton.setOnClickListener { _ ->
openPrivacyPolicyDialog(this, "EMAIL")
}
binding.errorReportCopyButton.setOnClickListener { _ ->
ShareUtils.copyToClipboard(this, buildMarkdown())
}
binding.errorReportGitHubButton.setOnClickListener { _ ->
openPrivacyPolicyDialog(this, "GITHUB")
}
// normal bugreport
buildInfo(errorInfo)
binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this))
binding.errorView.text = formErrorText(errorInfo.stackTraces)
// print stack trace once again for debugging:
errorInfo.stackTraces.forEach { Log.e(TAG, it) }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.error_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
onBackPressed()
true
}
R.id.menu_item_share_error -> {
ShareUtils.shareText(
applicationContext,
getString(R.string.error_report_title),
buildJson()
)
true
}
else -> false
}
}
private fun openPrivacyPolicyDialog(context: Context, action: String) {
AlertDialog.Builder(context)
.setIcon(android.R.drawable.ic_dialog_alert)
.setTitle(R.string.privacy_policy_title)
.setMessage(R.string.start_accept_privacy_policy)
.setCancelable(false)
.setNeutralButton(R.string.read_privacy_policy) { _, _ ->
ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))
}
.setPositiveButton(R.string.accept) { _, _ ->
if (action == "EMAIL") { // send on email
val intent = Intent(Intent.ACTION_SENDTO)
.setData("mailto:".toUri()) // only email apps should handle this
.putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS))
.putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject)
.putExtra(Intent.EXTRA_TEXT, buildJson())
ShareUtils.openIntentInApp(context, intent)
} else if (action == "GITHUB") { // open the NewPipe issue page on GitHub
ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL)
}
}
.setNegativeButton(R.string.decline, null)
.show()
}
private fun formErrorText(stacktrace: Array<String>): String {
val separator = "-------------------------------------"
return stacktrace.joinToString(separator + "\n", separator + "\n", separator)
}
private fun buildInfo(info: ErrorInfo) {
binding.errorInfoLabelsView.text = getString(R.string.info_labels)
val text = info.userAction.message + "\n" +
info.request + "\n" +
contentLanguageString + "\n" +
contentCountryString + "\n" +
appLanguage + "\n" +
info.getServiceName() + "\n" +
currentTimeStamp + "\n" +
packageName + "\n" +
BuildConfig.VERSION_NAME + "\n" +
osString
binding.errorInfosView.text = text
}
private fun buildJson(): String {
try {
return JsonWriter.string()
.`object`()
.value("user_action", errorInfo.userAction.message)
.value("request", errorInfo.request)
.value("content_language", contentLanguageString)
.value("content_country", contentCountryString)
.value("app_language", appLanguage)
.value("service", errorInfo.getServiceName())
.value("package", packageName)
.value("version", BuildConfig.VERSION_NAME)
.value("os", osString)
.value("time", currentTimeStamp)
.array("exceptions", errorInfo.stackTraces.toList())
.value("user_comment", binding.errorCommentBox.getText().toString())
.end()
.done()
} catch (exception: Exception) {
Log.e(TAG, "Error while erroring: Could not build json", exception)
}
return ""
}
private fun buildMarkdown(): String {
try {
return buildString(1024) {
val userComment = binding.errorCommentBox.text.toString()
if (userComment.isNotEmpty()) {
appendLine(userComment)
}
// basic error info
appendLine("## Exception")
appendLine("* __User Action:__ ${errorInfo.userAction.message}")
appendLine("* __Request:__ ${errorInfo.request}")
appendLine("* __Content Country:__ $contentCountryString")
appendLine("* __Content Language:__ $contentLanguageString")
appendLine("* __App Language:__ $appLanguage")
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
appendLine("* __Timestamp:__ $currentTimeStamp")
appendLine("* __Package:__ $packageName")
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}")
appendLine("* __OS:__ $osString")
// Collapse all logs to a single paragraph when there are more than one
// to keep the GitHub issue clean.
if (errorInfo.stackTraces.size > 1) {
append("<details><summary><b>Exceptions (")
append(errorInfo.stackTraces.size)
append(")</b></summary><p>\n")
}
// add the logs
errorInfo.stackTraces.forEachIndexed { index, stacktrace ->
append("<details><summary><b>Crash log ")
if (errorInfo.stackTraces.size > 1) {
append(index + 1)
}
append("</b>")
append("</summary><p>\n")
append("\n```\n${stacktrace}\n```\n")
append("</details>\n")
}
// make sure to close everything
if (errorInfo.stackTraces.size > 1) {
append("</p></details>\n")
}
append("<hr>\n")
}
} catch (exception: Exception) {
Log.e(TAG, "Error while erroring: Could not build markdown", exception)
return ""
}
}
private fun addGuruMeditation() {
// just an easter egg
var text = binding.errorSorryView.text.toString()
text += "\n" + getString(R.string.guru_meditation)
binding.errorSorryView.text = text
}
companion object {
// LOG TAGS
private val TAG = ErrorActivity::class.java.toString()
// BUNDLE TAGS
const val ERROR_INFO = "error_info"
private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"
private const val ERROR_EMAIL_SUBJECT = "Exception in "
private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues"
}
}
@@ -166,7 +166,7 @@ class ErrorInfo private constructor(
private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
// want to default to SERVICE_NONE
ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
?: SERVICE_NONE
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
@@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2015-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.error
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import com.grack.nanojson.JsonWriter
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.external_communication.ShareUtils
/**
* Pure utility functions for building and sending error reports.
* No Activity dependency — only requires a [Context].
*/
object ErrorReportHelper {
private val TAG = ErrorReportHelper::class.java.simpleName
private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"
private const val ERROR_EMAIL_SUBJECT = "Exception in "
const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues"
fun buildJson(context: Context, errorInfo: ErrorInfo, comment: String): String {
try {
return JsonWriter.string()
.`object`()
.value("user_action", errorInfo.userAction.message)
.value("request", errorInfo.request)
.value("content_language", getContentLanguage(context))
.value("content_country", getContentCountry(context))
.value("app_language", getAppLanguage())
.value("service", errorInfo.getServiceName())
.value("package", context.packageName)
.value("version", BuildConfig.VERSION_NAME)
.value("os", getOsString())
.value("time", getCurrentTimestamp())
.array("exceptions", errorInfo.stackTraces.toList())
.value("user_comment", comment)
.end()
.done()
} catch (exception: Exception) {
Log.e(TAG, "Could not build json", exception)
}
return ""
}
fun buildMarkdown(context: Context, errorInfo: ErrorInfo, comment: String): String {
try {
return buildString(1024) {
if (comment.isNotEmpty()) {
appendLine(comment)
}
appendLine("## Exception")
appendLine("* __User Action:__ ${errorInfo.userAction.message}")
appendLine("* __Request:__ ${errorInfo.request}")
appendLine("* __Content Country:__ ${getContentCountry(context)}")
appendLine("* __Content Language:__ ${getContentLanguage(context)}")
appendLine("* __App Language:__ ${getAppLanguage()}")
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
appendLine("* __Timestamp:__ ${getCurrentTimestamp()}")
appendLine("* __Package:__ ${context.packageName}")
appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}")
appendLine("* __OS:__ ${getOsString()}")
if (errorInfo.stackTraces.size > 1) {
append("<details><summary><b>Exceptions (")
append(errorInfo.stackTraces.size)
append(")</b></summary><p>\n")
}
errorInfo.stackTraces.forEachIndexed { index, stacktrace ->
append("<details><summary><b>Crash log ")
if (errorInfo.stackTraces.size > 1) {
append(index + 1)
}
append("</b></summary><p>\n")
append("\n```\n$stacktrace\n```\n")
append("</details>\n")
}
if (errorInfo.stackTraces.size > 1) {
append("</p></details>\n")
}
append("<hr>\n")
}
} catch (exception: Exception) {
Log.e(TAG, "Could not build markdown", exception)
return ""
}
}
fun sendErrorEmail(context: Context, errorInfo: ErrorInfo, comment: String) {
val subject = "$ERROR_EMAIL_SUBJECT${context.getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}"
val intent = Intent(Intent.ACTION_SENDTO)
.setData("mailto:".toUri())
.putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS))
.putExtra(Intent.EXTRA_SUBJECT, subject)
.putExtra(Intent.EXTRA_TEXT, buildJson(context, errorInfo, comment))
ShareUtils.openIntentInApp(context, intent)
}
fun shareError(context: Context, errorInfo: ErrorInfo, comment: String) {
ShareUtils.shareText(
context,
context.getString(R.string.error_report_title),
buildJson(context, errorInfo, comment)
)
}
fun copyForGitHub(context: Context, errorInfo: ErrorInfo, comment: String) {
ShareUtils.copyToClipboard(context, buildMarkdown(context, errorInfo, comment))
}
fun openGitHubIssues(context: Context) {
ShareUtils.openUrlInApp(context, ERROR_GITHUB_ISSUE_URL)
}
fun openPrivacyPolicy(context: Context) {
ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))
}
private fun getContentLanguage(context: Context): String = Localization.getPreferredLocalization(context).localizationCode
private fun getContentCountry(context: Context): String = Localization.getPreferredContentCountry(context).countryCode
private fun getAppLanguage(): String = Localization.getAppLocale().toString()
private fun getOsString(): String {
val name = System.getProperty("os.name")!!
val osBase = Build.VERSION.BASE_OS.ifEmpty { "Android" }
return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}"
}
private fun getCurrentTimestamp(): String = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}
@@ -14,6 +14,7 @@ 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.ComposeActivity
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
@@ -25,7 +26,7 @@ import org.schabi.newpipe.R
* is available.
* - Use a notification if the exception happens inside a background service (player, subscription
* import, ...) or there is no activity/fragment from which to extract a root view.
* - Finally use the error activity only as a last resort in case the exception is critical and
* - Finally use the error screen only as a last resort in case the exception is critical and
* happens in an open activity (since the workflow would be interrupted anyway in that case).
*/
class ErrorUtil {
@@ -33,7 +34,7 @@ class ErrorUtil {
private const val ERROR_REPORT_NOTIFICATION_ID = 5340681
/**
* Starts a new error activity allowing the user to report the provided error. Only use this
* Starts a new error screen allowing the user to report the provided error. Only use this
* method directly as a last resort in case the exception is critical and happens in an open
* activity (since the workflow would be interrupted anyway in that case). So never use this
* for background services.
@@ -50,12 +51,12 @@ class ErrorUtil {
) {
createNotification(context, errorInfo)
} else {
context.startActivity(getErrorActivityIntent(context, errorInfo))
context.startActivity(getErrorScreenIntent(context, errorInfo))
}
}
/**
* Show a bottom snackbar to the user, with a report button that opens the error activity.
* Show a bottom snackbar to the user, with a report button that opens the error screen.
* Use this method if the exception is not critical and it happens in a place where a root
* view is available.
*
@@ -70,7 +71,7 @@ class ErrorUtil {
}
/**
* Show a bottom snackbar to the user, with a report button that opens the error activity.
* Show a bottom snackbar to the user, with a report button that opens the error screen.
* Use this method if the exception is not critical and it happens in a place where a root
* view is available.
*
@@ -104,7 +105,7 @@ class ErrorUtil {
}
/**
* Create an error notification. Tapping on the notification opens the error activity. Use
* Create an error notification. Tapping on the notification opens the error screen. Use
* this method if the exception happens inside a background service (player, subscription
* import, ...) or there is no activity/fragment from which to extract a root view.
*
@@ -128,7 +129,7 @@ class ErrorUtil {
PendingIntentCompat.getActivity(
context,
0,
getErrorActivityIntent(context, errorInfo),
getErrorScreenIntent(context, errorInfo),
PendingIntent.FLAG_UPDATE_CURRENT,
false
)
@@ -147,11 +148,8 @@ class ErrorUtil {
}
}
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
val intent = Intent(context, ErrorActivity::class.java)
intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return intent
private fun getErrorScreenIntent(context: Context, errorInfo: ErrorInfo): Intent {
return ComposeActivity.errorIntent(context, errorInfo)
}
private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) {
@@ -162,7 +160,7 @@ class ErrorUtil {
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
.setActionTextColor(Color.YELLOW)
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
context.startActivity(getErrorActivityIntent(context, errorInfo))
context.startActivity(getErrorScreenIntent(context, errorInfo))
}.show()
}
}
@@ -6,9 +6,11 @@ import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
public class EmptyFragment extends BaseFragment {
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
@@ -26,8 +28,10 @@ public class EmptyFragment extends BaseFragment {
final Bundle savedInstanceState) {
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
view.findViewById(R.id.empty_state_view).setVisibility(
showMessage ? View.VISIBLE : View.GONE);
final ComposeView composeView = view.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(composeView);
composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE);
return view;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
import android.content.Context;
import android.content.SharedPreferences;
@@ -10,7 +11,6 @@ import android.graphics.Color;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -45,6 +45,7 @@ import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -194,6 +195,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
tabAdapter = new TabAdapter(getChildFragmentManager());
binding.viewPager.setAdapter(tabAdapter);
binding.tabLayout.setupWithViewPager(binding.viewPager);
@@ -647,8 +650,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return;
}
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
binding.channelKaomoji.setText("(︶︹︺)");
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
binding.emptyStateView.setVisibility(View.VISIBLE);
}
}
@@ -26,6 +26,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
@@ -79,6 +80,12 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
EmptyStateUtil.setEmptyStateComposable(rootView.findViewById(R.id.empty_state_view));
}
@Override
public void onDestroyView() {
super.onDestroyView();
@@ -1,172 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.text.HtmlCompat;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.text.TextLinkifier;
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
import java.util.Queue;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public final class CommentRepliesFragment
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
@State
CommentsInfoItem commentsInfoItem; // the comment to show replies of
private final CompositeDisposable disposables = new CompositeDisposable();
/*//////////////////////////////////////////////////////////////////////////
// Constructors and lifecycle
//////////////////////////////////////////////////////////////////////////*/
// only called by the Android framework, after which readFrom is called and restores all data
public CommentRepliesFragment() {
super(UserAction.REQUESTED_COMMENT_REPLIES);
}
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
this();
this.commentsInfoItem = commentsInfoItem;
// setting "" as title since the title will be properly set right after
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
}
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comments, container, false);
}
@Override
public void onDestroyView() {
disposables.clear();
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
return () -> {
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final CommentsInfoItem item = commentsInfoItem;
// load the author avatar
CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars());
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
? View.VISIBLE : View.GONE);
// setup author name and comment date
binding.authorName.setText(item.getUploaderName());
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
binding.authorTouchArea.setOnClickListener(
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
// setup like count, hearted and pinned
binding.thumbsUpCount.setText(
Localization.likeCount(requireContext(), item.getLikeCount()));
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
// not to use a different margin only when both the next two views are gone
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
.setMarginEnd(DeviceUtils.dpToPx(
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
requireContext()));
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
// setup comment content
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
item.getUrl(), disposables, null);
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
return binding.getRoot();
};
}
/*//////////////////////////////////////////////////////////////////////////
// State saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public void writeTo(final Queue<Object> objectsToSave) {
super.writeTo(objectsToSave);
objectsToSave.add(commentsInfoItem);
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects);
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
}
/*//////////////////////////////////////////////////////////////////////////
// Data loading
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<CommentRepliesInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem,
// the reply count string will be shown as the activity title
Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount())));
}
@Override
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
// commentsInfoItem.getUrl() should contain the url of the original
// ListInfo<CommentsInfoItem>, which should be the stream url
return ExtractorHelper.getMoreCommentItems(
serviceId, commentsInfoItem.getUrl(), currentNextPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
/**
* @return the comment to which the replies are shown
*/
public CommentsInfoItem getCommentsInfoItem() {
return commentsInfoItem;
}
}
@@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import java.util.Collections;
public final class CommentRepliesInfo extends ListInfo<CommentsInfoItem> {
/**
* This class is used to wrap the comment replies page into a ListInfo object.
*
* @param comment the comment from which to get replies
* @param name will be shown as the fragment title
*/
public CommentRepliesInfo(final CommentsInfoItem comment, final String name) {
super(comment.getServiceId(),
new ListLinkHandler("", "", "", Collections.emptyList(), null), name);
setNextPage(comment.getReplies());
setRelatedItems(Collections.emptyList()); // since it must be non-null
}
}
@@ -1,123 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
private final CompositeDisposable disposables = new CompositeDisposable();
private TextView emptyStateDesc;
public static CommentsFragment getInstance(final int serviceId, final String url,
final String name) {
final CommentsFragment instance = new CommentsFragment();
instance.setInitialData(serviceId, url, name);
return instance;
}
public CommentsFragment() {
super(UserAction.REQUESTED_COMMENTS);
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
emptyStateDesc = rootView.findViewById(R.id.empty_state_desc);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comments, container, false);
}
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
/*//////////////////////////////////////////////////////////////////////////
// Load and handle
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
}
@Override
protected Single<CommentsInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
public void handleResult(@NonNull final CommentsInfo result) {
super.handleResult(result);
emptyStateDesc.setText(
result.isCommentsDisabled()
? R.string.comments_are_disabled
: R.string.no_comments);
ViewUtils.slideUp(requireView(), 120, 150, 0.06f);
disposables.clear();
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) { }
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) { }
@Override
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
public boolean scrollToComment(final CommentsInfoItem comment) {
final int position = infoListAdapter.getItemsList().indexOf(comment);
if (position < 0) {
return false;
}
itemsList.scrollToPosition(position);
return true;
}
}
@@ -0,0 +1,34 @@
package org.schabi.newpipe.fragments.list.comments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.ui.components.video.comment.CommentSection
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_SERVICE_ID
import org.schabi.newpipe.util.KEY_URL
class CommentsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
CommentSection()
}
}
}
companion object {
@JvmStatic
fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply {
arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url)
}
}
}
@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.search;
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static java.util.Arrays.asList;
@@ -10,12 +11,19 @@ import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.Editable;
import android.text.Html;
import android.text.Layout;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.AlignmentSpan;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
@@ -65,6 +73,7 @@ import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@@ -355,6 +364,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
setEmptyStateComposable(searchBinding.emptyStateView, EmptyStateSpec.NoSearchResult);
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
// animations are just strange and useless, since the suggestions keep changing too much
searchBinding.suggestionsList.setItemAnimator(null);
@@ -453,22 +464,28 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) {
if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) {
final MenuItem musicItem = menu.add(2,
menu.add(2,
itemId++,
0,
"YouTube Music");
musicItem.setEnabled(false);
getStyledHeader("YouTube"));
menu.add(2,
itemId++,
1,
getStyledHeader("YouTube Music"));
} else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) {
final MenuItem sepiaItem = menu.add(2,
menu.add(2,
itemId++,
0,
"Sepia Search");
sepiaItem.setEnabled(false);
getStyledHeader("Search"));
menu.add(2,
itemId++,
1,
getStyledHeader("Sepia Search"));
}
menuItemToFilterName.put(itemId, filter);
final MenuItem item = menu.add(1,
itemId++,
0,
1,
ServiceHelper.getTranslatedFilterString(filter, c));
if (isFirstItem) {
item.setChecked(true);
@@ -480,6 +497,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
restoreFilterChecked(menu, filterItemCheckedId);
}
private SpannableString getStyledHeader(final String title) {
final SpannableString styledTitle = new SpannableString(title);
styledTitle.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(), 0);
styledTitle.setSpan(new ForegroundColorSpan(Color.GRAY), 0, title.length(), 0);
styledTitle.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER),
0, title.length(), 0);
return styledTitle;
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId()));
@@ -1,202 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.ktx.ViewUtils;
import java.io.Serializable;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key";
private RelatedItemsInfo relatedItemsInfo;
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
private RelatedItemsHeaderBinding headerBinding;
public static RelatedItemsFragment getInstance(final StreamInfo info) {
final RelatedItemsFragment instance = new RelatedItemsFragment();
instance.setInitialData(info);
return instance;
}
public RelatedItemsFragment() {
super(UserAction.REQUESTED_STREAM);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_related_items, container, false);
}
@Override
public void onDestroyView() {
headerBinding = null;
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
return null;
}
headerBinding = RelatedItemsHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final SharedPreferences pref = PreferenceManager
.getDefaultSharedPreferences(requireContext());
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
headerBinding.autoplaySwitch.setChecked(autoplay);
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
.putBoolean(getString(R.string.auto_queue_key), b).apply());
return headerBinding::getRoot;
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> relatedItemsInfo);
}
@Override
public void showLoading() {
super.showLoading();
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.INVISIBLE);
}
}
@Override
public void handleResult(@NonNull final RelatedItemsInfo result) {
super.handleResult(result);
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.VISIBLE);
}
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
// Nothing to do - override parent
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
// Nothing to do - override parent
}
private void setInitialData(final StreamInfo info) {
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
if (this.relatedItemsInfo == null) {
this.relatedItemsInfo = new RelatedItemsInfo(info);
}
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(INFO_KEY, relatedItemsInfo);
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
super.onRestoreInstanceState(savedState);
final Serializable serializable = savedState.getSerializable(INFO_KEY);
if (serializable instanceof RelatedItemsInfo) {
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
}
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
}
}
@Override
protected ItemViewMode getItemViewMode() {
ItemViewMode mode = super.getItemViewMode();
// Only list mode is supported. Either List or card will be used.
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
mode = ItemViewMode.LIST;
}
return mode;
}
@Override
protected void showInfoItemDialog(final StreamInfoItem item) {
// Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment
// so that its context is not lost when the RelatedItemsFragment is reinitialized,
// e.g. when a new stream is loaded in a parent VideoDetailFragment.
final Fragment parentFragment = getParentFragment();
if (parentFragment != null) {
try {
new InfoItemDialog.Builder(
parentFragment.getActivity(),
parentFragment.getContext(),
parentFragment,
item
).create().show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
}
} else {
super.showInfoItemDialog(item);
}
}
}
@@ -0,0 +1,35 @@
package org.schabi.newpipe.fragments.list.videos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.ktx.serializable
import org.schabi.newpipe.ui.components.video.RelatedItems
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_INFO
class RelatedItemsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}
}
companion object {
@JvmStatic
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
arguments = bundleOf(KEY_INFO to info)
}
}
}
@@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import java.util.ArrayList;
import java.util.Collections;
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
/**
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
*
* @param info the stream info from which to get related items
*/
public RelatedItemsInfo(final StreamInfo info) {
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
info.getId(), Collections.emptyList(), null), info.getName());
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
}
}
@@ -21,7 +21,6 @@ import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
@@ -283,46 +282,32 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
return switch (type) {
// #4475 and #3368
// Always create a new instance otherwise the same instance
// is sometimes reused which causes a crash
case HEADER_TYPE:
return new HFHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE:
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE:
return new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE:
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE:
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE:
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE:
return new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE:
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE:
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE:
return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE:
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE:
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE:
return new CommentInfoItemHolder(infoItemBuilder, parent);
default:
return new FallbackViewHolder(new View(parent.getContext()));
}
case HEADER_TYPE -> new HFHolder(headerSupplier.get());
case FOOTER_TYPE -> new HFHolder(PignateFooterBinding
.inflate(layoutInflater, parent, false)
.getRoot()
);
case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent);
case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent);
case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent);
case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent);
case MINI_PLAYLIST_HOLDER_TYPE ->
new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE ->
new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE ->
new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
default -> new FallbackViewHolder(new View(parent.getContext()));
};
}
@Override
@@ -252,7 +252,7 @@ public final class InfoItemDialog {
* @return the current {@link Builder} instance
*/
public Builder addEnqueueEntriesIfNeeded() {
final PlayerHolder holder = PlayerHolder.getInstance();
final PlayerHolder holder = PlayerHolder.INSTANCE;
if (holder.isPlayQueueReady()) {
addEntry(StreamDialogDefaultEntry.ENQUEUE);
@@ -45,10 +45,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
* </p>
*/
public enum StreamDialogDefaultEntry {
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
),
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
final var activity = fragment.requireActivity();
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
}),
/**
* Enqueues the stream automatically to the current PlayerType.
@@ -1,210 +0,0 @@
package org.schabi.newpipe.info_list.holder;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.text.TextEllipsizer;
public class CommentInfoItemHolder extends InfoItemHolder {
private static final int COMMENT_DEFAULT_LINES = 2;
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final ImageView itemThumbsUpView;
private final TextView itemLikesCountView;
private final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
private final Button repliesButton;
@NonNull
private final TextEllipsizer textEllipsizer;
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_comment_item, parent);
itemRoot = itemView.findViewById(R.id.itemRoot);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
repliesButton = itemView.findViewById(R.id.replies_button);
commentHorizontalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
textEllipsizer.setStateChangeListener(isEllipsized -> {
if (Boolean.TRUE.equals(isEllipsized)) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
if (!(infoItem instanceof CommentsInfoItem item)) {
return;
}
// load the author avatar
CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars());
if (ImageStrategy.shouldLoadImages()) {
itemThumbnailView.setVisibility(View.VISIBLE);
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
commentVerticalPadding, commentVerticalPadding);
} else {
itemThumbnailView.setVisibility(View.GONE);
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
commentHorizontalPadding, commentVerticalPadding);
}
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
// setup the top row, with pinned icon, author name and comment date
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
itemTitleView.setText(Localization.concatenateStrings(
uploaderName,
Localization.relativeTimeOrTextual(
itemBuilder.getContext(),
item.getUploadDate(),
item.getTextualUploadDate())));
// setup bottom row, with likes, heart and replies button
itemLikesCountView.setText(
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
final boolean hasReplies = item.getReplies() != null;
repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null);
repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE);
repliesButton.setText(hasReplies
? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : "");
((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin =
hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext());
// setup comment content and click listeners to expand/ellipsize it
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
textEllipsizer.setStreamUrl(item.getUrl());
textEllipsizer.setContent(item.getCommentText());
textEllipsizer.ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener((v, event) -> {
final CharSequence text = itemContentView.getText();
if (text instanceof Spanned buffer) {
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(itemContentView, event);
final var links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(itemContentView);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
});
itemView.setOnClickListener(view -> {
textEllipsizer.toggle();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
});
itemView.setOnLongClickListener(view -> {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
final CharSequence text = itemContentView.getText();
if (text != null) {
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
}
}
return true;
});
}
private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
item);
}
private void openCommentReplies(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
item);
}
private void allowLinkFocus() {
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
}
private void denyLinkFocus() {
itemContentView.setMovementMethod(null);
}
private boolean shouldFocusLinks() {
if (itemView.isInTouchMode()) {
return false;
}
final URLSpan[] urls = itemContentView.getUrls();
return urls != null && urls.length != 0;
}
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
denyLinkFocus();
}
}
}
@@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
StreamStateEntity state2 = null;
if (DependentPreferenceHelper
.getPositionsInListsEnabled(itemProgressView.getContext())) {
state2 = historyRecordManager.loadStreamState(infoItem)
.blockingGet()[0];
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
}
if (state2 != null) {
itemProgressView.setVisibility(View.VISIBLE);
@@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
state = historyRecordManager
.loadStreamState(infoItem)
.blockingGet()[0];
.blockingGet();
}
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
@@ -1,11 +1,11 @@
package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import java.io.Serializable
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return BundleCompat.getSerializable(this, key, T::class.java)
}
fun Bundle?.toDebugString(): String {
@@ -0,0 +1,27 @@
package org.schabi.newpipe.ktx
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.fragment.app.FragmentActivity
import kotlinx.serialization.json.Json
import net.newpipe.Constants
import net.newpipe.app.ComposeActivity
import net.newpipe.app.navigation.Destination
tailrec fun Context.findFragmentActivity(): FragmentActivity {
return when (this) {
is FragmentActivity -> this
is ContextWrapper -> baseContext.findFragmentActivity()
else -> throw IllegalStateException("Unable to find FragmentActivity")
}
}
/**
* Navigates to a given compose destination
*/
fun Context.navigateTo(destination: Destination) {
val intent = Intent(this, ComposeActivity::class.java)
.putExtra(Constants.INTENT_SCREEN_KEY, Json.encodeToString(destination))
startActivity(intent)
}
@@ -22,9 +22,6 @@ import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
@@ -66,10 +63,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int HEADER_TYPE = 0;
private static final int FOOTER_TYPE = 1;
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
@@ -303,14 +297,6 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
} else {
return STREAM_PLAYLIST_HOLDER_TYPE;
}
case STATISTIC_STREAM_ITEM:
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
} else {
return STREAM_STATISTICS_HOLDER_TYPE;
}
default:
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
@@ -326,43 +312,36 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
Log.d(TAG, "onCreateViewHolder() called with: "
+ "parent = [" + parent + "], type = [" + type + "]");
}
switch (type) {
case HEADER_TYPE:
return new HeaderFooterHolder(headerSupplier.get());
case FOOTER_TYPE:
return new HeaderFooterHolder(footer);
case LOCAL_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_HOLDER_TYPE:
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
default:
return switch (type) {
case HEADER_TYPE -> new HeaderFooterHolder(headerSupplier.get());
case FOOTER_TYPE -> new HeaderFooterHolder(footer);
case LOCAL_PLAYLIST_HOLDER_TYPE ->
new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE ->
new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE ->
new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE ->
new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE ->
new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE ->
new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE ->
new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE ->
new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE ->
new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
default -> {
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
return new FallbackViewHolder(new View(parent.getContext()));
}
yield new FallbackViewHolder(new View(parent.getContext()));
}
};
}
@SuppressWarnings("FinalParameters")
@@ -16,6 +16,7 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@@ -39,6 +40,8 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.debounce.DebounceSavable;
@@ -124,6 +127,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
super.initViews(rootView, savedInstanceState);
itemListAdapter.setUseItemHandle(true);
final ComposeView emptyView = rootView.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
}
@Override
@@ -1,7 +1,6 @@
package org.schabi.newpipe.local.feed
import android.content.Context
import android.util.Log
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
@@ -10,6 +9,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
import kotlinx.coroutines.rx3.await
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedEntity
@@ -110,20 +110,9 @@ class FeedDatabaseManager(context: Context) {
)
}
fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
suspend fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
streamTable.deleteOrphans()
}
fun clear() {
feedTable.deleteAll()
val deletedOrphans = streamTable.deleteOrphans()
if (DEBUG) {
Log.d(
this::class.java.simpleName,
"clear() → streamTable.deleteOrphans() → $deletedOrphans"
)
}
streamTable.deleteOrphans().await()
}
// /////////////////////////////////////////////////////////////////////////
@@ -76,6 +76,7 @@ import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
@@ -135,6 +136,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
// super.onViewCreated() calls initListeners() which require the binding to be initialized
_feedBinding = FragmentFeedBinding.bind(rootView)
feedBinding.emptyStateView.setEmptyStateComposable()
super.onViewCreated(rootView, savedInstanceState)
val factory = FeedViewModel.getFactory(requireContext(), groupId)
@@ -204,6 +206,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// Menu
// /////////////////////////////////////////////////////////////////////////
@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
@@ -214,6 +217,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
inflater.inflate(R.menu.menu_feed_fragment, menu)
}
@Deprecated("Deprecated in Java")
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.menu_item_feed_help) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@@ -269,6 +273,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
.show()
}
@Deprecated("Deprecated in Java")
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
if (
@@ -76,6 +76,7 @@ class NotificationHelper(val context: Context) {
val avatarIcon =
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
summaryBuilder.setLargeIcon(avatarIcon)
// Show individual stream notifications, set channel icon only if there is actually one
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Notification
import io.reactivex.rxjava3.core.Single
@@ -15,6 +14,8 @@ import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.rx3.rxCompletable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode
@@ -262,7 +263,7 @@ class FeedLoadManager(private val context: Context) {
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
* Remove streams from the database which are not linked / used by any table.
*/
private fun postProcessFeed() = Completable.fromRunnable {
private fun postProcessFeed() = rxCompletable(Dispatchers.IO) {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
feedDatabaseManager.removeOrphansOrOlderStreams()
@@ -0,0 +1,94 @@
package org.schabi.newpipe.local.history
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.Surface
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.await
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.ui.screens.HistoryScreen
import org.schabi.newpipe.ui.theme.AppTheme
class HistoryFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
HistoryScreen()
}
}
}
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
val context = requireActivity()
(context as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.title_activity_history)
val recordManager = HistoryRecordManager(context)
context.addMenuProvider(
object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.menu_history, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_history_clear -> {
AlertDialog.Builder(context)
.setTitle(R.string.delete_view_history_alert)
.setNegativeButton(R.string.cancel) { dialog, which -> dialog.dismiss() }
.setPositiveButton(R.string.delete) { dialog, which ->
viewLifecycleOwner.lifecycleScope.launch {
launch(getExceptionHandler("Delete playback states")) {
recordManager.deleteCompleteStreamStateHistory().await()
Toast
.makeText(context, R.string.watch_history_states_deleted, Toast.LENGTH_SHORT)
.show()
}
launch(getExceptionHandler("Delete watch history")) {
recordManager.deleteWholeStreamHistory().await()
Toast.makeText(context, R.string.watch_history_deleted, Toast.LENGTH_SHORT)
.show()
}
launch(getExceptionHandler("Clear orphaned records")) {
recordManager.removeOrphanedRecords().await()
}
}
}
.show()
}
}
return true
}
},
viewLifecycleOwner
)
}
private fun getExceptionHandler(action: String) = CoroutineExceptionHandler { _, throwable ->
openActivity(requireContext(), ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, action))
}
}
@@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.collection.LongLongPair;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.NewPipeDatabase;
@@ -45,7 +48,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.local.feed.FeedViewModel;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
@@ -91,47 +93,39 @@ public class HistoryRecordManager {
* @param info the item to mark as watched
* @return a Maybe containing the ID of the item if successful
*/
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
public Completable markAsWatched(final StreamInfoItem info) {
if (!isStreamHistoryEnabled()) {
return Maybe.empty();
return Completable.complete();
}
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final long streamId;
final long duration;
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
if (info.getDuration() < 0) {
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
info.getServiceId(),
info.getUrl(),
false
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
return Single.just(info)
.filter(item -> item.getDuration() >= 0)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
)
.subscribeOn(Schedulers.io())
.blockingGet();
duration = completeInfo.getDuration();
streamId = streamTable.upsert(new StreamEntity(completeInfo));
} else {
duration = info.getDuration();
streamId = streamTable.upsert(new StreamEntity(info));
}
.switchIfEmpty(remoteInfo)
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
final long duration = pair.getFirst();
final long streamId = pair.getSecond();
// Update the stream progress to the full duration of the video
final StreamStateEntity entity = new StreamStateEntity(
streamId,
duration * 1000
);
streamStateTable.upsert(entity);
// Update the stream progress to the full duration of the video
final var entity = new StreamStateEntity(streamId, duration * 1000);
streamStateTable.upsert(entity);
// Add a history entry
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
// never actually viewed: add history entry but with 0 views
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
} else {
return 0L;
}
})).subscribeOn(Schedulers.io());
// Add a history entry
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
// never actually viewed: add history entry but with 0 views
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
streamHistoryTable.insert(entry);
}
}))
.subscribeOn(Schedulers.io());
}
public Maybe<Long> onViewed(final StreamInfo info) {
@@ -157,30 +151,22 @@ public class HistoryRecordManager {
}
public Completable deleteStreamHistoryAndState(final long streamId) {
return Completable.fromAction(() -> {
streamStateTable.deleteState(streamId);
streamHistoryTable.deleteStreamHistory(streamId);
}).subscribeOn(Schedulers.io());
return streamStateTable.deleteState(streamId)
.andThen(streamHistoryTable.deleteStreamHistory(streamId));
}
public Single<Integer> deleteWholeStreamHistory() {
return Single.fromCallable(streamHistoryTable::deleteAll)
.subscribeOn(Schedulers.io());
public Completable deleteWholeStreamHistory() {
return streamHistoryTable.deleteAll().subscribeOn(Schedulers.io());
}
public Single<Integer> deleteCompleteStreamStateHistory() {
return Single.fromCallable(streamStateTable::deleteAll)
.subscribeOn(Schedulers.io());
public Completable deleteCompleteStreamStateHistory() {
return streamStateTable.deleteAll().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamStatisticsEntry>> getStreamStatistics() {
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
}
private boolean isStreamHistoryEnabled() {
return sharedPreferences.getBoolean(streamHistoryKey, false);
}
@@ -221,7 +207,7 @@ public class HistoryRecordManager {
public Flowable<List<String>> getRelatedSearches(final String query,
final int similarQueryLimit,
final int uniqueQueryLimit) {
return query.length() > 0
return !query.isEmpty()
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
}
@@ -236,47 +222,31 @@ public class HistoryRecordManager {
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map(info -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.flatMapMaybe(this::loadStreamState)
.filter(state -> state.isValid(queueItem.getDuration()))
.subscribeOn(Schedulers.io());
}
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.filter(state -> state.isValid(info.getDuration()))
.flatMapMaybe(streamStateTable::getState)
.subscribeOn(Schedulers.io());
}
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
return Completable.fromAction(() -> database.runInTransaction(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
final var state = new StreamStateEntity(streamId, progressMillis);
if (state.isValid(info.getDuration())) {
streamStateTable.upsert(state);
}
})).subscribeOn(Schedulers.io());
}
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
return Single.fromCallable(() -> {
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
return new StreamStateEntity[]{null};
}
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
return new StreamStateEntity[]{null};
}
return new StreamStateEntity[]{states.get(0)};
}).subscribeOn(Schedulers.io());
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
return streamTable.getStream(info.getServiceId(), info.getUrl())
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
.subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
@@ -295,13 +265,7 @@ public class HistoryRecordManager {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
.blockingFirst();
if (states.isEmpty()) {
result.add(null);
} else {
result.add(states.get(0));
}
result.add(streamStateTable.getState(streamId).blockingGet());
}
return result;
}).subscribeOn(Schedulers.io());
@@ -311,8 +275,7 @@ public class HistoryRecordManager {
// Utility
///////////////////////////////////////////////////////
public Single<Integer> removeOrphanedRecords() {
return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io());
public Completable removeOrphanedRecords() {
return streamTable.deleteOrphans().subscribeOn(Schedulers.io());
}
}
@@ -0,0 +1,42 @@
package org.schabi.newpipe.local.history
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.ui.components.items.Stream
class HistoryViewModel(
application: Application,
private val savedStateHandle: SavedStateHandle
) : AndroidViewModel(application) {
private val historyDao = NewPipeDatabase.getInstance(getApplication()).streamHistoryDAO()
val sortKey = savedStateHandle.getStateFlow(ORDER_KEY, SortKey.LAST_PLAYED)
val historyItems = sortKey
.flatMapLatest {
Pager(PagingConfig(pageSize = 20)) {
historyDao.getOrderedHistory(it)
}.flow
}
.map { pagingData -> pagingData.map { Stream(it) } }
.flowOn(Dispatchers.IO)
.cachedIn(viewModelScope)
fun updateOrder(sortKey: SortKey) {
savedStateHandle[ORDER_KEY] = sortKey
}
companion object {
private const val ORDER_KEY = "order"
}
}
@@ -0,0 +1,9 @@
package org.schabi.newpipe.local.history
import androidx.annotation.StringRes
import org.schabi.newpipe.R
enum class SortKey(@StringRes val title: Int) {
LAST_PLAYED(R.string.history_sort_date),
MOST_PLAYED(R.string.history_sort_views)
}
@@ -1,392 +0,0 @@
package org.schabi.newpipe.local.history;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable();
@State
Parcelable itemsListState;
private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED;
private StatisticPlaylistControlBinding headerBinding;
private PlaylistControlBinding playlistControlBinding;
/* Used for independent events */
private Subscription databaseSubscription;
private HistoryRecordManager recordManager;
private List<StreamStatisticsEntry> processResult(final List<StreamStatisticsEntry> results) {
final Comparator<StreamStatisticsEntry> comparator;
switch (sortMode) {
case LAST_PLAYED:
comparator = Comparator.comparing(StreamStatisticsEntry::getLatestAccessDate);
break;
case MOST_PLAYED:
comparator = Comparator.comparingLong(StreamStatisticsEntry::getWatchCount);
break;
default:
return null;
}
Collections.sort(results, comparator.reversed());
return results;
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
///////////////////////////////////////////////////////////////////////////
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
recordManager = new HistoryRecordManager(getContext());
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_playlist, container, false);
}
@Override
public void onResume() {
super.onResume();
if (activity != null) {
setTitle(activity.getString(R.string.title_activity_history));
}
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.menu_history, menu);
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Views
///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
if (!useAsFrontPage) {
setTitle(getString(R.string.title_last_played));
}
}
@Override
protected Supplier<View> getListHeaderSupplier() {
headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(),
itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding::getRoot;
}
@Override
protected void initListeners() {
super.initListeners();
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
final StreamEntity item =
((StreamStatisticsEntry) selectedItem).getStreamEntity();
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
}
}
@Override
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
showInfoItemDialog((StreamStatisticsEntry) selectedItem);
}
}
});
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.action_history_clear) {
HistorySettingsFragment
.openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables);
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Loading
///////////////////////////////////////////////////////////////////////////
@Override
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
recordManager.getStreamStatistics()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHistoryObserver());
}
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Destruction
///////////////////////////////////////////////////////////////////////////
@Override
public void onPause() {
super.onPause();
itemsListState = Objects.requireNonNull(itemsList.getLayoutManager()).onSaveInstanceState();
}
@Override
public void onDestroyView() {
super.onDestroyView();
if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener();
}
headerBinding = null;
playlistControlBinding = null;
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = null;
}
@Override
public void onDestroy() {
super.onDestroy();
recordManager = null;
itemsListState = null;
}
///////////////////////////////////////////////////////////////////////////
// Statistics Loader
///////////////////////////////////////////////////////////////////////////
private Subscriber<List<StreamStatisticsEntry>> getHistoryObserver() {
return new Subscriber<List<StreamStatisticsEntry>>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
databaseSubscription = s;
databaseSubscription.request(1);
}
@Override
public void onNext(final List<StreamStatisticsEntry> streams) {
handleResult(streams);
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
}
@Override
public void onError(final Throwable exception) {
showError(
new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics"));
}
@Override
public void onComplete() {
}
};
}
@Override
public void handleResult(@NonNull final List<StreamStatisticsEntry> result) {
super.handleResult(result);
if (itemListAdapter == null) {
return;
}
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
itemListAdapter.clearStreamItemList();
if (result.isEmpty()) {
showEmptyState();
return;
}
itemListAdapter.addItems(processResult(result));
if (itemsListState != null && itemsList.getLayoutManager() != null) {
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
hideLoading();
}
///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling
///////////////////////////////////////////////////////////////////////////
@Override
protected void resetFragment() {
super.resetFragment();
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
private void toggleSortMode() {
if (sortMode == StatisticSortMode.LAST_PLAYED) {
sortMode = StatisticSortMode.MOST_PLAYED;
setTitle(getString(R.string.title_most_played));
headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history);
headerBinding.sortButtonText.setText(R.string.title_last_played);
} else {
sortMode = StatisticSortMode.LAST_PLAYED;
setTitle(getString(R.string.title_last_played));
headerBinding.sortButtonIcon.setImageResource(
R.drawable.ic_filter_list);
headerBinding.sortButtonText.setText(R.string.title_most_played);
}
startLoading(true);
}
private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) {
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
}
private void showInfoItemDialog(final StreamStatisticsEntry item) {
final Context context = getContext();
final StreamInfoItem infoItem = item.toStreamInfoItem();
try {
final InfoItemDialog.Builder dialogBuilder =
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
// set entries in the middle; the others are added automatically
dialogBuilder
.addEntry(StreamDialogDefaultEntry.DELETE)
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteEntry(
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
.create()
.show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
}
}
private void deleteEntry(final int index) {
final LocalItem infoItem = itemListAdapter.getItemsList().get(index);
if (infoItem instanceof StreamStatisticsEntry) {
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
final Disposable onDelete = recordManager
.deleteStreamHistoryAndState(entry.getStreamId())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
if (getView() != null) {
Snackbar.make(getView(), R.string.one_item_deleted,
Snackbar.LENGTH_SHORT).show();
} else {
Toast.makeText(getContext(),
R.string.one_item_deleted,
Toast.LENGTH_SHORT).show();
}
},
throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY, "Deleting item")));
disposables.add(onDelete);
}
}
@Override
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
private PlayQueue getPlayQueue(final int index) {
if (itemListAdapter == null) {
return new SinglePlayQueue(Collections.emptyList(), 0);
}
final List<LocalItem> infoItems = itemListAdapter.getItemsList();
final List<StreamInfoItem> streamInfoItems = new ArrayList<>(infoItems.size());
for (final LocalItem item : infoItems) {
if (item instanceof StreamStatisticsEntry) {
streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem());
}
}
return new SinglePlayQueue(streamInfoItems, index);
}
private enum StatisticSortMode {
LAST_PLAYED,
MOST_PLAYED,
}
}
@@ -1,13 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}
@@ -1,13 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_grid_item, parent);
}
}
@@ -1,161 +0,0 @@
package org.schabi.newpipe.local.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.image.CoilHelper;
import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/*
* Created by Christian Schabesberger on 01.08.16.
* <p>
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* StreamInfoItemHolder.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>
* 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>
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class LocalStatisticStreamItemHolder extends LocalItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemVideoTitleView;
public final TextView itemUploaderView;
public final TextView itemDurationView;
@Nullable
public final TextView itemAdditionalDetails;
private final AnimatedProgressBar itemProgressView;
public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder,
final ViewGroup parent) {
this(itemBuilder, R.layout.list_stream_item, parent);
}
LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView);
itemUploaderView = itemView.findViewById(R.id.itemUploaderView);
itemDurationView = itemView.findViewById(R.id.itemDurationView);
itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails);
itemProgressView = itemView.findViewById(R.id.itemProgressView);
}
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateTimeFormatter dateTimeFormatter) {
return Localization.concatenateStrings(
// watchCount
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
dateTimeFormatter.format(entry.getLatestAccessDate()),
// serviceName
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
}
@Override
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateTimeFormatter dateTimeFormatter) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemUploaderView.setText(item.getStreamEntity().getUploader());
if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.
setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.duration_background_color));
itemDurationView.setVisibility(View.VISIBLE);
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
&& item.getProgressMillis() > 0) {
itemProgressView.setVisibility(View.VISIBLE);
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setVisibility(View.GONE);
}
} else {
itemDurationView.setVisibility(View.GONE);
itemProgressView.setVisibility(View.GONE);
}
if (itemAdditionalDetails != null) {
itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateTimeFormatter));
}
// Default thumbnail is shown on error, while loading and if the url is empty
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView,
item.getStreamEntity().getThumbnailUrl());
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnItemSelectedListener() != null) {
itemBuilder.getOnItemSelectedListener().selected(item);
}
});
itemView.setLongClickable(true);
itemView.setOnLongClickListener(view -> {
if (itemBuilder.getOnItemSelectedListener() != null) {
itemBuilder.getOnItemSelectedListener().held(item);
}
return true;
});
}
@Override
public void updateState(final LocalItem localItem,
final HistoryRecordManager historyRecordManager) {
if (!(localItem instanceof StreamStatisticsEntry)) {
return;
}
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
&& item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
} else {
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
.toSeconds(item.getProgressMillis()));
ViewUtils.animate(itemProgressView, true, 500);
}
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
ViewUtils.animate(itemProgressView, false, 500);
}
}
}
@@ -46,6 +46,7 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.GroupsHeader
import org.schabi.newpipe.local.subscription.item.Header
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ServiceHelper
@@ -113,6 +114,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
// Menu
// ////////////////////////////////////////////////////////////////////////
@Deprecated("Deprecated in Java")
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
@@ -199,6 +201,8 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
binding.itemsList.adapter = groupAdapter
binding.itemsList.itemAnimator = null
binding.emptyStateView.setEmptyStateComposable()
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {
@@ -50,16 +50,13 @@ class SubscriptionManager(context: Context) {
}
}
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo?>>) {
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) {
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
subscriptionTable.upsertAll(listEntities)
database.runInTransaction {
infoList.forEachIndexed { index, info ->
// There may be no tabs on the channel to refresh the feed from
if (info.second == null) return@forEachIndexed
val streams = info.second!!.relatedItems.filterIsInstance<StreamInfoItem>()
val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>()
feedDatabaseManager.upsertAll(listEntities[index].uid, streams)
}
}
@@ -119,6 +119,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return object : Dialog(requireActivity(), theme) {
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (!this@FeedGroupDialog.onBackPressed()) {
super.onBackPressed()
@@ -3,14 +3,18 @@ package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ListEmptyViewBinding
import org.schabi.newpipe.databinding.ListEmptyViewSubscriptionsBinding
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
/**
* When there are no subscriptions, show a hint to the user about how to import subscriptions
*/
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewSubscriptionsBinding>() {
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
override fun bind(viewBinding: ListEmptyViewSubscriptionsBinding, position: Int) {
viewBinding.root.setEmptyStateComposable(EmptyStateSpec.NoSubscriptionsHint)
}
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
override fun initializeViewBinding(view: View) = ListEmptyViewSubscriptionsBinding.bind(view)
}
@@ -19,6 +19,7 @@ import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactive.awaitFirst
import kotlinx.coroutines.withContext
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
@@ -51,7 +52,9 @@ class SubscriptionExportWorker(
}
}
Log.i(TAG, "Exported $qty subscriptions")
if (BuildConfig.DEBUG) {
Log.i(TAG, "Exported $qty subscriptions")
}
withContext(Dispatchers.Main) {
Toast
@@ -60,8 +63,10 @@ class SubscriptionExportWorker(
}
Result.success()
} catch (exception: Exception) {
Log.e(TAG, "Error while exporting subscriptions", exception)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while exporting subscriptions", e)
}
withContext(Dispatchers.Main) {
Toast
@@ -1,18 +1,12 @@
/*
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.local.subscription.workers
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Parcelable
import android.util.Log
import android.webkit.MimeTypeMap
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
@@ -22,118 +16,101 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.rx3.await
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.streams.io.SharpInputStream
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionImportWorker(
private val context: Context,
private val params: WorkerParameters
) : CoroutineWorker(context, params) {
private lateinit var subscriptions: List<SubscriptionItem>
private val subscriptionManager = SubscriptionManager(context)
private val mutex = Mutex()
private val size: Int
get() = subscriptions.size
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
// This is needed for API levels < 31 (Android S).
override suspend fun getForegroundInfo(): ForegroundInfo {
return createForegroundInfo(title = context.getString(R.string.import_ongoing))
return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0)
}
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun doWork(): Result {
subscriptions = try {
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
} catch (exception: Exception) {
Log.e(TAG, "Error while loading subscriptions from path", exception)
withContext(Dispatchers.Main) {
Toast.makeText(context, R.string.subscriptions_import_unsuccessful, LENGTH_SHORT)
.show()
val subscriptions =
try {
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while loading subscriptions from path", e)
}
withContext(Dispatchers.Main) {
Toast
.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
.show()
}
return Result.failure()
}
return Result.failure()
}
val title = context.resources.getQuantityString(R.plurals.import_subscriptions, size, size)
setForeground(createForegroundInfo(title = title))
val mutex = Mutex()
var index = 1
val qty = subscriptions.size
var title =
applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty)
try {
var index = 1
subscriptions.asFlow()
.flatMapMerge(concurrency = PARALLEL_EXTRACTIONS) { subscription ->
flow {
runCatching {
ExtractorHelper.getChannelInfo(
subscription.serviceId,
subscription.url,
true
).await()
}.mapCatching { channelInfo ->
val channelTab = when {
channelInfo.tabs.isNotEmpty() -> {
ExtractorHelper.getChannelTab(
subscription.serviceId,
channelInfo.tabs[0],
true
).await()
}
val channelInfoList =
try {
withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) {
subscriptions
.map {
async {
val channelInfo =
ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await()
val channelTab =
ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await()
else -> {
Log.i(TAG, "No channel tabs available for: ${channelInfo.url}")
null
}
val currentIndex = mutex.withLock { index++ }
setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty))
channelInfo to channelTab
}
Pair(channelInfo, channelTab)
}.also {
mutex.withLock { index++ }
}.onFailure { exception ->
Log.e(TAG, "Failed to fetch subscription", exception)
}.onSuccess { (channelInfo, channelTab) ->
setForeground(createForegroundInfo(title, channelInfo.name, index))
emit(channelInfo to channelTab)
}
}
}.awaitAll()
}
.chunked(BUFFER_COUNT_BEFORE_INSERT)
.collect { chunk ->
subscriptionManager.upsertAll(chunk)
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Error while loading subscription data", e)
}
} catch (exception: Exception) {
Log.e(TAG, "Error while processing subscription data", exception)
withContext(Dispatchers.Main) {
Toast.makeText(context, R.string.subscriptions_import_unsuccessful, LENGTH_SHORT)
.show()
withContext(Dispatchers.Main) {
Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
.show()
}
return Result.failure()
}
return Result.failure()
title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty)
setForeground(createForegroundInfo(title, null, 0, 0))
index = 0
val subscriptionManager = SubscriptionManager(applicationContext)
for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) {
withContext(Dispatchers.IO) {
subscriptionManager.upsertAll(chunk)
}
index += chunk.size
setForeground(createForegroundInfo(title, null, index, qty))
}
setForeground(createForegroundInfo(title = title, progress = size))
withContext(Dispatchers.Main) {
Toast.makeText(applicationContext, R.string.import_complete_toast, LENGTH_SHORT)
Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT)
.show()
}
return Result.success()
}
private suspend fun loadSubscriptionsFromInput(
input: SubscriptionImportInput
): List<SubscriptionItem> {
private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> {
return withContext(Dispatchers.IO) {
when (input) {
is SubscriptionImportInput.ChannelUrlMode ->
@@ -142,18 +119,16 @@ class SubscriptionImportWorker(
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
is SubscriptionImportInput.InputStreamMode ->
StoredFileHelper(context, input.url.toUri(), DEFAULT_MIME).let { fileHelper ->
SharpInputStream(fileHelper.getStream()).use { stream ->
val contentType = getInputStreamContentType(fileHelper)
NewPipe.getService(input.serviceId).subscriptionExtractor
.fromInputStream(stream, contentType)
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
}
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
val contentType =
MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME }
NewPipe.getService(input.serviceId).subscriptionExtractor
.fromInputStream(it, contentType)
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
}
is SubscriptionImportInput.PreviousExportMode ->
context.contentResolver.openInputStream(input.url.toUri())?.use {
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
ImportExportJsonHelper.readFrom(it)
}
} ?: emptyList()
@@ -162,36 +137,35 @@ class SubscriptionImportWorker(
private fun createForegroundInfo(
title: String,
text: String? = null,
progress: Int = 0
text: String?,
currentProgress: Int,
maxProgress: Int
): ForegroundInfo {
val maxProgress = subscriptions.size
val notification = NotificationCompat
.Builder(context, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setOngoing(true)
.setProgress(maxProgress, progress, progress == 0)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentTitle(title)
.setContentText(text)
.addAction(
R.drawable.ic_close,
context.getString(R.string.cancel),
WorkManager.getInstance(context).createCancelPendingIntent(id)
).apply {
if (progress > 0 && maxProgress > 0) {
val progressText = "$progress/$maxProgress"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setSubText(progressText)
} else {
setContentInfo(progressText)
val notification =
NotificationCompat
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setOngoing(true)
.setProgress(maxProgress, currentProgress, currentProgress == 0)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentTitle(title)
.setContentText(text)
.addAction(
R.drawable.ic_close,
applicationContext.getString(R.string.cancel),
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id)
).apply {
if (currentProgress > 0 && maxProgress > 0) {
val progressText = "$currentProgress/$maxProgress"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setSubText(progressText)
} else {
setContentInfo(progressText)
}
}
}
}.build()
val serviceType = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
}
}.build()
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
}
@@ -207,20 +181,6 @@ class SubscriptionImportWorker(
private const val BUFFER_COUNT_BEFORE_INSERT = 50
const val WORK_NAME = "SubscriptionImportWorker"
internal fun getInputStreamContentType(fileHelper: StoredFileHelper): String {
val contentType = fileHelper.getType()
if (!contentType.isNullOrEmpty() && contentType != DEFAULT_MIME) {
return contentType
}
val fileName = fileHelper.getName() ?: return DEFAULT_MIME
val pointIndex = fileName.lastIndexOf('.')
return when {
pointIndex == -1 || pointIndex >= fileName.length - 1 -> DEFAULT_MIME
else -> fileName.substring(pointIndex + 1)
}
}
}
}
@@ -250,7 +210,8 @@ sealed class SubscriptionImportInput : Parcelable {
private const val PREVIOUS_EXPORT_MODE = 2
fun fromData(data: Data): SubscriptionImportInput {
when (val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)) {
val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)
when (mode) {
CHANNEL_URL_MODE -> {
val serviceId = data.getInt("service_id", -1)
if (serviceId == -1) {
@@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.navigation
import androidx.activity.compose.LocalActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.IntentCompat
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import org.schabi.newpipe.ComposeActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorReportHelper
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.screens.AboutScreen
import org.schabi.newpipe.ui.screens.ErrorReportEvent
import org.schabi.newpipe.ui.screens.ErrorReportScreen
import org.schabi.newpipe.ui.screens.settings.debug.DebugScreen
import org.schabi.newpipe.ui.screens.settings.home.SettingsHomeScreen
/**
* Top-level navigation display for all Compose screens in the app.
* @param startDestination the initial screen to display, resolved from the launching Intent.
*/
@Composable
fun NavDisplay(startDestination: NavKey) {
val backstack = rememberNavBackStack(startDestination)
val context = LocalContext.current
// TODO: Drop this logic once everything is in Compose
val activity = LocalActivity.current
fun onNavigateUp() {
if (backstack.size > 1) {
backstack.removeLastOrNull()
} else {
activity?.finish()
}
}
NavDisplay(
backStack = backstack,
onBack = ::onNavigateUp,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
// About
entry<Screen.About> {
ScaffoldWithToolbar(
title = stringResource(R.string.title_activity_about),
onBackClick = ::onNavigateUp
) { padding ->
AboutScreen(padding)
}
}
// Error Report
entry<Screen.Error> {
val errorInfo = remember {
IntentCompat.getParcelableExtra(
activity!!.intent,
ComposeActivity.EXTRA_ERROR_INFO,
ErrorInfo::class.java
)!!
}
ErrorReportScreen(
errorInfo = errorInfo,
onEvent = { event ->
when (event) {
is ErrorReportEvent.ReportViaEmail ->
ErrorReportHelper.sendErrorEmail(context, errorInfo, event.comment)
is ErrorReportEvent.CopyForGitHub ->
ErrorReportHelper.copyForGitHub(context, errorInfo, event.comment)
is ErrorReportEvent.ReportOnGitHub ->
ErrorReportHelper.openGitHubIssues(context)
is ErrorReportEvent.ReadPrivacyPolicy ->
ErrorReportHelper.openPrivacyPolicy(context)
is ErrorReportEvent.ShareError ->
ErrorReportHelper.shareError(context, errorInfo, event.comment)
is ErrorReportEvent.NavigateUp ->
onNavigateUp()
}
}
)
}
// Settings
entry<Screen.Settings.Home> {
SettingsHomeScreen(
onNavigate = { screen -> backstack.add(screen) },
onBackClick = ::onNavigateUp
)
}
entry<Screen.Settings.Player> {
Text(stringResource(id = R.string.settings_category_player_title))
}
entry<Screen.Settings.Behaviour> {
Text(stringResource(id = R.string.settings_category_player_behavior_title))
}
entry<Screen.Settings.Download> {
Text(stringResource(id = R.string.settings_category_downloads_title))
}
entry<Screen.Settings.LookFeel> {
Text(stringResource(id = R.string.settings_category_look_and_feel_title))
}
entry<Screen.Settings.HistoryCache> {
Text(stringResource(id = R.string.settings_category_history_title))
}
entry<Screen.Settings.Content> {
Text(stringResource(id = R.string.settings_category_content_title))
}
entry<Screen.Settings.Feed> {
Text(stringResource(id = R.string.settings_category_feed_title))
}
entry<Screen.Settings.Services> {
Text(stringResource(id = R.string.settings_category_services_title))
}
entry<Screen.Settings.Language> {
Text(stringResource(id = R.string.settings_category_language_title))
}
entry<Screen.Settings.BackupRestore> {
Text(stringResource(id = R.string.settings_category_backup_restore_title))
}
entry<Screen.Settings.Updates> {
Text(stringResource(id = R.string.settings_category_updates_title))
}
entry<Screen.Settings.Debug> {
DebugScreen(onBackClick = { backstack.removeLastOrNull() })
}
}
)
}
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2017-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen : NavKey {
@Serializable
data object About : Screen
@Serializable
data object Error : Screen
sealed interface Settings : Screen {
@Serializable
data object Home : Settings
@Serializable
data object Player : Settings
@Serializable
data object Behaviour : Settings
@Serializable
data object Download : Settings
@Serializable
data object LookFeel : Settings
@Serializable
data object HistoryCache : Settings
@Serializable
data object Content : Settings
@Serializable
data object Feed : Settings
@Serializable
data object Services : Settings
@Serializable
data object Language : Settings
@Serializable
data object BackupRestore : Settings
@Serializable
data object Updates : Settings
@Serializable
data object Debug : Settings
}
}
@@ -0,0 +1,35 @@
package org.schabi.newpipe.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
class CommentRepliesSource(
private val commentInfo: CommentsInfoItem
) : PagingSource<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
return try {
// params.key is null the first time load() is called, and we need to return the first page
val repliesPage = params.key ?: commentInfo.replies
val info = withContext(Dispatchers.IO) {
CommentsInfo.getMoreItems(service, commentInfo.url, repliesPage)
}
LoadResult.Page(info.items, null, info.nextPage)
} catch (e: Exception) {
// Catches UnknownHostException (offline) or any extraction errors
// and tells Paging3 to show the error state in the UI.
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
}
@@ -0,0 +1,35 @@
package org.schabi.newpipe.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.ui.components.video.comment.CommentInfo
class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
// params.key is null the first time the load() function is called, so we need to return the
// first batch of already-loaded comments
if (params.key == null) {
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
} else {
return try {
val info = withContext(Dispatchers.IO) {
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
}
LoadResult.Page(info.items, null, info.nextPage)
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}
}
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
}
@@ -95,8 +95,48 @@ public final class PlayQueueActivity extends AppCompatActivity
getSupportActionBar().setTitle(R.string.title_activity_play_queue);
}
serviceConnection = getServiceConnection();
bind();
serviceConnection = new ServiceConnection() {
@Override
public void onServiceDisconnected(final ComponentName name) {
Log.d(TAG, "Player service is disconnected");
}
@Override
public void onServiceConnected(final ComponentName name, final IBinder binder) {
Log.d(TAG, "Player service is connected");
if (binder instanceof PlayerService.LocalBinder) {
@Nullable final PlayerService s =
((PlayerService.LocalBinder) binder).getService();
if (s == null) {
throw new IllegalArgumentException(
"PlayerService.LocalBinder.getService() must never be"
+ "null after the service connects");
}
player = s.getPlayer();
}
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
unbind();
} else {
onQueueUpdate(player.getPlayQueue());
buildComponents();
if (player != null) {
player.setActivityListener(PlayQueueActivity.this);
}
}
}
};
// 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);
}
serviceBound = success;
}
@Override
@@ -178,19 +218,6 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
// Service Connection
////////////////////////////////////////////////////////////////////////////
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);
}
serviceBound = success;
}
private void unbind() {
if (serviceBound) {
@@ -210,34 +237,6 @@ public final class PlayQueueActivity extends AppCompatActivity
}
}
private ServiceConnection getServiceConnection() {
return new ServiceConnection() {
@Override
public void onServiceDisconnected(final ComponentName name) {
Log.d(TAG, "Player service is disconnected");
}
@Override
public void onServiceConnected(final ComponentName name, final IBinder service) {
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerService.LocalBinder) {
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
}
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
unbind();
} else {
onQueueUpdate(player.getPlayQueue());
buildComponents();
if (player != null) {
player.setActivityListener(PlayQueueActivity.this);
}
}
}
};
}
////////////////////////////////////////////////////////////////////////////
// Component Building
////////////////////////////////////////////////////////////////////////////
@@ -45,7 +45,6 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static coil3.Image_androidKt.toBitmap;
import android.content.BroadcastReceiver;
@@ -141,6 +140,10 @@ import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.disposables.SerialDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* The ExoPlayer wrapper & Player business logic.
* Only instantiated once, from {@link PlayerService}.
*/
public final class Player implements PlaybackListener, Listener {
public static final boolean DEBUG = MainActivity.DEBUG;
public static final String TAG = Player.class.getSimpleName();
@@ -408,12 +411,11 @@ public final class Player implements PlaybackListener, Listener {
.subscribe(info -> {
final @Nullable PlayQueue oldPlayQueue = playQueue;
info.setStartPosition(data.getSeconds());
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
final PlayQueueItem item = new PlayQueueItem(info);
// If the stream is already playing,
// we can just seek to the appropriate timestamp
if (oldPlayQueue != null
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
if (oldPlayQueue != null && item.equals(oldPlayQueue.getItem())) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
if (simpleExoPlayer.getPlaybackState()
@@ -429,12 +431,12 @@ public final class Player implements PlaybackListener, Listener {
// If there is no queue yet, just add our item
if (oldPlayQueue == null) {
newPlayQueue = new SinglePlayQueue(playQueueItem);
newPlayQueue = new SinglePlayQueue(item);
// else we add the timestamped stream behind the current video
// and start playing it.
} else {
oldPlayQueue.enqueueNext(playQueueItem, true);
oldPlayQueue.enqueueNext(item, true);
oldPlayQueue.offsetIndex(1);
newPlayQueue = oldPlayQueue;
}
@@ -477,7 +479,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().isSameItem(playQueue.getItem())
&& newQueue.getItem().equals(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
@@ -564,36 +566,42 @@ public final class Player implements PlaybackListener, Listener {
}
private void initUIsForCurrentPlayerType() {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
final var playerClass = switch (playerType) {
case MAIN -> MainPlayerUi.class;
case POPUP -> PopupPlayerUi.class;
case BACKGROUND -> BackgroundPlayerUi.class;
default -> null; // Should not be possible
};
if (playerClass == null || UIs.get(playerClass) != null) {
// correct UI already in place
return;
}
// try to reuse binding if possible
final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
.orElseGet(() -> {
if (playerType == PlayerType.AUDIO) {
return null;
} else {
return PlayerBinding.inflate(LayoutInflater.from(context));
}
});
@Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class);
final PlayerBinding binding;
if (ui != null) {
binding = ui.getBinding();
} else if (playerType == PlayerType.BACKGROUND) {
binding = null;
} else {
binding = PlayerBinding.inflate(LayoutInflater.from(context));
}
switch (playerType) {
case MAIN:
UIs.destroyAll(PopupPlayerUi.class);
UIs.destroyAll(BackgroundPlayerUi.class);
UIs.destroyAllOfType(PopupPlayerUi.class);
UIs.destroyAllOfType(BackgroundPlayerUi.class);
UIs.addAndPrepare(new MainPlayerUi(this, binding));
break;
case POPUP:
UIs.destroyAll(MainPlayerUi.class);
UIs.destroyAll(BackgroundPlayerUi.class);
UIs.destroyAllOfType(MainPlayerUi.class);
UIs.destroyAllOfType(BackgroundPlayerUi.class);
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
break;
case AUDIO:
UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi
case BACKGROUND:
// destroys both MainPlayerUi and PopupPlayerUi
UIs.destroyAllOfType(VideoPlayerUi.class);
UIs.addAndPrepare(new BackgroundPlayerUi(this));
break;
}
@@ -682,9 +690,15 @@ public final class Player implements PlaybackListener, Listener {
}
}
public void destroy() {
/**
* Shut down this player.
* Saves the stream progress, sets recovery.
* Then destroys the player in all UIs and destroys the UIs as well.
*/
public void saveAndShutdown() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
Log.d(TAG, "saveAndShutdown() called");
}
saveStreamProgressState();
@@ -698,7 +712,7 @@ public final class Player implements PlaybackListener, Listener {
progressUpdateDisposable.set(null);
streamItemDisposable.clear();
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
UIs.destroyAllOfType(null);
}
public void setRecovery() {
@@ -923,7 +937,6 @@ public final class Player implements PlaybackListener, Listener {
.loadScaledDownThumbnail(context, thumbnails, thumbnailTarget);
}
private void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
// Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the
// thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since
@@ -2111,6 +2124,10 @@ public final class Player implements PlaybackListener, Listener {
triggerProgressUpdate();
}
/**
* Remove the listener, if it was set.
* @param listener listener to remove
* */
public void removeFragmentListener(final PlayerServiceEventListener listener) {
if (fragmentListener == listener) {
fragmentListener = null;
@@ -2125,6 +2142,10 @@ public final class Player implements PlaybackListener, Listener {
triggerProgressUpdate();
}
/**
* Remove the listener, if it was set.
* @param listener listener to remove
* */
void removeActivityListener(final PlayerEventListener listener) {
if (activityListener == listener) {
activityListener = null;
@@ -2389,7 +2410,7 @@ public final class Player implements PlaybackListener, Listener {
}
public boolean audioPlayerSelected() {
return playerType == PlayerType.AUDIO;
return playerType == PlayerType.BACKGROUND;
}
public boolean videoPlayerSelected() {
@@ -1,348 +0,0 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program 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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
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 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;
//region Service lifecycle
@Override
public void onCreate() {
super.onCreate();
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
ThemeHelper.setTheme(this);
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 + "]");
}
// 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 (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;
}
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;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (player != null && !player.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopForImmediateReusing();
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (player != null && !player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
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);
}
/**
* 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();
// 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) {
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 {
private final WeakReference<PlayerService> playerService;
LocalBinder(final PlayerService playerService) {
this.playerService = new WeakReference<>(playerService);
}
public PlayerService getService() {
return playerService.get();
}
}
/**
* @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
}
@@ -0,0 +1,323 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program 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.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player
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.core.app.ServiceCompat
import androidx.media.MediaBrowserServiceCompat
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import java.lang.ref.WeakReference
import java.util.function.Consumer
import org.schabi.newpipe.ktx.toDebugString
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
/**
* One service for all players.
*/
class PlayerService : MediaBrowserServiceCompat() {
// 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 lateinit var mediaBrowserImpl: MediaBrowserImpl
private lateinit var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer
// these are instantiated in onCreate() as per
// https://developer.android.com/training/cars/media#browser_workflow
private lateinit var mediaSession: MediaSessionCompat
private lateinit var sessionConnector: MediaSessionConnector
/**
* @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.
*/
var player: Player? = null
private set
private val mBinder: IBinder = LocalBinder(this)
/**
* The parameter taken by this [Consumer] can be null to indicate the player is being
* stopped.
*/
private var onPlayerStartedOrStopped: ((player: Player?) -> Unit)? = null
//region Service lifecycle
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Log.d(TAG, "onCreate() called")
}
ThemeHelper.setTheme(this)
mediaBrowserImpl = MediaBrowserImpl(this, this::notifyChildrenChanged)
// see https://developer.android.com/training/cars/media#browser_workflow
val session = MediaSessionCompat(this, "MediaSessionPlayerServ")
mediaSession = session
setSessionToken(session.sessionToken)
val connector = MediaSessionConnector(session)
sessionConnector = connector
connector.setMetadataDeduplicationEnabled(true)
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
context = this,
setMediaSessionError = connector::setCustomErrorMessage,
clearMediaSessionError = { connector.setCustomErrorMessage(null) },
onPrepare = { player?.onPrepare() }
)
connector.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 fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (DEBUG) {
Log.d(
TAG,
"onStartCommand() called with: intent = [$intent], extras = [${
intent.extras.toDebugString()}], flags = [$flags], startId = [$startId]"
)
}
// 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)) {
val playerWasNull = (player == null)
if (playerWasNull) {
// make sure the player exists, in case the service was resumed
player = 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)?.createNotificationAndStartForeground()
if (playerWasNull) {
// 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?.invoke(player)
}
}
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)
return START_NOT_STICKY
}
val oldPlayerType = player?.playerType
player?.handleIntent(intent)
player?.handleIntentPost(oldPlayerType)
player?.UIs()?.get(MediaSessionPlayerUi::class.java)
?.handleMediaButtonIntent(intent)
return START_NOT_STICKY
}
fun stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called")
}
val p = player
if (p != null && !p.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
p.smoothStopForImmediateReusing()
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
val p = player
if (p != null && !p.videoPlayerSelected()) {
return
}
onDestroy()
// Unload from memory completely
Runtime.getRuntime().halt(0)
}
override fun onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called")
}
super.onDestroy()
cleanup()
mediaBrowserPlaybackPreparer.dispose()
mediaSession.release()
mediaBrowserImpl.dispose()
}
private fun cleanup() {
val p = player
if (p != null) {
// notify that the player is being destroyed
onPlayerStartedOrStopped?.invoke(null)
p.saveAndShutdown()
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)
}
/**
* 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 [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).
*/
fun destroyPlayerAndStopService() {
if (DEBUG) {
Log.d(TAG, "destroyPlayerAndStopService() called")
}
cleanup()
// 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(Intent(this, PlayerService::class.java))
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
}
//endregion
//region Bind
override fun onBind(intent: Intent): IBinder? {
if (DEBUG) {
Log.d(
TAG,
"onBind() called with: intent = [$intent], extras = [${
intent.extras.toDebugString()}]"
)
}
return if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
// 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 .
mBinder
} else if (SERVICE_INTERFACE == intent.action) {
// MediaBrowserService also uses its own binder, so for actions related to the media
// browser service, pass the onBind to the superclass.
super.onBind(intent)
} else {
// This is an unknown request, avoid returning any binder to not leak objects.
null
}
}
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
private val playerService = WeakReference(playerService)
val service: PlayerService?
get() = playerService.get()
}
/**
* Sets the listener that will be called when the player is started or stopped. If a
* `null` listener is passed, then the current listener will be unset. The parameter taken
* by the [Consumer] can be null to indicate that the player is stopping.
* @param listener the listener to set or unset
*/
fun setPlayerListener(listener: ((player: Player?) -> Unit)?) {
this.onPlayerStartedOrStopped = listener
listener?.invoke(player)
}
//endregion
//region Media browser
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
// TODO check if the accessing package has permission to view data
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints)
}
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
mediaBrowserImpl.onLoadChildren(parentId, result)
}
override fun onSearch(
query: String,
extras: Bundle?,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
mediaBrowserImpl.onSearch(query, result)
} //endregion
companion object {
private val TAG: String = PlayerService::class.java.getSimpleName()
private val DEBUG = Player.DEBUG
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
}
}
@@ -6,7 +6,8 @@
package org.schabi.newpipe.player
enum class PlayerType {
BACKGROUND,
MAIN,
AUDIO,
POPUP
POPUP,
NONE
}
@@ -5,6 +5,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.playqueue.PlayQueue;
/** Player-specific events like queue or progress updates. */
public interface PlayerEventListener {
void onQueueUpdate(PlayQueue queue);
void onPlaybackUpdate(int state, int repeatMode, boolean shuffled,
@@ -2,12 +2,15 @@ package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackException;
/** {@link org.schabi.newpipe.player.event.PlayerEventListener} that also gets called for
* application-specific events like screen rotation or UI changes.
*/
public interface PlayerServiceEventListener extends PlayerEventListener {
void onViewCreated();
void onFullscreenStateChanged(boolean fullscreen);
void onScreenRotationButtonClicked();
void onFullscreenToggleButtonClicked();
void onMoreOptionsLongClicked();
@@ -1,372 +0,0 @@
package org.schabi.newpipe.player.helper;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.App;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
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 {
private PlayerHolder() {
}
private static PlayerHolder instance;
public static synchronized PlayerHolder getInstance() {
if (PlayerHolder.instance == null) {
PlayerHolder.instance = new PlayerHolder();
}
return PlayerHolder.instance;
}
private static final boolean DEBUG = MainActivity.DEBUG;
private static final String TAG = PlayerHolder.class.getSimpleName();
@Nullable private PlayerServiceExtendedEventListener listener;
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private PlayerService playerService;
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,
* otherwise `null` if no service is running.
*
* @return Current PlayerType
*/
@Nullable
public PlayerType getType() {
return getPlayer().map(Player::getPlayerType).orElse(null);
}
public boolean isPlaying() {
return getPlayer().map(Player::isPlaying).orElse(false);
}
public boolean isPlayerOpen() {
return getPlayer().isPresent();
}
/**
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
* the stream long press menu) when there actually is a play queue to manipulate.
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
*/
public boolean isPlayQueueReady() {
return getPlayQueue().isPresent();
}
public boolean isBound() {
return bound;
}
public int getQueueSize() {
return getPlayQueue().map(PlayQueue::size).orElse(0);
}
public int getQueuePosition() {
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
}
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
listener = newListener;
if (listener == null) {
return;
}
// Force reload data from service
if (playerService != null) {
listener.onServiceConnected(playerService);
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
}
}
// helper to handle context in common place as using the same
// context to bind/unbind a service is crucial
private Context getCommonContext() {
return App.getInstance();
}
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) {
return;
}
// startService() can be called concurrently and it will give a random crashes
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
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));
}
class PlayerServiceConnection implements ServiceConnection {
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;
}
@Override
public void onServiceDisconnected(final ComponentName compName) {
if (DEBUG) {
Log.d(TAG, "Player service is disconnected");
}
final Context context = getCommonContext();
unbind(context);
}
@Override
public void onServiceConnected(final ComponentName compName, final IBinder service) {
if (DEBUG) {
Log.d(TAG, "Player service is connected");
}
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
if (listener != null) {
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());
}
}
}
private void bind(final Context context) {
if (DEBUG) {
Log.d(TAG, "bind() called");
}
// 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");
}
if (bound) {
context.unbindService(serviceConnection);
bound = false;
stopPlayerListener();
playerService = null;
if (listener != null) {
listener.onPlayerDisconnected();
listener.onServiceDisconnected();
}
}
}
private void startPlayerListener() {
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 (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
public void onViewCreated() {
if (listener != null) {
listener.onViewCreated();
}
}
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
if (listener != null) {
listener.onFullscreenStateChanged(fullscreen);
}
}
@Override
public void onScreenRotationButtonClicked() {
if (listener != null) {
listener.onScreenRotationButtonClicked();
}
}
@Override
public void onMoreOptionsLongClicked() {
if (listener != null) {
listener.onMoreOptionsLongClicked();
}
}
@Override
public void onPlayerError(final PlaybackException error,
final boolean isCatchableException) {
if (listener != null) {
listener.onPlayerError(error, isCatchableException);
}
}
@Override
public void hideSystemUiIfNeeded() {
if (listener != null) {
listener.hideSystemUiIfNeeded();
}
}
@Override
public void onQueueUpdate(final PlayQueue queue) {
if (listener != null) {
listener.onQueueUpdate(queue);
}
}
@Override
public void onPlaybackUpdate(final int state,
final int repeatMode,
final boolean shuffled,
final PlaybackParameters parameters) {
if (listener != null) {
listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters);
}
}
@Override
public void onProgressUpdate(final int currentProgress,
final int duration,
final int bufferPercent) {
if (listener != null) {
listener.onProgressUpdate(currentProgress, duration, bufferPercent);
}
}
@Override
public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
if (listener != null) {
listener.onMetadataUpdate(info, queue);
}
}
@Override
public void onServiceStopped() {
if (listener != null) {
listener.onServiceStopped();
}
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);
}
}
};
}
@@ -0,0 +1,318 @@
package org.schabi.newpipe.player.helper
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.android.exoplayer2.PlaybackException
import com.google.android.exoplayer2.PlaybackParameters
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.PlayerService
import org.schabi.newpipe.player.PlayerService.LocalBinder
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
private val DEBUG = MainActivity.DEBUG
private val TAG: String = PlayerHolder::class.java.getSimpleName()
/**
* Singleton that manages a `PlayerService`
* and can be used to control the player instance through the service.
*/
object PlayerHolder {
private var listener: PlayerServiceExtendedEventListener? = null
var isBound: Boolean = false
private set
private var playerService: PlayerService? = null
private val player: Player?
get() = playerService?.player
// player play queue might be null e.g. while player is starting
private val playQueue: PlayQueue?
get() = this.player?.playQueue
/**
* Returns the current [PlayerType] of the [PlayerService] service,
* otherwise `null` if no service is running.
*
* @return Current PlayerType
*/
val type: PlayerType?
get() = this.player?.playerType
val isPlaying: Boolean
get() = this.player?.isPlaying == true
val isPlayerOpen: Boolean
get() = this.player != null
/**
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
* the stream long press menu) when there actually is a play queue to manipulate.
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
*/
val isPlayQueueReady: Boolean
get() = this.playQueue != null
val queueSize: Int
get() = this.playQueue?.size() ?: 0
val queuePosition: Int
get() = this.playQueue?.index ?: 0
fun setListener(newListener: PlayerServiceExtendedEventListener?) {
listener = newListener
// Force reload data from service
newListener?.let { listener ->
playerService?.let { service ->
listener.onServiceConnected(service)
startPlayerListener()
// ^ will call listener.onPlayerConnected() down the line if there is an active player
}
}
}
private val commonContext: Context
// helper to handle context in common place as using the same
get() = App.instance
/**
* Connect to (and if needed start) the [PlayerService]
* and bind [PlayerServiceConnection] to it.
* If the service is already started, only set the listener.
* @param playAfterConnect If this holders service was already started,
* start playing immediately
* @param newListener set this listener
*/
fun startService(
playAfterConnect: Boolean,
newListener: PlayerServiceExtendedEventListener?
) {
if (DEBUG) {
Log.d(TAG, "startService() called with playAfterConnect=$playAfterConnect")
}
val context = this.commonContext
setListener(newListener)
if (this.isBound) {
return
}
// startService() can be called concurrently and it will give a random crashes
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context)
val intent = Intent(context, PlayerService::class.java)
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
ContextCompat.startForegroundService(context, intent)
PlayerServiceConnection.doPlayAfterConnect(playAfterConnect)
bind(context)
}
fun stopService() {
if (DEBUG) {
Log.d(TAG, "stopService() called")
}
playerService?.destroyPlayerAndStopService()
val context = this.commonContext
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(Intent(context, PlayerService::class.java))
}
internal object PlayerServiceConnection : ServiceConnection {
internal var playAfterConnect = false
/**
* @param playAfterConnection Sets the value of [playAfterConnect] to pass to the
* [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it
* is called. The value of [playAfterConnect] will be reset to false after that.
*/
fun doPlayAfterConnect(playAfterConnection: Boolean) {
this.playAfterConnect = playAfterConnection
}
override fun onServiceDisconnected(compName: ComponentName?) {
if (DEBUG) {
Log.d(TAG, "Player service is disconnected")
}
val context: Context = this@PlayerHolder.commonContext
unbind(context)
}
override fun onServiceConnected(compName: ComponentName?, service: IBinder?) {
if (DEBUG) {
Log.d(TAG, "Player service is connected")
}
val localBinder = service as LocalBinder
val s = localBinder.service
requireNotNull(s) {
"PlayerService.LocalBinder.getService() must never be" +
"null after the service connects"
}
playerService = s
listener?.let { l ->
l.onServiceConnected(s)
player?.let { l.onPlayerConnected(it, playAfterConnect) }
}
startPlayerListener()
// ^ will call listener.onPlayerConnected() down the line if there is an active player
if (playerService != null && playerService?.player != 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.service)
}
}
}
private fun bind(context: Context) {
if (DEBUG) {
Log.d(TAG, "bind() called")
}
// BIND_AUTO_CREATE starts the service if it's not already running
this.isBound = bind(context, Context.BIND_AUTO_CREATE)
if (!this.isBound) {
context.unbindService(PlayerServiceConnection)
}
}
fun tryBindIfNeeded(context: Context) {
if (!this.isBound) {
// 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 fun bind(context: Context, flags: Int): Boolean {
val serviceIntent = Intent(context, PlayerService::class.java)
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION)
return context.bindService(serviceIntent, PlayerServiceConnection, flags)
}
private fun unbind(context: Context) {
if (DEBUG) {
Log.d(TAG, "unbind() called")
}
if (this.isBound) {
context.unbindService(PlayerServiceConnection)
this.isBound = false
stopPlayerListener()
playerService = null
listener?.onPlayerDisconnected()
listener?.onServiceDisconnected()
}
}
private fun startPlayerListener() {
// 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)
this.player?.setFragmentListener(HolderPlayerServiceEventListener)
}
private fun stopPlayerListener() {
playerService?.setPlayerListener(null)
this.player?.removeFragmentListener(HolderPlayerServiceEventListener)
}
/**
* This listener will be held by the players created by [PlayerService].
*/
private object HolderPlayerServiceEventListener : PlayerServiceEventListener {
override fun onViewCreated() {
listener?.onViewCreated()
}
override fun onFullscreenStateChanged(fullscreen: Boolean) {
listener?.onFullscreenStateChanged(fullscreen)
}
override fun onFullscreenToggleButtonClicked() {
listener?.onFullscreenToggleButtonClicked()
}
override fun onMoreOptionsLongClicked() {
listener?.onMoreOptionsLongClicked()
}
override fun onPlayerError(
error: PlaybackException?,
isCatchableException: Boolean
) {
listener?.onPlayerError(error, isCatchableException)
}
override fun hideSystemUiIfNeeded() {
listener?.hideSystemUiIfNeeded()
}
override fun onQueueUpdate(queue: PlayQueue?) {
listener?.onQueueUpdate(queue)
}
override fun onPlaybackUpdate(
state: Int,
repeatMode: Int,
shuffled: Boolean,
parameters: PlaybackParameters?
) {
listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
}
override fun onProgressUpdate(
currentProgress: Int,
duration: Int,
bufferPercent: Int
) {
listener?.onProgressUpdate(currentProgress, duration, bufferPercent)
}
override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
listener?.onMetadataUpdate(info, queue)
}
override fun onServiceStopped() {
listener?.onServiceStopped()
unbind(this@PlayerHolder.commonContext)
}
}
/**
* This listener will be held by bound [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 val playerStateListener: (Player?) -> Unit = { player: Player? ->
listener?.let { l ->
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
l.onPlayerDisconnected()
} else {
l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect)
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
PlayerServiceConnection.playAfterConnect = false
player.setFragmentListener(HolderPlayerServiceEventListener)
}
}
}
}
@@ -9,6 +9,7 @@ import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media.MediaBrowserServiceCompat
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media.MediaBrowserServiceCompat.Result
@@ -17,7 +18,6 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import java.util.function.Consumer
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
@@ -47,8 +47,7 @@ import org.schabi.newpipe.util.image.ImageStrategy
*/
class MediaBrowserImpl(
private val context: Context,
// parentId
notifyChildrenChanged: Consumer<String>
notifyChildrenChanged: (parentId: String) -> Unit
) {
private val packageValidator = PackageValidator(context)
private val database = NewPipeDatabase.getInstance(context)
@@ -56,9 +55,7 @@ class MediaBrowserImpl(
init {
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
disposables.add(
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
)
disposables.add(getMergedPlaylists().subscribe { notifyChildrenChanged(ID_BOOKMARKS) })
}
//region Cleanup
@@ -198,17 +195,16 @@ class MediaBrowserImpl(
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))
.setExtras(
bundleOf(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE
to context.resources.getString(R.string.tab_bookmarks)
)
)
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
@@ -217,8 +213,9 @@ class MediaBrowserImpl(
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForInfoItem(item))
.setMediaId(createMediaIdForInfoItem(item))
.setTitle(item.name)
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
when (item.infoType) {
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
@@ -227,10 +224,6 @@ class MediaBrowserImpl(
else -> return null
}
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
builder.setIconUri(imageUriOrNullIfDisabled(it))
}
return MediaBrowserCompat.MediaItem(
builder.build(),
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
@@ -271,7 +264,7 @@ class MediaBrowserImpl(
index: Int
): MediaBrowserCompat.MediaItem {
val builder = MediaDescriptionCompat.Builder()
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
.setTitle(item.streamEntity.title)
.setSubtitle(item.streamEntity.uploader)
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
@@ -291,10 +284,7 @@ class MediaBrowserImpl(
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
.setTitle(item.name)
.setSubtitle(item.uploaderName)
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
builder.setIconUri(imageUriOrNullIfDisabled(it))
}
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
return MediaBrowserCompat.MediaItem(
builder.build(),
@@ -317,10 +307,10 @@ class MediaBrowserImpl(
}
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
val history = database.streamHistoryDAO().history.firstOrError()
return history.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
return database.streamHistoryDAO().getHistory().firstOrError()
.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
}
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
@@ -6,7 +6,6 @@ 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
@@ -31,6 +30,7 @@ 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.Localization
import org.schabi.newpipe.util.NavigationHelper
/**
@@ -111,7 +111,7 @@ class MediaBrowserPlaybackPreparer(
//region Errors
private fun onUnsupportedError() {
setMediaSessionError.accept(
ContextCompat.getString(context, R.string.content_not_supported),
Localization.compatGetString(context, R.string.content_not_supported),
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
)
}
@@ -216,7 +216,7 @@ class MediaBrowserPlaybackPreparer(
}
val streamId = path[0].toLong()
return database.streamHistoryDAO().history
return database.streamHistoryDAO().getHistory()
.firstOrError()
.map { items ->
val infoItems = items
@@ -124,8 +124,10 @@ public class MediaSessionPlayerUi extends PlayerUi
MediaButtonReceiver.handleIntent(mediaSession, intent);
}
public Optional<MediaSessionCompat.Token> getSessionToken() {
return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
@NonNull
public MediaSessionCompat.Token getSessionToken() {
return mediaSession.getSessionToken();
}
@@ -138,7 +140,10 @@ public class MediaSessionPlayerUi extends PlayerUi
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class);
if (ui != null) {
ui.hideControls(0, 0);
}
}
@Override
@@ -106,10 +106,10 @@ public final class NotificationUtil {
final int[] compactSlots = initializeNotificationSlots();
mediaStyle.setShowActionsInCompactView(compactSlots);
}
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
@Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class);
if (ui != null) {
mediaStyle.setMediaSession(ui.getSessionToken());
}
// setup notification builder
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
@@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription;
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject;
import static org.schabi.newpipe.BuildConfig.DEBUG;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
public class MediaSourceManager {
@@ -1,575 +0,0 @@
package org.schabi.newpipe.player.playqueue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.subjects.PublishSubject;
/**
* PlayQueue is responsible for keeping track of a list of streams and the index of
* the stream that should be currently playing.
* <p>
* This class contains basic manipulation of a playlist while also functions as a
* message bus, providing all listeners with new updates to the play queue.
* </p>
* <p>
* This class can be serialized for passing intents, but in order to start the
* message bus, it must be initialized.
* </p>
*/
public abstract class PlayQueue implements Serializable {
public static final boolean DEBUG = MainActivity.DEBUG;
@NonNull
private final AtomicInteger queueIndex;
private final List<PlayQueueItem> history = new ArrayList<>();
private List<PlayQueueItem> backup;
private List<PlayQueueItem> streams;
private transient PublishSubject<PlayQueueEvent> eventBroadcast;
private transient Flowable<PlayQueueEvent> broadcastReceiver;
private transient boolean disposed = false;
PlayQueue(final int index, final List<PlayQueueItem> startWith) {
streams = new ArrayList<>(startWith);
if (streams.size() > index) {
history.add(streams.get(index));
}
queueIndex = new AtomicInteger(index);
}
/*//////////////////////////////////////////////////////////////////////////
// Playlist actions
//////////////////////////////////////////////////////////////////////////*/
/**
* Initializes the play queue message buses.
* <p>
* Also starts a self reporter for logging if debug mode is enabled.
* </p>
*/
public void init() {
eventBroadcast = PublishSubject.create();
broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER)
.observeOn(AndroidSchedulers.mainThread())
.startWithItem(new InitEvent());
}
/**
* Dispose the play queue by stopping all message buses.
*/
public void dispose() {
if (eventBroadcast != null) {
eventBroadcast.onComplete();
}
eventBroadcast = null;
broadcastReceiver = null;
disposed = true;
}
/**
* Checks if the queue is complete.
* <p>
* A queue is complete if it has loaded all items in an external playlist
* single stream or local queues are always complete.
* </p>
*
* @return whether the queue is complete
*/
public abstract boolean isComplete();
/**
* Load partial queue in the background, does nothing if the queue is complete.
*/
public abstract void fetch();
/*//////////////////////////////////////////////////////////////////////////
// Readonly ops
//////////////////////////////////////////////////////////////////////////*/
/**
* @return the current index that should be played
*/
public int getIndex() {
return queueIndex.get();
}
/**
* Changes the current playing index to a new index.
* <p>
* This method is guarded using in a circular manner for index exceeding the play queue size.
* </p>
* <p>
* Will emit a {@link SelectEvent} if the index is not the current playing index.
* </p>
*
* @param index the index to be set
*/
public synchronized void setIndex(final int index) {
final int oldIndex = getIndex();
final int newIndex;
if (index < 0) {
newIndex = 0;
} else if (index < streams.size()) {
// Regular assignment for index in bounds
newIndex = index;
} else if (streams.isEmpty()) {
// Out of bounds from here on
// Need to check if stream is empty to prevent arithmetic error and negative index
newIndex = 0;
} else if (isComplete()) {
// Circular indexing
newIndex = index % streams.size();
} else {
// Index of last element
newIndex = streams.size() - 1;
}
queueIndex.set(newIndex);
if (oldIndex != newIndex) {
history.add(streams.get(newIndex));
}
/*
TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
different from the old one but this is emitted regardless? Not sure what this what it does
exactly so I won't touch it
*/
broadcast(new SelectEvent(oldIndex, newIndex));
}
/**
* @return the current item that should be played, or null if the queue is empty
*/
@Nullable
public PlayQueueItem getItem() {
return getItem(getIndex());
}
/**
* @param index the index of the item to return
* @return the item at the given index, or null if the index is out of bounds
*/
@Nullable
public PlayQueueItem getItem(final int index) {
if (index < 0 || index >= streams.size()) {
return null;
}
return streams.get(index);
}
/**
* Returns the index of the given item using referential equality.
* May be null despite play queue contains identical item.
*
* @param item the item to find the index of
* @return the index of the given item
*/
public int indexOf(@NonNull final PlayQueueItem item) {
return streams.indexOf(item);
}
/**
* @return the current size of play queue.
*/
public int size() {
return streams.size();
}
/**
* Checks if the play queue is empty.
*
* @return whether the play queue is empty
*/
public boolean isEmpty() {
return streams.isEmpty();
}
/**
* Determines if the current play queue is shuffled.
*
* @return whether the play queue is shuffled
*/
public boolean isShuffled() {
return backup != null;
}
/**
* @return an immutable view of the play queue
*/
@NonNull
public List<PlayQueueItem> getStreams() {
return Collections.unmodifiableList(streams);
}
/*//////////////////////////////////////////////////////////////////////////
// Write ops
//////////////////////////////////////////////////////////////////////////*/
/**
* Returns the play queue's update broadcast.
* May be null if the play queue message bus is not initialized.
*
* @return the play queue's update broadcast
*/
@Nullable
public Flowable<PlayQueueEvent> getBroadcastReceiver() {
return broadcastReceiver;
}
/**
* Changes the current playing index by an offset amount.
* <p>
* Will emit a {@link SelectEvent} if offset is non-zero.
* </p>
*
* @param offset the offset relative to the current index
*/
public synchronized void offsetIndex(final int offset) {
setIndex(getIndex() + offset);
}
/**
* Notifies that a change has occurred.
*/
public synchronized void notifyChange() {
broadcast(new AppendEvent(0));
}
/**
* Appends the given {@link PlayQueueItem}s to the current play queue.
* <p>
* If the play queue is shuffled, then append the items to the backup queue as is and
* append the shuffle items to the play queue.
* </p>
* <p>
* Will emit a {@link AppendEvent} on any given context.
* </p>
*
* @param items {@link PlayQueueItem}s to append
*/
public synchronized void append(@NonNull final List<PlayQueueItem> items) {
final List<PlayQueueItem> itemList = new ArrayList<>(items);
if (isShuffled()) {
backup.addAll(itemList);
Collections.shuffle(itemList);
}
if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued()
&& !itemList.get(0).isAutoQueued()) {
streams.remove(streams.size() - 1);
}
streams.addAll(itemList);
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>
* The current playing index will decrement if it is greater than the index being removed.
* On cases where the current playing index exceeds the playlist range, it is set to 0.
* </p>
* <p>
* Will emit a {@link RemoveEvent} if the index is within the play queue index range.
* </p>
*
* @param index the index of the item to remove
*/
public synchronized void remove(final int index) {
if (index >= streams.size() || index < 0) {
return;
}
removeInternal(index);
broadcast(new RemoveEvent(index, getIndex()));
}
/**
* Report an exception for the item at the current index in order and skip to the next one
* <p>
* This is done as a separate event as the underlying manager may have
* different implementation regarding exceptions.
* </p>
*/
public synchronized void error() {
final int oldIndex = getIndex();
queueIndex.incrementAndGet();
if (streams.size() > queueIndex.get()) {
history.add(streams.get(queueIndex.get()));
}
broadcast(new ErrorEvent(oldIndex, getIndex()));
}
private synchronized void removeInternal(final int removeIndex) {
final int currentIndex = queueIndex.get();
final int size = size();
if (currentIndex > removeIndex) {
queueIndex.decrementAndGet();
} else if (currentIndex >= size) {
queueIndex.set(currentIndex % (size - 1));
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
queueIndex.set(0);
}
if (backup != null) {
backup.remove(getItem(removeIndex));
}
history.remove(streams.remove(removeIndex));
if (streams.size() > queueIndex.get()) {
history.add(streams.get(queueIndex.get()));
}
}
/**
* Moves a queue item at the source index to the target index.
* <p>
* If the item being moved is the currently playing, then the current playing index is set
* to that of the target.
* If the moved item is not the currently playing and moves to an index <b>AFTER</b> the
* current playing index, then the current playing index is decremented.
* Vice versa if the an item after the currently playing is moved <b>BEFORE</b>.
* </p>
*
* @param source the original index of the item
* @param target the new index of the item
*/
public synchronized void move(final int source, final int target) {
if (source < 0 || target < 0) {
return;
}
if (source >= streams.size() || target >= streams.size()) {
return;
}
final int current = getIndex();
if (source == current) {
queueIndex.set(target);
} else if (source < current && target >= current) {
queueIndex.decrementAndGet();
} else if (source > current && target <= current) {
queueIndex.incrementAndGet();
}
final PlayQueueItem playQueueItem = streams.remove(source);
playQueueItem.setAutoQueued(false);
streams.add(target, playQueueItem);
broadcast(new MoveEvent(source, target));
}
/**
* Sets the recovery record of the item at the index.
* <p>
* Broadcasts a recovery event.
* </p>
*
* @param index index of the item
* @param position the recovery position
*/
public synchronized void setRecovery(final int index, final long position) {
if (index < 0 || index >= streams.size()) {
return;
}
streams.get(index).setRecoveryPosition(position);
broadcast(new RecoveryEvent(index, position));
}
/**
* Revoke the recovery record of the item at the index.
* <p>
* Broadcasts a recovery event.
* </p>
*
* @param index index of the item
*/
public synchronized void unsetRecovery(final int index) {
setRecovery(index, PlayQueueItem.RECOVERY_UNSET);
}
/**
* Shuffles the current play queue
* <p>
* This method first backs up the existing play queue and item being played. Then a newly
* shuffled play queue will be generated along with currently playing item placed at the
* beginning of the queue. This item will also be added to the history.
* </p>
* <p>
* Will emit a {@link ReorderEvent} if shuffled.
* </p>
*
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
* top, so shuffling a size-2 list does nothing)
*/
public synchronized void shuffle() {
// Create a backup if it doesn't already exist
// Note: The backup-list has to be created at all cost (even when size <= 2).
// Otherwise it's not possible to enter shuffle-mode!
if (backup == null) {
backup = new ArrayList<>(streams);
}
// Can't shuffle a list that's empty or only has one element
if (size() <= 2) {
return;
}
final int originalIndex = getIndex();
final PlayQueueItem currentItem = getItem();
Collections.shuffle(streams);
// Move currentItem to the head of the queue
streams.remove(currentItem);
streams.add(0, currentItem);
queueIndex.set(0);
history.add(currentItem);
broadcast(new ReorderEvent(originalIndex, 0));
}
/**
* Unshuffles the current play queue if a backup play queue exists.
* <p>
* This method undoes shuffling and index will be set to the previously playing item if found,
* otherwise, the index will reset to 0.
* </p>
* <p>
* Will emit a {@link ReorderEvent} if a backup exists.
* </p>
*/
public synchronized void unshuffle() {
if (backup == null) {
return;
}
final int originIndex = getIndex();
final PlayQueueItem current = getItem();
streams = backup;
backup = null;
final int newIndex = streams.indexOf(current);
if (newIndex != -1) {
queueIndex.set(newIndex);
} else {
queueIndex.set(0);
}
if (streams.size() > queueIndex.get()) {
history.add(streams.get(queueIndex.get()));
}
broadcast(new ReorderEvent(originIndex, queueIndex.get()));
}
/**
* Selects previous played item.
*
* This method removes currently playing item from history and
* starts playing the last item from history if it exists
*
* @return true if history is not empty and the item can be played
* */
public synchronized boolean previous() {
if (history.size() <= 1) {
return false;
}
history.remove(history.size() - 1);
final PlayQueueItem last = history.remove(history.size() - 1);
setIndex(indexOf(last));
return true;
}
/*
* Compares two PlayQueues. Useful when a user switches players but queue is the same so
* we don't have to do anything with new queue.
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
public boolean equalStreams(@Nullable final PlayQueue other) {
if (other == null) {
return false;
}
if (size() != other.size()) {
return false;
}
for (int i = 0; i < size(); i++) {
final PlayQueueItem stream = streams.get(i);
final PlayQueueItem otherStream = other.streams.get(i);
// Check is based on serviceId and URL
if (!stream.isSameItem(otherStream)) {
return false;
}
}
return true;
}
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
if (equalStreams(other)) {
//noinspection ConstantConditions
return other.getIndex() == getIndex(); //NOSONAR: other is not null
}
return false;
}
public boolean isDisposed() {
return disposed;
}
/*//////////////////////////////////////////////////////////////////////////
// Rx Broadcast
//////////////////////////////////////////////////////////////////////////*/
private void broadcast(@NonNull final PlayQueueEvent event) {
if (eventBroadcast != null) {
eventBroadcast.onNext(event);
}
}
}
@@ -0,0 +1,511 @@
package org.schabi.newpipe.player.playqueue
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.subjects.PublishSubject
import java.io.Serializable
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent
import org.schabi.newpipe.player.playqueue.PlayQueueItem
/**
* PlayQueue is responsible for keeping track of a list of streams and the index of
* the stream that should be currently playing.
*
* This class contains basic manipulation of a playlist while also functions as a
* message bus, providing all listeners with new updates to the play queue.
*
* This class can be serialized for passing intents, but in order to start the
* message bus, it must be initialized.
*/
abstract class PlayQueue internal constructor(
index: Int,
startWith: List<PlayQueueItem>
) : Serializable {
private val queueIndex = AtomicInteger(index)
private val history = mutableListOf<PlayQueueItem>()
private var backup = mutableListOf<PlayQueueItem>()
private var streams = startWith.toMutableList()
@Transient
private var eventBroadcast: PublishSubject<PlayQueueEvent>? = null
/**
* Returns the play queue's update broadcast.
* May be null if the play queue message bus is not initialized.
*
* @return the play queue's update broadcast
*/
@Transient
var broadcastReceiver: Flowable<PlayQueueEvent>? = null
private set
@Transient
var isDisposed: Boolean = false
private set
init {
if (streams.size > index) {
history.add(streams[index])
}
}
/*//////////////////////////////////////////////////////////////////////////
// Playlist actions
////////////////////////////////////////////////////////////////////////// */
/**
* Initializes the play queue message buses.
*
* Also starts a self reporter for logging if debug mode is enabled.
*/
fun init() {
eventBroadcast = PublishSubject.create()
broadcastReceiver =
eventBroadcast!!
.toFlowable(BackpressureStrategy.BUFFER)
.observeOn(AndroidSchedulers.mainThread())
.startWithItem(InitEvent())
}
/**
* Dispose the play queue by stopping all message buses.
*/
open fun dispose() {
eventBroadcast?.onComplete()
eventBroadcast = null
broadcastReceiver = null
this.isDisposed = true
}
/**
* Checks if the queue is complete.
*
* A queue is complete if it has loaded all items in an external playlist
* single stream or local queues are always complete.
*
* @return whether the queue is complete
*/
abstract val isComplete: Boolean
/**
* Load partial queue in the background, does nothing if the queue is complete.
*/
abstract fun fetch()
/*//////////////////////////////////////////////////////////////////////////
// Readonly ops
////////////////////////////////////////////////////////////////////////// */
/**
* Changes the current playing index to a new index.
*
* This method is guarded using in a circular manner for index exceeding the play queue size.
*
* Will emit a [SelectEvent] if the index is not the current playing index.
*
* @param index the index to be set
* @return the current index that should be played
*/
@set:Synchronized
var index: Int = 0
get() = queueIndex.get()
set(index) {
val oldIndex = field
val newIndex: Int
if (index < 0) {
newIndex = 0
} else if (index < streams.size) {
// Regular assignment for index in bounds
newIndex = index
} else if (streams.isEmpty()) {
// Out of bounds from here on
// Need to check if stream is empty to prevent arithmetic error and negative index
newIndex = 0
} else if (this.isComplete) {
// Circular indexing
newIndex = index % streams.size
} else {
// Index of last element
newIndex = streams.size - 1
}
queueIndex.set(newIndex)
if (oldIndex != newIndex) {
history.add(streams[newIndex])
}
/*
TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
different from the old one but this is emitted regardless? Not sure what this what it does
exactly so I won't touch it
*/
broadcast(SelectEvent(oldIndex, newIndex))
}
/**
* @return the current item that should be played, or null if the queue is empty
*/
val item get() = getItem(this.index)
/**
* @param index the index of the item to return
* @return the item at the given index, or null if the index is out of bounds
*/
fun getItem(index: Int) = streams.getOrNull(index)
/**
* Returns the index of the given item using referential equality.
* May be null despite play queue contains identical item.
*
* @param item the item to find the index of
* @return the index of the given item
*/
fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item)
/**
* @return the current size of play queue.
*/
fun size(): Int = streams.size
/**
* Checks if the play queue is empty.
*
* @return whether the play queue is empty
*/
val isEmpty: Boolean
get() = streams.isEmpty()
/**
* Determines if the current play queue is shuffled.
*
* @return whether the play queue is shuffled
*/
val isShuffled: Boolean
get() = backup.isNotEmpty()
/**
* @return an immutable view of the play queue
*/
fun getStreams(): List<PlayQueueItem> = Collections.unmodifiableList(streams)
/*//////////////////////////////////////////////////////////////////////////
// Write ops
////////////////////////////////////////////////////////////////////////// */
/**
* Changes the current playing index by an offset amount.
*
* Will emit a [SelectEvent] if offset is non-zero.
*
* @param offset the offset relative to the current index
*/
@Synchronized
fun offsetIndex(offset: Int) {
this.index += offset
}
/**
* Notifies that a change has occurred.
*/
@Synchronized
fun notifyChange() {
broadcast(AppendEvent(0))
}
/**
* Appends the given [PlayQueueItem]s to the current play queue.
*
* If the play queue is shuffled, then append the items to the backup queue as is and
* append the shuffle items to the play queue.
*
* Will emit a [AppendEvent] on any given context.
*
* @param items [PlayQueueItem]s to append
*/
@Synchronized
fun append(items: List<PlayQueueItem>) {
val itemList = items.toMutableList()
if (this.isShuffled) {
backup.addAll(itemList)
itemList.shuffle()
}
if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) {
streams.removeAt(streams.lastIndex)
}
streams.addAll(itemList)
broadcast(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.
*/
fun enqueueNext(item: PlayQueueItem, skipIfSame: Boolean) {
val currentIndex = index
// if the next item is the same item as the one we want to enqueue, skip if flag is true
if (skipIfSame && item == getItem(currentIndex + 1)) {
return
}
append(listOf(item))
move(size() - 1, currentIndex + 1)
}
/**
* Removes the item at the given index from the play queue.
*
* The current playing index will decrement if it is greater than the index being removed.
* On cases where the current playing index exceeds the playlist range, it is set to 0.
*
* Will emit a [RemoveEvent] if the index is within the play queue index range.
*
* @param index the index of the item to remove
*/
@Synchronized
fun remove(index: Int) {
if (index >= streams.size || index < 0) {
return
}
removeInternal(index)
broadcast(RemoveEvent(index, this.index))
}
/**
* Report an exception for the item at the current index in order and skip to the next one
*
* This is done as a separate event as the underlying manager may have
* different implementation regarding exceptions.
*/
@Synchronized
fun error() {
val oldIndex = this.index
queueIndex.incrementAndGet()
if (streams.size > queueIndex.get()) {
history.add(streams[queueIndex.get()])
}
broadcast(ErrorEvent(oldIndex, this.index))
}
@Synchronized
private fun removeInternal(removeIndex: Int) {
val currentIndex = queueIndex.get()
val size = size()
if (currentIndex > removeIndex) {
queueIndex.decrementAndGet()
} else if (currentIndex >= size) {
queueIndex.set(currentIndex % (size - 1))
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
queueIndex.set(0)
}
backup.remove(getItem(removeIndex)!!)
history.remove(streams.removeAt(removeIndex))
if (streams.size > queueIndex.get()) {
history.add(streams[queueIndex.get()])
}
}
/**
* Moves a queue item at the source index to the target index.
*
* If the item being moved is the currently playing, then the current playing index is set
* to that of the target.
* If the moved item is not the currently playing and moves to an index **AFTER** the
* current playing index, then the current playing index is decremented.
* Vice versa if the an item after the currently playing is moved **BEFORE**.
*
* @param source the original index of the item
* @param target the new index of the item
*/
@Synchronized
fun move(
source: Int,
target: Int
) {
if (source < 0 || target < 0) {
return
}
if (source >= streams.size || target >= streams.size) {
return
}
val current = this.index
if (source == current) {
queueIndex.set(target)
} else if (source < current && target >= current) {
queueIndex.decrementAndGet()
} else if (source > current && target <= current) {
queueIndex.incrementAndGet()
}
val playQueueItem = streams.removeAt(source)
playQueueItem.isAutoQueued = false
streams.add(target, playQueueItem)
broadcast(MoveEvent(source, target))
}
/**
* Sets the recovery record of the item at the index.
*
* Broadcasts a recovery event.
*
* @param index index of the item
* @param position the recovery position
*/
@Synchronized
fun setRecovery(
index: Int,
position: Long
) {
streams.getOrNull(index)?.let {
it.recoveryPosition = position
broadcast(RecoveryEvent(index, position))
}
}
/**
* Revoke the recovery record of the item at the index.
*
* Broadcasts a recovery event.
*
* @param index index of the item
*/
@Synchronized
fun unsetRecovery(index: Int) {
setRecovery(index, PlayQueueItem.RECOVERY_UNSET)
}
/**
* Shuffles the current play queue
*
* This method first backs up the existing play queue and item being played. Then a newly
* shuffled play queue will be generated along with currently playing item placed at the
* beginning of the queue. This item will also be added to the history.
*
* Will emit a [ReorderEvent] if shuffled.
*
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
* top, so shuffling a size-2 list does nothing)
*/
@Synchronized
fun shuffle() {
// Create a backup if it doesn't already exist
// Note: The backup-list has to be created at all cost (even when size <= 2).
// Otherwise it's not possible to enter shuffle-mode!
if (backup.isEmpty()) {
backup = streams.toMutableList()
}
// Can't shuffle a list that's empty or only has one element
if (size() <= 2) {
return
}
val originalIndex = this.index
val currentItem = this.item
streams.shuffle()
// Move currentItem to the head of the queue
streams.remove(currentItem!!)
streams.add(0, currentItem)
queueIndex.set(0)
history.add(currentItem)
broadcast(ReorderEvent(originalIndex, 0))
}
/**
* Unshuffles the current play queue if a backup play queue exists.
*
* This method undoes shuffling and index will be set to the previously playing item if found,
* otherwise, the index will reset to 0.
*
* Will emit a [ReorderEvent] if a backup exists.
*/
@Synchronized
fun unshuffle() {
if (backup.isEmpty()) {
return
}
val originIndex = this.index
val current = this.item
streams = backup
backup = mutableListOf()
val newIndex = streams.indexOf(current!!)
if (newIndex != -1) {
queueIndex.set(newIndex)
} else {
queueIndex.set(0)
}
if (streams.size > queueIndex.get()) {
history.add(streams[queueIndex.get()])
}
broadcast(ReorderEvent(originIndex, queueIndex.get()))
}
/**
* Selects previous played item.
*
* This method removes currently playing item from history and
* starts playing the last item from history if it exists
*
* @return true if history is not empty and the item can be played
*/
@Synchronized
fun previous(): Boolean {
if (history.size <= 1) {
return false
}
history.removeAt(history.size - 1)
val last = history.removeAt(history.size - 1)
this.index = indexOf(last)
return true
}
/*
* Compares two PlayQueues. Useful when a user switches players but queue is the same so
* we don't have to do anything with new queue.
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams
override fun hashCode(): Int = streams.hashCode()
fun equalStreamsAndIndex(other: PlayQueue?): Boolean {
return equals(other) && other!!.index == this.index // NOSONAR: other is not null
}
/*//////////////////////////////////////////////////////////////////////////
// Rx Broadcast
////////////////////////////////////////////////////////////////////////// */
private fun broadcast(event: PlayQueueEvent) {
eventBroadcast?.onNext(event)
}
}
@@ -1,158 +0,0 @@
package org.schabi.newpipe.player.playqueue;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.util.ExtractorHelper;
import java.io.Serializable;
import java.util.List;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class PlayQueueItem implements Serializable {
public static final long RECOVERY_UNSET = Long.MIN_VALUE;
private static final String EMPTY_STRING = "";
@NonNull
private final String title;
@NonNull
private final String url;
private final int serviceId;
private final long duration;
@NonNull
private final List<Image> thumbnails;
@NonNull
private final String uploader;
private final String uploaderUrl;
@NonNull
private final StreamType streamType;
private boolean isAutoQueued;
private long recoveryPosition;
private Throwable error;
public PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType());
if (info.getStartPosition() > 0) {
setRecoveryPosition(info.getStartPosition() * 1000);
}
}
PlayQueueItem(@NonNull final StreamInfoItem item) {
this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(),
item.getThumbnails(), item.getUploaderName(),
item.getUploaderUrl(), item.getStreamType());
}
@SuppressWarnings("ParameterNumber")
private PlayQueueItem(@Nullable final String name, @Nullable final String url,
final int serviceId, final long duration,
final List<Image> thumbnails, @Nullable final String uploader,
final String uploaderUrl, @NonNull final StreamType streamType) {
this.title = name != null ? name : EMPTY_STRING;
this.url = url != null ? url : EMPTY_STRING;
this.serviceId = serviceId;
this.duration = duration;
this.thumbnails = thumbnails;
this.uploader = uploader != null ? uploader : EMPTY_STRING;
this.uploaderUrl = uploaderUrl;
this.streamType = streamType;
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;
}
@NonNull
public String getUrl() {
return url;
}
public int getServiceId() {
return serviceId;
}
public long getDuration() {
return duration;
}
@NonNull
public List<Image> getThumbnails() {
return thumbnails;
}
@NonNull
public String getUploader() {
return uploader;
}
public String getUploaderUrl() {
return uploaderUrl;
}
@NonNull
public StreamType getStreamType() {
return streamType;
}
public long getRecoveryPosition() {
return recoveryPosition;
}
/*package-private*/ void setRecoveryPosition(final long recoveryPosition) {
this.recoveryPosition = recoveryPosition;
}
@Nullable
public Throwable getError() {
return error;
}
@NonNull
public Single<StreamInfo> getStream() {
return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false)
.subscribeOn(Schedulers.io())
.doOnError(throwable -> error = throwable);
}
public boolean isAutoQueued() {
return isAutoQueued;
}
////////////////////////////////////////////////////////////////////////////
// Item States, keep external access out
////////////////////////////////////////////////////////////////////////////
public void setAutoQueued(final boolean autoQueued) {
isAutoQueued = autoQueued;
}
}
@@ -0,0 +1,75 @@
package org.schabi.newpipe.player.playqueue
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import java.io.Serializable
import java.util.Objects
import org.schabi.newpipe.extractor.Image
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.util.ExtractorHelper
class PlayQueueItem private constructor(
val title: String,
val url: String,
val serviceId: Int,
val duration: Long,
val thumbnails: List<Image>,
val uploader: String,
val uploaderUrl: String?,
val streamType: StreamType
) : Serializable {
//
// ////////////////////////////////////////////////////////////////////// */
// Item States, keep external access out
//
// ////////////////////////////////////////////////////////////////////// */
var isAutoQueued: Boolean = false
// package-private
var recoveryPosition = RECOVERY_UNSET
var error: Throwable? = null
private set
constructor(info: StreamInfo) : this(
info.name.orEmpty(),
info.url.orEmpty(),
info.serviceId,
info.duration,
info.thumbnails,
info.uploaderName.orEmpty(),
info.uploaderUrl,
info.streamType
) {
if (info.startPosition > 0) {
this.recoveryPosition = info.startPosition * 1000
}
}
constructor(item: StreamInfoItem) : this(
item.name.orEmpty(),
item.url.orEmpty(),
item.serviceId,
item.duration,
item.thumbnails,
item.uploaderName.orEmpty(),
item.uploaderUrl,
item.streamType
)
val stream: Single<StreamInfo>
get() =
ExtractorHelper
.getStreamInfo(serviceId, url, false)
.subscribeOn(Schedulers.io())
.doOnError { throwable -> error = throwable }
override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url
override fun hashCode() = Objects.hash(url, serviceId)
companion object {
const val RECOVERY_UNSET = Long.MIN_VALUE
}
}

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