mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-11-08 23:54:27 +00:00
Merge pull request #13992 from TeamNewPipe/release-0.29.2
Release 0.29.2
This commit is contained in:
446 files changed
+15662
-8388
No files matched your search
@@ -49,7 +49,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
|
||||
|
||||
### Before starting development
|
||||
|
||||
* If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it.
|
||||
* If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it. Please wait for a response from the maintainers before starting development. This prevents multiple people from working on the same issue at the same time, which can lead to wasted effort and frustration. PRs that do not reference an existing issue which is **assigned** to you might be closed without further explanation.
|
||||
* If there is no existing issue for what you want to work on, **open a new one** describing the changes you are planning to introduce. This gives the team and the community a chance to give **feedback** before you spend time on something that is already in development, should be done differently, or should be avoided completely.
|
||||
* Please show **intention to maintain your features** and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description.
|
||||
* Create PRs that cover only **one specific issue/solution/bug**. Do not create PRs that are huge monoliths and could have been split into multiple independent contributions.
|
||||
@@ -59,6 +59,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
|
||||
|
||||
* 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.
|
||||
* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged!
|
||||
* Filling in the **PR template** is mandatory. Ignoring the template or removing it can get your PR closed without further explanation.
|
||||
* Respond if someone requests changes or otherwise raises issues about your PRs.
|
||||
* Try to figure out yourself why builds on our CI fail.
|
||||
* Make sure your PR is **up-to-date** with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must *rebase* your branch on the `dev` branch manually and resolve the conflicts on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). Doing this makes the maintainers' job way easier.
|
||||
|
||||
@@ -24,8 +24,16 @@
|
||||
#### Relies on the following changes
|
||||
<!-- Delete this if it doesn't apply to your PR. -->
|
||||
-
|
||||
#### Testing/QA
|
||||
|
||||
#### APK testing
|
||||
##### 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
|
||||
<!-- 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).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
module.exports = async ({github, context}) => {
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
author {
|
||||
login
|
||||
}
|
||||
closingIssuesReferences(first: 100) {
|
||||
nodes {
|
||||
number
|
||||
repository {
|
||||
nameWithOwner
|
||||
}
|
||||
assignees(first: 10) {
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = await github.graphql(query, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
number: pullRequest.number
|
||||
});
|
||||
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
const authorLogin = result.repository.pullRequest.author.login;
|
||||
const authorIsAssigned = linkedIssues.some(issue =>
|
||||
issue.assignees.nodes.some(assignee => assignee.login === authorLogin)
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Found ${linkedIssues.length} linked issue(s); ` +
|
||||
`the PR author ${authorLogin} is assigned to at least one: ${authorIsAssigned}`
|
||||
);
|
||||
|
||||
if (authorIsAssigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: ['template-ignored']
|
||||
});
|
||||
|
||||
let body;
|
||||
if (linkedIssues.length === 0) {
|
||||
body = 'Please link this PR to an existing issue you are assigned to. Assignees are expected to discuss the details of the PR beforehand. This PR will be closed if it is not linked to an issue.';
|
||||
} else {
|
||||
body = 'Please link this PR to an existing issue you are assigned to. This PR might be closed if the details of this PR were not agreed upon beforehand.';
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: body
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
# SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: Check for linked issue
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-linked-issue:
|
||||
name: Check for linked issues and author being assigned to at least one issue
|
||||
if: >-
|
||||
!contains(fromJSON('["MEMBER", "OWNER", "COLLABORATOR"]'),
|
||||
github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dump Author Association
|
||||
env:
|
||||
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
run: |
|
||||
echo "Author Association: $AUTHOR_ASSOCIATION"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Check linked issues
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const script = require('.github/workflows/linked-issue-check.js');
|
||||
await script({github, context});
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: No Response
|
||||
|
||||
# Both `issue_comment` and `scheduled` event types are required for this Action
|
||||
@@ -17,8 +20,18 @@ jobs:
|
||||
noResponse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: lee-dohm/no-response@v0.5.0
|
||||
- uses: tcely/no-response@v0.2.2
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
daysUntilClose: 14
|
||||
responseRequiredLabel: waiting for author
|
||||
- uses: tcely/no-response@v0.2.2
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
daysUntilClose: 14
|
||||
responseRequiredLabel: template missing
|
||||
- uses: tcely/no-response@v0.2.2
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
daysUntilClose: 14
|
||||
responseRequiredLabel: template ignored
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/*
|
||||
* Script for commenting on issues/PRs when the template is missing or ignored
|
||||
* to encourage proper use of the templates.
|
||||
*/
|
||||
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
module.exports = async ({github, context}) => {
|
||||
|
||||
const label = context.payload.label && context.payload.label.name;
|
||||
if (!label) return;
|
||||
const isPR = !!context.payload.pull_request;
|
||||
const number = isPR ? context.payload.pull_request.number : context.payload.issue.number;
|
||||
|
||||
let templateInfo;
|
||||
if (isPR) {
|
||||
templateInfo = await fetchTemplateAndGenerateComment('Raw template', 'https://raw.githubusercontent.com/TeamNewPipe/NewPipe/refs/heads/dev/.github/PULL_REQUEST_TEMPLATE.md', false);
|
||||
} else {
|
||||
const templates = {
|
||||
'bug report': 'https://raw.githubusercontent.com/TeamNewPipe/NewPipe/refs/heads/dev/.github/ISSUE_TEMPLATE/bug_report.yml',
|
||||
'feature request': 'https://raw.githubusercontent.com/TeamNewPipe/NewPipe/refs/heads/dev/.github/ISSUE_TEMPLATE/feature_request.yml'
|
||||
};
|
||||
|
||||
const templateParts = await Promise.all(Object.entries(templates).map(async ([templateName, url]) => {
|
||||
return await fetchTemplateAndGenerateComment(templateName, url, true);
|
||||
}));
|
||||
|
||||
templateInfo = templateParts.join('\n\n');
|
||||
}
|
||||
|
||||
let body = '';
|
||||
if (label === 'template-ignored') {
|
||||
body = 'Please fill in the template completely or at least as much as possible. The team needs more detailed information. Here is the template, in case you deleted parts of it:\n\n' + templateInfo;
|
||||
} else if (label === 'template-missing') {
|
||||
body = `Thank you for creating this ${isPR ? 'PR' : 'issue'}. Please edit your description and fill in the correct template with the required information:\n\n${templateInfo}`;
|
||||
} else {
|
||||
// should never happen, but just in case
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: number,
|
||||
body
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async function fetchTemplateAndGenerateComment(templateName, url, extractBody) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.log(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
return `<details><summary>${templateName}</summary>\n<a href="${url}" target="_blank">View template</a></details>`;
|
||||
}
|
||||
let template = await response.text();
|
||||
|
||||
if (extractBody) {
|
||||
const issueTemplate = yaml.load(template);
|
||||
template = renderIssueForm(issueTemplate.body || []);
|
||||
}
|
||||
|
||||
const codeFence = '\n```\n';
|
||||
return `<details><summary>${templateName}</summary>\n${codeFence}${template}${codeFence}</details>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the body of a YAML issue template into a Markdown string.
|
||||
* @see https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms.md
|
||||
* @param body
|
||||
* @returns string the Markdown representation of the issue form body
|
||||
*/
|
||||
function renderIssueForm(body) {
|
||||
return body.map(renderFormElement).filter(Boolean).join('\n\n').trim();
|
||||
}
|
||||
|
||||
function renderFormElement(element) {
|
||||
const attributes = element.attributes || {};
|
||||
switch (element.type) {
|
||||
case 'markdown':
|
||||
return attributes.value || '';
|
||||
case 'input':
|
||||
case 'textarea':
|
||||
case 'upload':
|
||||
return renderTextElement(attributes);
|
||||
case 'dropdown':
|
||||
return renderDropdown(attributes);
|
||||
case 'checkboxes':
|
||||
return renderCheckboxes(attributes);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextElement(attributes) {
|
||||
const heading = attributes.label ? `### ${attributes.label}` : '';
|
||||
const description = attributes.description ? `<!--\n${attributes.description}\n-->` : '';
|
||||
const value = attributes.value || attributes.placeholder || '';
|
||||
return [heading, description, value].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function renderDropdown(attributes) {
|
||||
const options = (attributes.options || []).map(option => `- ${option}`).join('\n');
|
||||
return [
|
||||
attributes.label && `### ${attributes.label}`,
|
||||
attributes.description && `<!--\n${attributes.description}\n-->`,
|
||||
options
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function renderCheckboxes(attributes) {
|
||||
const options = (attributes.options || [])
|
||||
.map(option => `- [ ] ${option.label || option}`)
|
||||
.join('\n');
|
||||
return [
|
||||
attributes.label && `### ${attributes.label}`,
|
||||
attributes.description && `<!--\n${attributes.description}\n-->`,
|
||||
options
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-FileCopyrightText: 2021-2022 NewPipe contributors <https://newpipe.net>
|
||||
# SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: Template label comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment_on_label:
|
||||
name: Comment on labeled issue/PR
|
||||
if: ${{ github.event.label.name == 'template-ignored' || github.event.label.name == 'template-missing' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get PR template
|
||||
if: ${{ github.event.pull_request && github.event.label.name == 'template-missing' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo 'PR_TEMPLATE<<EOF'
|
||||
curl https://raw.githubusercontent.com/TeamNewPipe/NewPipe/refs/heads/dev/.github/PULL_REQUEST_TEMPLATE.md
|
||||
echo EOF
|
||||
} >> $GITHUB_ENV
|
||||
|
||||
- name: Install js-yaml
|
||||
run: npm install --no-save --ignore-scripts js-yaml@4.1.0
|
||||
|
||||
- name: Post comment
|
||||
uses: actions/github-script@v8
|
||||
timeout-minutes: 2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const script = require('.github/workflows/template-label.js');
|
||||
await script({github, context});
|
||||
@@ -1,149 +0,0 @@
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
|
||||
|
||||
<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://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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
<hr>
|
||||
|
||||
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)*
|
||||
|
||||
> [!warning]
|
||||
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
|
||||
>
|
||||
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
|
||||
## Screenshots
|
||||
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/00.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/08.png" width=160>](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)
|
||||
<br/><br/>
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
|
||||
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
|
||||
|
||||
### Supported Services
|
||||
|
||||
NewPipe currently supports these services:
|
||||
|
||||
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
|
||||
* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
|
||||
* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
|
||||
* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
|
||||
* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
|
||||
* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
|
||||
|
||||
As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
|
||||
|
||||
Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
|
||||
|
||||
If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||
|
||||
## Description
|
||||
|
||||
NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
|
||||
|
||||
Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
|
||||
|
||||
### Features
|
||||
|
||||
* Watch videos at resolutions up to 4K
|
||||
* Listen to audio in the background, only loading the audio stream to save data
|
||||
* Popup mode (floating player, aka Picture-in-Picture)
|
||||
* Watch live streams
|
||||
* Show/hide subtitles/closed captions
|
||||
* Search videos and audios (on YouTube, you can specify the content language as well)
|
||||
* Enqueue videos (and optionally save them as local playlists)
|
||||
* Show/hide general information about videos (such as description and tags)
|
||||
* Show/hide next/related videos
|
||||
* Show/hide comments
|
||||
* Search videos, audios, channels, playlists and albums
|
||||
* Browse videos and audios within a channel
|
||||
* Subscribe to channels (yes, without logging into any account!)
|
||||
* Get notifications about new videos from channels you're subscribed to
|
||||
* Create and edit channel groups (for easier browsing and management)
|
||||
* Browse video feeds generated from your channel groups
|
||||
* View and search your watch history
|
||||
* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
|
||||
* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
|
||||
* Download videos/audios/subtitles (closed captions)
|
||||
* Open in Kodi
|
||||
* Watch/Block age-restricted material
|
||||
|
||||
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
|
||||
<span id="updates"></span>
|
||||
|
||||
## Installation and updates
|
||||
You can install NewPipe using one of the following methods:
|
||||
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
|
||||
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it.
|
||||
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
|
||||
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
|
||||
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
|
||||
|
||||
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
|
||||
|
||||
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
|
||||
1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists
|
||||
2. Uninstall NewPipe
|
||||
3. Download the APK from the new source and install it
|
||||
4. Import the data from step 1 via Settings > Backup and Restore > Import Database
|
||||
|
||||
> [!Note]
|
||||
> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
|
||||
|
||||
### APK Info
|
||||
|
||||
This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
|
||||
```
|
||||
CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
|
||||
```
|
||||
|
||||
## Contribution
|
||||
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/">
|
||||
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
## Donate
|
||||
If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
|
||||
|
||||
## License
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
@@ -14,6 +14,7 @@ plugins {
|
||||
alias(libs.plugins.jetbrains.kotlinx.serialization)
|
||||
alias(libs.plugins.sonarqube)
|
||||
alias(libs.plugins.about.libraries)
|
||||
alias(libs.plugins.androidx.room)
|
||||
checkstyle
|
||||
}
|
||||
|
||||
@@ -140,11 +141,10 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
room3 {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
|
||||
// Custom dependency configuration for ktlint
|
||||
val ktlint by configurations.creating
|
||||
|
||||
@@ -227,6 +227,7 @@ dependencies {
|
||||
|
||||
// Checkstyle
|
||||
checkstyle(libs.puppycrawl.checkstyle)
|
||||
debugImplementation(libs.leakcanary)
|
||||
ktlint(libs.pinterest.ktlint)
|
||||
|
||||
// AndroidX
|
||||
@@ -244,6 +245,7 @@ dependencies {
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.rxjava3)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
@@ -268,6 +270,7 @@ dependencies {
|
||||
|
||||
// HTTP client
|
||||
implementation(libs.squareup.okhttp)
|
||||
implementation(libs.squareup.okhttp.brotli)
|
||||
|
||||
// Media player
|
||||
implementation(libs.google.exoplayer.core)
|
||||
@@ -324,6 +327,8 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.runner)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.assertj.core)
|
||||
androidTestImplementation(libs.androidx.sqlite.bundled)
|
||||
androidTestImplementation(libs.jetbrains.coroutines.test)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.room3.testing.MigrationTestHelper
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.sqlite.execSQL
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
|
||||
@@ -37,134 +32,112 @@ class DatabaseMigrationTest {
|
||||
private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
}
|
||||
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
|
||||
@get:Rule
|
||||
val testHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java
|
||||
instrumentation = instrumentation,
|
||||
databaseClass = AppDatabase::class,
|
||||
driver = BundledSQLiteDriver(),
|
||||
file = File.createTempFile("test", ".db")
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom2to3() {
|
||||
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
|
||||
fun migrateDatabaseFrom2to3() = runTest {
|
||||
val databaseInV2 = testHelper.createDatabase(version = Migrations.DB_VER_2)
|
||||
|
||||
databaseInV2.run {
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
put("title", DEFAULT_TITLE)
|
||||
put("stream_type", DEFAULT_TYPE.name)
|
||||
put("duration", DEFAULT_DURATION)
|
||||
put("uploader", DEFAULT_UPLOADER_NAME)
|
||||
put("thumbnail_url", DEFAULT_THUMBNAIL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
}
|
||||
)
|
||||
val sql = """
|
||||
INSERT OR FAIL INTO streams
|
||||
(service_id, url, title, stream_type, duration, uploader, thumbnail_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_SERVICE_ID)
|
||||
statement.bindText(2, DEFAULT_URL)
|
||||
statement.bindText(3, DEFAULT_TITLE)
|
||||
statement.bindText(4, DEFAULT_TYPE.name)
|
||||
statement.bindLong(5, DEFAULT_DURATION)
|
||||
statement.bindText(6, DEFAULT_UPLOADER_NAME)
|
||||
statement.bindText(7, DEFAULT_THUMBNAIL)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_SECOND_SERVICE_ID)
|
||||
statement.bindText(2, DEFAULT_SECOND_URL)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_SERVICE_ID)
|
||||
statement.step()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_3,
|
||||
true,
|
||||
Migrations.MIGRATION_2_3
|
||||
val migratedConnection = testHelper.runMigrationsAndValidate(
|
||||
version = Migrations.DB_VER_3,
|
||||
migrations = listOf(Migrations.MIGRATION_2_3)
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_4,
|
||||
true,
|
||||
Migrations.MIGRATION_3_4
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_5,
|
||||
true,
|
||||
Migrations.MIGRATION_4_5
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_6,
|
||||
true,
|
||||
Migrations.MIGRATION_5_6
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_7,
|
||||
true,
|
||||
Migrations.MIGRATION_6_7
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_8,
|
||||
true,
|
||||
Migrations.MIGRATION_7_8
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_9,
|
||||
true,
|
||||
Migrations.MIGRATION_8_9
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().getAll().blockingFirst()
|
||||
|
||||
// Only expect 2, the one with the null url will be ignored
|
||||
assertEquals(2, listFromDB.size)
|
||||
migratedConnection.prepare("SELECT (COUNT(*) = 2) FROM streams").use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
val streamFromMigratedDatabase = listFromDB[0]
|
||||
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
|
||||
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
|
||||
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
|
||||
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
|
||||
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
|
||||
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(streamFromMigratedDatabase.viewCount)
|
||||
assertNull(streamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(streamFromMigratedDatabase.uploadDate)
|
||||
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
|
||||
// Verify first stream exists
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM streams
|
||||
WHERE
|
||||
service_id = $DEFAULT_SERVICE_ID AND
|
||||
url = '$DEFAULT_URL' AND
|
||||
title = '$DEFAULT_TITLE' AND
|
||||
stream_type = '${DEFAULT_TYPE.name}' AND
|
||||
duration = $DEFAULT_DURATION AND
|
||||
uploader = '$DEFAULT_UPLOADER_NAME' AND
|
||||
thumbnail_url = '$DEFAULT_THUMBNAIL' AND
|
||||
view_count IS NULL AND
|
||||
textual_upload_date IS NULL AND
|
||||
upload_date IS NULL AND
|
||||
is_upload_date_approximation IS NULL
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
val secondStreamFromMigratedDatabase = listFromDB[1]
|
||||
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.title)
|
||||
// Should fallback to VIDEO_STREAM
|
||||
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
|
||||
assertEquals(0, secondStreamFromMigratedDatabase.duration)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.uploader)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(secondStreamFromMigratedDatabase.viewCount)
|
||||
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.uploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||
// Verify second stream exists
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM streams
|
||||
WHERE
|
||||
service_id = $DEFAULT_SECOND_SERVICE_ID AND
|
||||
url = '$DEFAULT_SECOND_URL' AND
|
||||
title = '' AND
|
||||
stream_type = '${StreamType.VIDEO_STREAM.name}' AND
|
||||
duration = 0 AND
|
||||
uploader = '' AND
|
||||
thumbnail_url = '' AND
|
||||
view_count IS NULL AND
|
||||
textual_upload_date IS NULL AND
|
||||
upload_date IS NULL AND
|
||||
is_upload_date_approximation IS NULL
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
migratedConnection.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom7to8() {
|
||||
val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7)
|
||||
fun migrateDatabaseFrom7to8() = runTest {
|
||||
val databaseInV7 = testHelper.createDatabase(version = Migrations.DB_VER_7)
|
||||
|
||||
val defaultSearch1 = " abc "
|
||||
val defaultSearch2 = " abc"
|
||||
@@ -175,176 +148,241 @@ class DatabaseMigrationTest {
|
||||
val otherServiceId = ServiceList.SoundCloud.serviceId
|
||||
|
||||
databaseInV7.run {
|
||||
insert(
|
||||
"search_history",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", serviceId)
|
||||
put("search", defaultSearch1)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", serviceId)
|
||||
put("search", defaultSearch2)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", otherServiceId)
|
||||
put("search", defaultSearch1)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", otherServiceId)
|
||||
put("search", defaultSearch2)
|
||||
}
|
||||
)
|
||||
val sql = "INSERT OR FAIL INTO search_history (service_id, search) VALUES (?, ?)"
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, serviceId)
|
||||
statement.bindText(2, defaultSearch1)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, serviceId)
|
||||
statement.bindText(2, defaultSearch2)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, otherServiceId)
|
||||
statement.bindText(2, defaultSearch1)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
prepare(sql).use { statement ->
|
||||
statement.bindInt(1, otherServiceId)
|
||||
statement.bindText(2, defaultSearch2)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_8,
|
||||
true,
|
||||
Migrations.MIGRATION_7_8
|
||||
val migratedConnection = testHelper.runMigrationsAndValidate(
|
||||
version = Migrations.DB_VER_8,
|
||||
migrations = listOf(Migrations.MIGRATION_7_8)
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_9,
|
||||
true,
|
||||
Migrations.MIGRATION_8_9
|
||||
)
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 2) FROM search_history
|
||||
WHERE
|
||||
search = 'abc' AND
|
||||
service_id IN (
|
||||
SELECT service_id FROM search_history
|
||||
WHERE search = 'abc'
|
||||
GROUP BY service_id
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
val migratedDatabaseV8 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV8.searchHistoryDAO().getAll().blockingFirst()
|
||||
|
||||
assertEquals(2, listFromDB.size)
|
||||
assertEquals("abc", listFromDB[0].search)
|
||||
assertEquals("abc", listFromDB[1].search)
|
||||
assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId)
|
||||
migratedConnection.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom8to9() {
|
||||
val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
|
||||
fun migrateDatabaseFrom8to9() = runTest {
|
||||
val databaseInV8 = testHelper.createDatabase(version = Migrations.DB_VER_8)
|
||||
|
||||
val lastPlaylistUIDSQL = "SELECT MAX(uid) FROM playlists"
|
||||
val playlistsSQL = """
|
||||
INSERT OR FAIL INTO playlists
|
||||
(name, is_thumbnail_permanent, thumbnail_stream_id)
|
||||
VALUES (?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
val lastRemotePlaylistUIDSQL = "SELECT MAX(uid) FROM remote_playlists"
|
||||
val remotePlaylistsSQL = """
|
||||
INSERT OR FAIL INTO remote_playlists
|
||||
(service_id, url)
|
||||
VALUES (?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
val localUid1: Long
|
||||
val localUid2: Long
|
||||
val remoteUid1: Long
|
||||
val remoteUid2: Long
|
||||
|
||||
databaseInV8.run {
|
||||
localUid1 = insert(
|
||||
"playlists",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("name", DEFAULT_NAME + "1")
|
||||
put("is_thumbnail_permanent", false)
|
||||
put("thumbnail_stream_id", -1)
|
||||
}
|
||||
)
|
||||
localUid2 = insert(
|
||||
"playlists",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("name", DEFAULT_NAME + "2")
|
||||
put("is_thumbnail_permanent", false)
|
||||
put("thumbnail_stream_id", -1)
|
||||
}
|
||||
)
|
||||
delete(
|
||||
"playlists",
|
||||
"uid = ?",
|
||||
Array(1) { localUid1 }
|
||||
)
|
||||
remoteUid1 = insert(
|
||||
"remote_playlists",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
}
|
||||
)
|
||||
remoteUid2 = insert(
|
||||
"remote_playlists",
|
||||
SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
delete(
|
||||
"remote_playlists",
|
||||
"uid = ?",
|
||||
Array(1) { remoteUid2 }
|
||||
)
|
||||
// Insert first local playlist
|
||||
prepare(playlistsSQL).use { statement ->
|
||||
statement.bindText(1, DEFAULT_NAME + "1")
|
||||
statement.bindBoolean(2, false)
|
||||
statement.bindLong(3, -1)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
localUid1 = prepare(lastPlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
// Insert second local playlist
|
||||
prepare(playlistsSQL).use { statement ->
|
||||
statement.bindText(1, DEFAULT_NAME + "2")
|
||||
statement.bindBoolean(2, false)
|
||||
statement.bindLong(3, -1)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
localUid2 = prepare(lastPlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
execSQL("DELETE FROM playlists WHERE uid = $localUid1")
|
||||
|
||||
// Insert first remote playlist
|
||||
prepare(remotePlaylistsSQL).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_SERVICE_ID)
|
||||
statement.bindText(2, DEFAULT_URL)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
remoteUid1 = prepare(lastRemotePlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
// Insert second remote playlist
|
||||
prepare(remotePlaylistsSQL).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_SECOND_SERVICE_ID)
|
||||
statement.bindText(2, DEFAULT_SECOND_URL)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
remoteUid2 = prepare(lastRemotePlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
execSQL("DELETE FROM remote_playlists WHERE uid = $remoteUid2")
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_9,
|
||||
true,
|
||||
Migrations.MIGRATION_8_9
|
||||
val migratedConnection = testHelper.runMigrationsAndValidate(
|
||||
version = Migrations.DB_VER_9,
|
||||
migrations = listOf(Migrations.MIGRATION_8_9)
|
||||
)
|
||||
|
||||
val migratedDatabaseV9 = getMigratedDatabase()
|
||||
var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
|
||||
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
|
||||
// Verify we have only one local playlist
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM playlists
|
||||
WHERE uid = $localUid2 AND display_index = -1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
assertEquals(1, localListFromDB.size)
|
||||
assertEquals(localUid2, localListFromDB[0].uid)
|
||||
assertEquals(-1, localListFromDB[0].displayIndex)
|
||||
assertEquals(1, remoteListFromDB.size)
|
||||
assertEquals(remoteUid1, remoteListFromDB[0].uid)
|
||||
assertEquals(-1, remoteListFromDB[0].displayIndex)
|
||||
// Verify we have only one remote playlist
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM remote_playlists
|
||||
WHERE uid = $remoteUid1 AND display_index = -1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
|
||||
PlaylistEntity(
|
||||
name = "${DEFAULT_NAME}3",
|
||||
isThumbnailPermanent = false,
|
||||
thumbnailStreamId = -1,
|
||||
displayIndex = -1
|
||||
)
|
||||
)
|
||||
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
|
||||
PlaylistRemoteEntity(
|
||||
serviceId = DEFAULT_THIRD_SERVICE_ID,
|
||||
orderingName = DEFAULT_NAME,
|
||||
url = DEFAULT_THIRD_URL,
|
||||
thumbnailUrl = DEFAULT_THUMBNAIL,
|
||||
uploader = DEFAULT_UPLOADER_NAME,
|
||||
displayIndex = -1,
|
||||
streamCount = 10
|
||||
)
|
||||
)
|
||||
// Insert third local playlist
|
||||
val newPlaylistsSQL = """
|
||||
INSERT OR FAIL INTO playlists
|
||||
(name, is_thumbnail_permanent, thumbnail_stream_id, display_index)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
migratedConnection.prepare(newPlaylistsSQL).use { statement ->
|
||||
statement.bindText(1, DEFAULT_NAME + "3")
|
||||
statement.bindBoolean(2, false)
|
||||
statement.bindLong(3, -1)
|
||||
statement.bindLong(4, -1)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
|
||||
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
|
||||
assertEquals(2, localListFromDB.size)
|
||||
assertEquals(localUid3, localListFromDB[1].uid)
|
||||
assertEquals(-1, localListFromDB[1].displayIndex)
|
||||
assertEquals(2, remoteListFromDB.size)
|
||||
assertEquals(remoteUid3, remoteListFromDB[1].uid)
|
||||
assertEquals(-1, remoteListFromDB[1].displayIndex)
|
||||
}
|
||||
val localUid3 = migratedConnection.prepare(lastPlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.DATABASE_NAME
|
||||
)
|
||||
.build()
|
||||
testHelper.closeWhenFinished(database)
|
||||
return database
|
||||
// Insert third remote playlist
|
||||
val newRemotePlaylistsSQL = """
|
||||
INSERT OR FAIL INTO remote_playlists
|
||||
(service_id, name, url, thumbnail_url, uploader, display_index, stream_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
migratedConnection.prepare(newRemotePlaylistsSQL).use { statement ->
|
||||
statement.bindInt(1, DEFAULT_THIRD_SERVICE_ID)
|
||||
statement.bindText(2, DEFAULT_NAME)
|
||||
statement.bindText(3, DEFAULT_THIRD_URL)
|
||||
statement.bindText(4, DEFAULT_THUMBNAIL)
|
||||
statement.bindText(5, DEFAULT_UPLOADER_NAME)
|
||||
statement.bindLong(6, -1)
|
||||
statement.bindLong(7, 10)
|
||||
statement.step()
|
||||
}
|
||||
|
||||
val remoteUid3 = migratedConnection.prepare(lastRemotePlaylistUIDSQL).use { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
// Verify we have 2 local playlists now with third one present
|
||||
migratedConnection.prepare("SELECT (COUNT(*) = 2) FROM playlists").use { statement ->
|
||||
statement.step()
|
||||
require(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM playlists
|
||||
WHERE uid = $localUid3 AND display_index = -1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
// Verify we have 2 remote playlists now with third one present
|
||||
migratedConnection.prepare("SELECT (COUNT(*) = 2) FROM remote_playlists").use { statement ->
|
||||
statement.step()
|
||||
require(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
migratedConnection.prepare(
|
||||
"""
|
||||
SELECT (COUNT(*) = 1) FROM remote_playlists
|
||||
WHERE uid = $remoteUid3 AND display_index = -1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.step()
|
||||
assert(statement.getBoolean(0))
|
||||
}
|
||||
|
||||
migratedConnection.close()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -68,7 +69,7 @@ class FeedDAOTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnlinkStreamsOlderThan_KeepOne() {
|
||||
fun testUnlinkStreamsOlderThan_KeepOne() = runTest {
|
||||
setupUnlinkDelete("2023-08-15T00:00:00Z")
|
||||
val streams = feedDAO.getStreams(
|
||||
FeedGroupEntity.GROUP_ALL_ID,
|
||||
@@ -82,7 +83,7 @@ class FeedDAOTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnlinkStreamsOlderThan_KeepMultiple() {
|
||||
fun testUnlinkStreamsOlderThan_KeepMultiple() = runTest {
|
||||
setupUnlinkDelete("2023-08-01T00:00:00Z")
|
||||
val streams = feedDAO.getStreams(
|
||||
FeedGroupEntity.GROUP_ALL_ID,
|
||||
@@ -106,7 +107,7 @@ class FeedDAOTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupUnlinkDelete(time: String) {
|
||||
private suspend fun setupUnlinkDelete(time: String) {
|
||||
clearAndFillTables()
|
||||
Single.fromCallable {
|
||||
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
|
||||
@@ -116,7 +117,7 @@ class FeedDAOTest {
|
||||
}.blockingSubscribe()
|
||||
}
|
||||
|
||||
private fun clearAndFillTables() {
|
||||
private suspend fun clearAndFillTables() {
|
||||
db.clearAllTables()
|
||||
streamDAO.insertAll(allStreams)
|
||||
subscriptionDAO.insertAll(
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ class LocalPlaylistManagerTest {
|
||||
// This should not behave like this.
|
||||
// It should throw an error because currently the result is null
|
||||
result.test().await().assertComplete()
|
||||
manager.playlists.test().awaitCount(1).assertValue(emptyList())
|
||||
manager.getPlaylists().test().awaitCount(1).assertValue(mutableListOf())
|
||||
}
|
||||
|
||||
@Test()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.schabi.newpipe.testUtil
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room3.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertSame
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
android:logo="@mipmap/ic_launcher"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/OpeningTheme"
|
||||
android:memtagMode="async"
|
||||
tools:ignore="AllowBackup">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -240,28 +241,6 @@
|
||||
<data android:scheme="vnd.youtube.launch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Hooktube filter -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="hooktube.com" />
|
||||
<data android:host="*.hooktube.com" />
|
||||
<!-- video prefix -->
|
||||
<data android:pathPrefix="/v/" />
|
||||
<data android:pathPrefix="/embed/" />
|
||||
<data android:pathPrefix="/watch" />
|
||||
<!-- channel prefix -->
|
||||
<data android:pathPrefix="/channel/" />
|
||||
<data android:pathPrefix="/user/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Invidious filter -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -273,34 +252,15 @@
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="tubus.eduvid.org" />
|
||||
<data android:host="invidio.us" />
|
||||
<data android:host="dev.invidio.us" />
|
||||
<data android:host="www.invidio.us" />
|
||||
<data android:host="redirect.invidious.io" />
|
||||
<data android:host="invidious.snopyta.org" />
|
||||
<data android:host="yewtu.be" />
|
||||
<data android:host="tube.connect.cafe" />
|
||||
<data android:host="invidious.kavin.rocks" />
|
||||
<data android:host="invidious-us.kavin.rocks" />
|
||||
<data android:host="piped.kavin.rocks" />
|
||||
<data android:host="invidious.site" />
|
||||
<data android:host="vid.mint.lgbt" />
|
||||
<data android:host="invidiou.site" />
|
||||
<data android:host="invidious.fdn.fr" />
|
||||
<data android:host="invidious.048596.xyz" />
|
||||
<data android:host="invidious.zee.li" />
|
||||
<data android:host="vid.puffyan.us" />
|
||||
<data android:host="ytprivate.com" />
|
||||
<data android:host="invidious.namazso.eu" />
|
||||
<data android:host="invidious.silkky.cloud" />
|
||||
<data android:host="invidious.exonip.de" />
|
||||
<data android:host="inv.riverside.rocks" />
|
||||
<data android:host="invidious.blamefran.net" />
|
||||
<data android:host="invidious.moomoo.me" />
|
||||
<data android:host="ytb.trom.tf" />
|
||||
<data android:host="yt.cyberhost.uk" />
|
||||
<data android:host="y.com.cm" />
|
||||
<data android:host="piped.video" />
|
||||
<data android:host="inv.nadeko.net/" />
|
||||
<data android:host="invidious.nerdvpn.de" />
|
||||
<data android:host="yt.chocolatemoo53.com" />
|
||||
<data android:host="invidious.tiekoetter.com" />
|
||||
<data android:host="invidious.f5.si" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
@@ -23,13 +23,16 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import okhttp3.CompressionInterceptor;
|
||||
import okhttp3.Gzip;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
import okhttp3.brotli.Brotli;
|
||||
|
||||
public final class DownloaderImpl extends Downloader {
|
||||
public static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0";
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
|
||||
"youtube_restricted_mode_key";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
|
||||
@@ -44,6 +47,9 @@ public final class DownloaderImpl extends Downloader {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"),
|
||||
// 16 * 1024 * 1024))
|
||||
.addInterceptor(new CompressionInterceptor(
|
||||
Brotli.INSTANCE,
|
||||
Gzip.INSTANCE))
|
||||
.build();
|
||||
this.mCookies = new HashMap<>();
|
||||
}
|
||||
|
||||
@@ -992,7 +992,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
if (lastCheck.plus(30, ChronoUnit.DAYS).isBefore(now)) {
|
||||
final String detailsUrl = getKeepAndroidOpenDetailsUrl();
|
||||
final var solutionUrl = "https://github.com/woheller69/FreeDroidWarn#solutions";
|
||||
|
||||
final var dialog = new AlertDialog.Builder(this)
|
||||
.setTitle("Keep Android Open")
|
||||
@@ -1001,16 +1000,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
.setPositiveButton(android.R.string.ok, (d, w) -> prefs.edit()
|
||||
.putLong(lastCheckKey, now.toEpochMilli())
|
||||
.apply())
|
||||
.setNeutralButton(R.string.kao_solution, null)
|
||||
.setNegativeButton(R.string.kao_dialog_more_info, null)
|
||||
.setNeutralButton(R.string.kao_dialog_more_info, null)
|
||||
.show();
|
||||
|
||||
// If we use setNeutralButton/setNegativeButton, dialog will close after pressing the
|
||||
// buttons, but we want it to close only when positive button is pressed
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
|
||||
.setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, detailsUrl));
|
||||
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||
.setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, solutionUrl));
|
||||
.setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, detailsUrl));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room.databaseBuilder
|
||||
import androidx.room3.Room.databaseBuilder
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
|
||||
import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
|
||||
@@ -28,7 +31,7 @@ object NewPipeDatabase {
|
||||
return databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.Companion.DATABASE_NAME
|
||||
AppDatabase.DATABASE_NAME
|
||||
).addMigrations(
|
||||
MIGRATION_1_2,
|
||||
MIGRATION_2_3,
|
||||
@@ -38,7 +41,7 @@ object NewPipeDatabase {
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9
|
||||
).build()
|
||||
).setDriver(BundledSQLiteDriver()).build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@@ -59,10 +62,15 @@ object NewPipeDatabase {
|
||||
|
||||
@JvmStatic
|
||||
fun checkpoint() {
|
||||
checkNotNull(databaseInstance) { "database is not initialized" }
|
||||
val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null)
|
||||
if (c.moveToFirst() && c.getInt(0) == 1) {
|
||||
throw RuntimeException("Checkpoint was blocked from completing")
|
||||
val database = checkNotNull(databaseInstance) { "database is not initialized" }
|
||||
runBlocking {
|
||||
database.useWriterConnection { transactor ->
|
||||
transactor.usePrepared("pragma wal_checkpoint(full)") { statement ->
|
||||
if (statement.step() && statement.getInt(0) == 1) {
|
||||
throw RuntimeException("Checkpoint was blocked from completing")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import org.schabi.newpipe.database.feed.dao.FeedDAO
|
||||
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
@@ -32,7 +32,7 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
@ColumnTypeConverters(Converters::class)
|
||||
@Database(
|
||||
version = Migrations.DB_VER_9,
|
||||
entities = [
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Update
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Delete
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.Update
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
|
||||
@Dao
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room3.ColumnTypeConverter
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
@@ -14,7 +14,7 @@ class Converters {
|
||||
* @param value the long value
|
||||
* @return the `OffsetDateTime`
|
||||
*/
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? {
|
||||
return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) }
|
||||
}
|
||||
@@ -25,27 +25,27 @@ class Converters {
|
||||
* @param offsetDateTime the `OffsetDateTime`
|
||||
* @return the long value
|
||||
*/
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? {
|
||||
return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun streamTypeOf(value: String): StreamType {
|
||||
return StreamType.valueOf(value)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun stringOf(streamType: StreamType): String {
|
||||
return streamType.name
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun integerOf(feedGroupIcon: FeedGroupIcon): Int {
|
||||
return feedGroupIcon.id
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
@ColumnTypeConverter
|
||||
fun feedGroupIconOf(id: Int): FeedGroupIcon {
|
||||
return FeedGroupIcon.entries.first { it.id == id }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.util.Log
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.execSQL
|
||||
import org.schabi.newpipe.MainActivity
|
||||
|
||||
object Migrations {
|
||||
@@ -33,7 +34,7 @@ object Migrations {
|
||||
private val TAG = Migrations::class.java.getName()
|
||||
private val isDebug = MainActivity.DEBUG
|
||||
|
||||
val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { db ->
|
||||
val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { connection ->
|
||||
if (isDebug) {
|
||||
Log.d(TAG, "Start migrating database")
|
||||
}
|
||||
@@ -46,45 +47,45 @@ object Migrations {
|
||||
|
||||
// Not much we can do about this, since room doesn't create tables before migration.
|
||||
// It's either this or blasting the entire database anew.
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE INDEX `index_search_history_search` " +
|
||||
"ON `search_history` (`search`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `streams` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
|
||||
"`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
|
||||
"`thumbnail_url` TEXT)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_streams_service_id_url` " +
|
||||
"ON `streams` (`service_id`, `url`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `stream_history` " +
|
||||
"(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
|
||||
"`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
|
||||
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE INDEX `index_stream_history_stream_id` " +
|
||||
"ON `stream_history` (`stream_id`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `stream_state` " +
|
||||
"(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
|
||||
"REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlists` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`name` TEXT, `thumbnail_url` TEXT)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
|
||||
db.execSQL(
|
||||
connection.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
|
||||
"(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
|
||||
"`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
|
||||
@@ -93,33 +94,33 @@ object Migrations {
|
||||
"FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE UNIQUE INDEX " +
|
||||
"`index_playlist_stream_join_playlist_id_join_index` " +
|
||||
"ON `playlist_stream_join` (`playlist_id`, `join_index`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE INDEX `index_playlist_stream_join_stream_id` " +
|
||||
"ON `playlist_stream_join` (`stream_id`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `remote_playlists` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
|
||||
"`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE INDEX `index_remote_playlists_name` " +
|
||||
"ON `remote_playlists` (`name`)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
|
||||
"ON `remote_playlists` (`service_id`, `url`)"
|
||||
)
|
||||
|
||||
// Populate streams table with existing entries in watch history
|
||||
// Latest data first, thus ignoring older entries with the same indices
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"INSERT OR IGNORE INTO streams (service_id, url, title, " +
|
||||
"stream_type, duration, uploader, thumbnail_url) " +
|
||||
|
||||
@@ -132,7 +133,7 @@ object Migrations {
|
||||
|
||||
// Once the streams have PKs, join them with the normalized history table
|
||||
// and populate it with the remaining data from watch history
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
|
||||
"SELECT uid, creation_date, 1 " +
|
||||
"FROM watch_history INNER JOIN streams " +
|
||||
@@ -141,16 +142,16 @@ object Migrations {
|
||||
"ORDER BY creation_date DESC"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE IF EXISTS watch_history")
|
||||
connection.execSQL("DROP TABLE IF EXISTS watch_history")
|
||||
|
||||
if (isDebug) {
|
||||
Log.d(TAG, "Stop migrating database")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { db ->
|
||||
val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { connection ->
|
||||
// Add NOT NULLs and new fields
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS streams_new " +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
|
||||
@@ -160,7 +161,7 @@ object Migrations {
|
||||
"is_upload_date_approximation INTEGER)"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
|
||||
"duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
|
||||
"upload_date, is_upload_date_approximation) " +
|
||||
@@ -172,15 +173,15 @@ object Migrations {
|
||||
"FROM streams WHERE url IS NOT NULL"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE streams")
|
||||
db.execSQL("ALTER TABLE streams_new RENAME TO streams")
|
||||
db.execSQL(
|
||||
connection.execSQL("DROP TABLE streams")
|
||||
connection.execSQL("ALTER TABLE streams_new RENAME TO streams")
|
||||
connection.execSQL(
|
||||
"CREATE UNIQUE INDEX index_streams_service_id_url " +
|
||||
"ON streams (service_id, url)"
|
||||
)
|
||||
|
||||
// Tables for feed feature
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed " +
|
||||
"(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(stream_id, subscription_id), " +
|
||||
@@ -189,14 +190,14 @@ object Migrations {
|
||||
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
|
||||
db.execSQL(
|
||||
connection.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_group " +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
|
||||
"icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
|
||||
)
|
||||
db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
|
||||
db.execSQL(
|
||||
connection.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
|
||||
"(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(group_id, subscription_id), " +
|
||||
@@ -205,11 +206,11 @@ object Migrations {
|
||||
"FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
|
||||
"ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE INDEX index_feed_group_subscription_join_subscription_id " +
|
||||
"ON feed_group_subscription_join (subscription_id)"
|
||||
)
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS feed_last_updated " +
|
||||
"(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
|
||||
"PRIMARY KEY(subscription_id), " +
|
||||
@@ -218,33 +219,33 @@ object Migrations {
|
||||
)
|
||||
}
|
||||
|
||||
val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { db ->
|
||||
db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
|
||||
val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { connection ->
|
||||
connection.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
|
||||
}
|
||||
|
||||
val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db ->
|
||||
db.execSQL(
|
||||
val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { connection ->
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
|
||||
"INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db ->
|
||||
db.execSQL(
|
||||
val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { connection ->
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
|
||||
"INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
|
||||
val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db ->
|
||||
val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { connection ->
|
||||
// Create a new column thumbnail_stream_id
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
|
||||
"INTEGER NOT NULL DEFAULT -1"
|
||||
)
|
||||
|
||||
// Migrate the thumbnail_url to the thumbnail_stream_id
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"UPDATE playlists SET thumbnail_stream_id = (" +
|
||||
" SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
|
||||
" FROM (" +
|
||||
@@ -257,7 +258,7 @@ object Migrations {
|
||||
)
|
||||
|
||||
// Remove the thumbnail_url field in the playlist table
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `playlists_new`" +
|
||||
"(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"name TEXT, " +
|
||||
@@ -265,87 +266,79 @@ object Migrations {
|
||||
"thumbnail_stream_id INTEGER NOT NULL)"
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
connection.execSQL(
|
||||
"INSERT INTO playlists_new" +
|
||||
" SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
|
||||
" FROM playlists"
|
||||
)
|
||||
|
||||
db.execSQL("DROP TABLE playlists")
|
||||
db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
|
||||
db.execSQL(
|
||||
connection.execSQL("DROP TABLE playlists")
|
||||
connection.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS " +
|
||||
"`index_playlists_name` ON `playlists` (`name`)"
|
||||
)
|
||||
}
|
||||
|
||||
val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { db ->
|
||||
db.execSQL(
|
||||
val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { connection ->
|
||||
connection.execSQL(
|
||||
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
|
||||
"MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
|
||||
)
|
||||
db.execSQL("UPDATE search_history SET search = trim(search)")
|
||||
connection.execSQL("UPDATE search_history SET search = trim(search)")
|
||||
}
|
||||
|
||||
val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { db ->
|
||||
try {
|
||||
db.beginTransaction()
|
||||
val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { connection ->
|
||||
// Update playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
connection.execSQL(
|
||||
"CREATE TABLE `playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
|
||||
"`thumbnail_stream_id` INTEGER NOT NULL, " +
|
||||
"`display_index` INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO `playlists_tmp` " +
|
||||
"(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"`display_index`) " +
|
||||
"SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"-1 " +
|
||||
"FROM `playlists`"
|
||||
)
|
||||
|
||||
// Update playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
db.execSQL(
|
||||
"CREATE TABLE `playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
|
||||
"`thumbnail_stream_id` INTEGER NOT NULL, " +
|
||||
"`display_index` INTEGER NOT NULL)"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO `playlists_tmp` " +
|
||||
"(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"`display_index`) " +
|
||||
"SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
|
||||
"-1 " +
|
||||
"FROM `playlists`"
|
||||
)
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
connection.execSQL("DROP TABLE `playlists`")
|
||||
connection.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
db.execSQL("DROP TABLE `playlists`")
|
||||
db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
|
||||
// Update remote_playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
connection.execSQL(
|
||||
"CREATE TABLE `remote_playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
|
||||
"`thumbnail_url` TEXT, `uploader` TEXT, " +
|
||||
"`display_index` INTEGER NOT NULL," +
|
||||
"`stream_count` INTEGER)"
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
|
||||
"`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
|
||||
"`stream_count`)" +
|
||||
"SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
|
||||
"-1, `stream_count` FROM `remote_playlists`"
|
||||
)
|
||||
|
||||
// Update remote_playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
db.execSQL(
|
||||
"CREATE TABLE `remote_playlists_tmp` " +
|
||||
"(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
|
||||
"`thumbnail_url` TEXT, `uploader` TEXT, " +
|
||||
"`display_index` INTEGER NOT NULL," +
|
||||
"`stream_count` INTEGER)"
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
|
||||
"`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
|
||||
"`stream_count`)" +
|
||||
"SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
|
||||
"-1, `stream_count` FROM `remote_playlists`"
|
||||
)
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
connection.execSQL("DROP TABLE `remote_playlists`")
|
||||
connection.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
db.execSQL("DROP TABLE `remote_playlists`")
|
||||
db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
|
||||
|
||||
// Create index on the new table.
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
|
||||
"ON `remote_playlists` (`service_id`, `url`)"
|
||||
)
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
// Create index on the new table.
|
||||
connection.execSQL(
|
||||
"CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
|
||||
"ON `remote_playlists` (`service_id`, `url`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.schabi.newpipe.database.feed.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Update
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import java.time.OffsetDateTime
|
||||
@@ -18,6 +20,7 @@ import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
abstract class FeedDAO {
|
||||
@Query("DELETE FROM feed")
|
||||
abstract fun deleteAll(): Int
|
||||
@@ -27,6 +30,10 @@ abstract class FeedDAO {
|
||||
* [FeedGroupEntity.GROUP_ALL_ID] to not filter by group
|
||||
* @param includePlayed if false, only return all of the live, never-played or non-finished
|
||||
* feed streams (see `@see` items); if true no filter is applied
|
||||
* @param includePartiallyPlayed if false, exclude streams that have been partially
|
||||
* played, keeping only never-played streams or streams whose
|
||||
* playback position is within the start/end playback thresholds;
|
||||
* if true, no partial-playback filter is applied
|
||||
* @param uploadDateBefore get only streams uploaded before this date (useful to filter out
|
||||
* future streams); use null to not filter by upload date
|
||||
* @return the feed streams filtered according to the conditions provided in the parameters
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package org.schabi.newpipe.database.feed.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Update
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
abstract class FeedGroupDAO {
|
||||
|
||||
@Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.Index
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.schabi.newpipe.database.feed.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.PrimaryKey
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
|
||||
package org.schabi.newpipe.database.history.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
interface SearchHistoryDAO : BasicDAO<SearchHistoryEntry> {
|
||||
|
||||
@get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)")
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
|
||||
package org.schabi.newpipe.database.history.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
@@ -16,6 +18,7 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
|
||||
|
||||
@Query("SELECT * FROM stream_history")
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Ignore
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.ForeignKey.Companion.CASCADE
|
||||
import androidx.room3.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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.schabi.newpipe.database.history.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room3.ColumnInfo
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room3.ColumnInfo
|
||||
import org.schabi.newpipe.database.LocalItem.LocalItemType
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
interface PlaylistDAO : BasicDAO<PlaylistEntity> {
|
||||
|
||||
@Query("SELECT * FROM playlists")
|
||||
@@ -45,4 +49,10 @@ interface PlaylistDAO : BasicDAO<PlaylistEntity> {
|
||||
return playlist.uid
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun updatePlaylists(updateItems: List<PlaylistMetadataEntry>, deletedItems: List<Long>) {
|
||||
deletedItems.forEach { uid -> deletePlaylist(uid) }
|
||||
updateItems.forEach { item -> upsertPlaylist(PlaylistEntity(item)) }
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,17 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
interface PlaylistRemoteDAO : BasicDAO<PlaylistRemoteEntity> {
|
||||
|
||||
@Query("SELECT * FROM remote_playlists")
|
||||
@@ -52,4 +55,10 @@ interface PlaylistRemoteDAO : BasicDAO<PlaylistRemoteEntity> {
|
||||
|
||||
@Query("DELETE FROM remote_playlists WHERE uid = :playlistId")
|
||||
fun deletePlaylist(playlistId: Long): Int
|
||||
|
||||
@Transaction
|
||||
fun updatePlaylists(updateItems: List<PlaylistRemoteEntity>, deletedItems: List<Long>) {
|
||||
deletedItems.forEach { deletePlaylist(it) }
|
||||
updateItems.forEach { upsert(it) }
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
|
||||
@@ -19,6 +21,7 @@ import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAU
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
|
||||
|
||||
@Query("SELECT * FROM playlist_stream_join")
|
||||
@@ -133,4 +136,10 @@ interface PlaylistStreamDAO : BasicDAO<PlaylistStreamEntity> {
|
||||
"""
|
||||
)
|
||||
fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable<MutableList<PlaylistDuplicatesEntry>>
|
||||
|
||||
@Transaction
|
||||
fun updatePlaylistStreams(playlistId: Long, joinEntities: List<PlaylistStreamEntity>) {
|
||||
deleteBatch(playlistId)
|
||||
insertAll(joinEntities)
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,13 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Ignore
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
|
||||
|
||||
@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE)
|
||||
@Entity(tableName = PlaylistEntity.PLAYLIST_TABLE)
|
||||
data class PlaylistEntity @JvmOverloads constructor(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
|
||||
+5
-5
@@ -7,11 +7,11 @@
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Ignore
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.schabi.newpipe.database.LocalItem.LocalItemType
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID
|
||||
|
||||
+5
-5
@@ -6,11 +6,11 @@
|
||||
|
||||
package org.schabi.newpipe.database.playlist.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.ForeignKey.Companion.CASCADE
|
||||
import androidx.room3.Index
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
package org.schabi.newpipe.database.stream
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Ignore
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import androidx.room3.Ignore
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.schabi.newpipe.database.stream
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Embedded
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.schabi.newpipe.database.stream.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import java.time.OffsetDateTime
|
||||
@@ -16,6 +18,7 @@ import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
@Query("SELECT * FROM streams")
|
||||
abstract override fun getAll(): Flowable<List<StreamEntity>>
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
|
||||
package org.schabi.newpipe.database.stream.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
|
||||
@@ -34,7 +37,7 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun deleteState(streamId: Long): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun silentInsertInternal(streamState: StreamStateEntity)
|
||||
|
||||
@Transaction
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.schabi.newpipe.database.stream.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Ignore
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import java.io.Serializable
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
|
||||
package org.schabi.newpipe.database.stream.model
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.ForeignKey
|
||||
import androidx.room3.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(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.schabi.newpipe.database.subscription
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.rxjava3.RxDaoReturnTypeConverters
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(RxDaoReturnTypeConverters::class)
|
||||
abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||
abstract fun rowCount(): Flowable<Long>
|
||||
|
||||
@@ -6,21 +6,24 @@
|
||||
|
||||
package org.schabi.newpipe.database.subscription
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.Ignore
|
||||
import androidx.room3.Index
|
||||
import androidx.room3.PrimaryKey
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.util.NO_SERVICE_ID
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@Entity(
|
||||
tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE,
|
||||
tableName = SubscriptionEntity.SUBSCRIPTION_TABLE,
|
||||
indices = [
|
||||
Index(
|
||||
value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL],
|
||||
value = [
|
||||
SubscriptionEntity.SUBSCRIPTION_SERVICE_ID,
|
||||
SubscriptionEntity.SUBSCRIPTION_URL
|
||||
],
|
||||
unique = true
|
||||
)
|
||||
]
|
||||
|
||||
@@ -924,7 +924,7 @@ public class DownloadDialog extends DialogFragment
|
||||
} else if (targetFile == null) {
|
||||
// This part is called if:
|
||||
// * the filename is not used in a pending/finished download
|
||||
// * the file does not exists, create
|
||||
// * the file does not exist, create
|
||||
|
||||
if (!mainStorage.mkdirs()) {
|
||||
showFailedDialog(R.string.error_path_creation);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import static android.text.TextUtils.isEmpty;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
|
||||
|
||||
@@ -66,7 +65,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
|
||||
* Get the description to display.
|
||||
* @return description object, if available
|
||||
*/
|
||||
@Nullable
|
||||
@NonNull
|
||||
protected abstract Description getDescription();
|
||||
|
||||
/**
|
||||
@@ -105,8 +104,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
|
||||
|
||||
private void setupDescription() {
|
||||
final Description description = getDescription();
|
||||
if (description == null || isEmpty(description.getContent())
|
||||
|| description == Description.EMPTY_DESCRIPTION) {
|
||||
if (Description.EMPTY_DESCRIPTION.equals(description)) {
|
||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||
return;
|
||||
@@ -137,13 +135,9 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
|
||||
|
||||
private void disableDescriptionSelection() {
|
||||
// show description content again, otherwise some links are not clickable
|
||||
final Description description = getDescription();
|
||||
if (description != null) {
|
||||
TextLinkifier.fromDescription(binding.detailDescriptionView,
|
||||
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
|
||||
getService(), getStreamUrl(),
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
}
|
||||
TextLinkifier.fromDescription(binding.detailDescriptionView, getDescription(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, getService(), getStreamUrl(),
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
|
||||
binding.detailDescriptionNoteView.setVisibility(View.GONE);
|
||||
binding.detailDescriptionView.setTextIsSelectable(false);
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
@@ -34,11 +33,11 @@ public class DescriptionFragment extends BaseDescriptionFragment {
|
||||
// keep empty constructor for State when resuming fragment from memory
|
||||
}
|
||||
|
||||
|
||||
@Nullable
|
||||
@NonNull
|
||||
@Override
|
||||
protected Description getDescription() {
|
||||
return streamInfo.getDescription();
|
||||
return streamInfo.getDescription() != null ? streamInfo.getDescription()
|
||||
: Description.EMPTY_DESCRIPTION;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
||||
@@ -1295,9 +1295,15 @@ public final class VideoDetailFragment
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
|
||||
// sometimes binding would be null here, even though getView() != null above u.u
|
||||
if (binding != null) {
|
||||
// prevent from re-adding a view multiple times
|
||||
playerUi.removeViewFromParent();
|
||||
binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
|
||||
// Only reparent the view if it's not already in the playerPlaceholder.
|
||||
// Removing and re-adding a SurfaceView destroys its native surface,
|
||||
// causing a black screen until the video decoder hits the next
|
||||
// keyframe (2-5 seconds on typical DASH/HLS streams).
|
||||
if (playerUi.getBinding().getRoot().getParent()
|
||||
!= binding.playerPlaceholder) {
|
||||
playerUi.removeViewFromParent();
|
||||
binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
|
||||
}
|
||||
playerUi.setupVideoSurfaceIfNeeded();
|
||||
}
|
||||
});
|
||||
|
||||
+2
-2
@@ -40,10 +40,10 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
|
||||
binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@NonNull
|
||||
@Override
|
||||
protected Description getDescription() {
|
||||
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
|
||||
return Description.of(channelInfo.getDescription(), Description.Type.PLAIN_TEXT);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
||||
+43
-10
@@ -47,6 +47,14 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
||||
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
/**
|
||||
* Replacement for {@code tabHandler} without the raw data it may contain.
|
||||
* Created on a background thread as {@code BandcampChannelLinkHandlerFactory}
|
||||
* performs network requests, which must not be performed on the main thread.
|
||||
*/
|
||||
@Nullable
|
||||
private ListLinkHandler lightweightTabHandler;
|
||||
|
||||
@NonNull
|
||||
public static ChannelTabFragment getInstance(final int serviceId,
|
||||
final ListLinkHandler tabHandler,
|
||||
@@ -97,7 +105,11 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
||||
|
||||
@Override
|
||||
protected Single<ChannelTabInfo> loadResult(final boolean forceLoad) {
|
||||
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad);
|
||||
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad)
|
||||
// Creating the lightweight handler may perform network requests, so it must not
|
||||
// happen on the main thread.
|
||||
.doOnSuccess(result ->
|
||||
lightweightTabHandler = createLightweightTabHandler(result, tabHandler));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -115,33 +127,54 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
||||
super.setTitle(channelName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final ChannelTabInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
/**
|
||||
* Creates a link handler with the same properties as {@code handler}, but without the raw
|
||||
* data a {@link ReadyChannelTabListLinkHandler} may contain. Must not be called on the main
|
||||
* thread, since some services perform network requests to build the link handler.
|
||||
*
|
||||
* @param result the loaded tab info, used to get the service's channel tab factory
|
||||
* @param handler the link handler that was used to load the tab
|
||||
* @return the lightweight link handler, or {@code null} if it is not needed or could not
|
||||
* be created
|
||||
*/
|
||||
@Nullable
|
||||
private ListLinkHandler createLightweightTabHandler(final ChannelTabInfo result,
|
||||
final ListLinkHandler handler) {
|
||||
// FIXME this is a really hacky workaround, to avoid storing useless data in the fragment
|
||||
// state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that
|
||||
// uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if
|
||||
// you combine just a couple of channel tab fragments you easily go over the 1MB
|
||||
// save&restore transaction limit, and get `TransactionTooLargeException`s. A proper
|
||||
// solution would require rethinking about `ReadyChannelTabListLinkHandler`s.
|
||||
if (tabHandler instanceof ReadyChannelTabListLinkHandler) {
|
||||
if (handler instanceof ReadyChannelTabListLinkHandler) {
|
||||
try {
|
||||
// once `handleResult` is called, the parsed data was already saved to cache, so
|
||||
// once the tab info has been loaded, the parsed data was already saved to cache, so
|
||||
// we can discard any raw data in ReadyChannelTabListLinkHandler and create a
|
||||
// link handler with identical properties, but without any raw data
|
||||
final ListLinkHandlerFactory channelTabLHFactory = result.getService()
|
||||
.getChannelTabLHFactory();
|
||||
if (channelTabLHFactory != null) {
|
||||
// some services do not not have a ChannelTabLHFactory
|
||||
tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(),
|
||||
tabHandler.getContentFilters(), tabHandler.getSortFilter());
|
||||
// some services do not have a ChannelTabLHFactory
|
||||
return channelTabLHFactory.fromQuery(handler.getId(),
|
||||
handler.getContentFilters(), handler.getSortFilter());
|
||||
}
|
||||
} catch (final ParsingException e) {
|
||||
// silently ignore the error, as the app can continue to function normally
|
||||
Log.w(TAG, "Could not recreate channel tab handler", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final ChannelTabInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
// Swap in the lightweight handler prepared in loadResult(), now that we are on main thread
|
||||
if (lightweightTabHandler != null) {
|
||||
tabHandler = lightweightTabHandler;
|
||||
lightweightTabHandler = null;
|
||||
}
|
||||
|
||||
if (playlistControlBinding != null) {
|
||||
// PlaylistControls should be visible only if there is some item in
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||
@@ -328,9 +327,9 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
streamCount = result.getStreamCount();
|
||||
setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage());
|
||||
|
||||
final Description description = result.getDescription();
|
||||
if (description != null && description != Description.EMPTY_DESCRIPTION
|
||||
&& !isBlank(description.getContent())) {
|
||||
final Description description = result.getDescription() != null ? result.getDescription()
|
||||
: Description.EMPTY_DESCRIPTION;
|
||||
if (!Description.EMPTY_DESCRIPTION.equals(description)) {
|
||||
final TextEllipsizer ellipsizer = new TextEllipsizer(
|
||||
headerBinding.playlistDescription, 5, getServiceById(result.getServiceId()));
|
||||
ellipsizer.setStateChangeListener(isEllipsized ->
|
||||
|
||||
@@ -10,12 +10,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;
|
||||
@@ -36,6 +43,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.collection.SparseArrayCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -68,7 +76,6 @@ import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
@@ -453,22 +460,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 +493,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()));
|
||||
@@ -683,15 +705,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "showKeyboardSearch() called");
|
||||
}
|
||||
KeyboardUtil.showKeyboard(activity, searchEditText);
|
||||
new SoftwareKeyboardControllerCompat(searchEditText).show();
|
||||
}
|
||||
|
||||
private void hideKeyboardSearch() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "hideKeyboardSearch() called");
|
||||
}
|
||||
|
||||
KeyboardUtil.hideKeyboard(activity, searchEditText);
|
||||
new SoftwareKeyboardControllerCompat(searchEditText).hide();
|
||||
searchEditText.clearFocus();
|
||||
}
|
||||
|
||||
private void showDeleteSuggestionDialog(final SuggestionItem item) {
|
||||
|
||||
@@ -65,7 +65,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
if (DependentPreferenceHelper
|
||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state2 = historyRecordManager.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
.blockingGet();
|
||||
}
|
||||
if (state2 != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
@@ -120,7 +120,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())) {
|
||||
|
||||
@@ -100,7 +100,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
localItems = new ArrayList<>();
|
||||
|
||||
dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
||||
.withLocale(Localization.getPreferredLocale(context));
|
||||
.withLocale(Localization.getAppLocale());
|
||||
}
|
||||
|
||||
public void setSelectedListener(final OnClickGesture<LocalItem> listener) {
|
||||
|
||||
@@ -172,6 +172,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
super.onResume()
|
||||
updateRelativeTimeViews()
|
||||
|
||||
if (groupName.isNotEmpty()) {
|
||||
activity?.supportActionBar?.subtitle = groupName
|
||||
}
|
||||
|
||||
if (updateListViewModeOnResume) {
|
||||
updateListViewModeOnResume = false
|
||||
|
||||
@@ -182,6 +186,16 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
// Clear the activities' action bar subtitle when leaving the fragment to prevent
|
||||
// the feed group name from leaking into subsequent views. (#13366)
|
||||
if (activity?.supportActionBar?.subtitle == groupName) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListViewMode() {
|
||||
// does everything needed to setup the layouts for grid or list modes
|
||||
groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCountStreams(context) else 1
|
||||
@@ -288,13 +302,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
|
||||
if (
|
||||
(groupName != "") &&
|
||||
(activity?.supportActionBar?.subtitle == groupName)
|
||||
) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.schabi.newpipe.local.feed.service
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.room3.withWriteTransaction
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
@@ -15,6 +16,7 @@ import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
@@ -287,37 +289,39 @@ class FeedLoadManager(private val context: Context) {
|
||||
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
|
||||
|
||||
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val info = notification.value!!
|
||||
runBlocking {
|
||||
feedDatabaseManager.database().withWriteTransaction {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val info = notification.value!!
|
||||
|
||||
notification.value!!.newStreams = filterNewStreams(info.streams)
|
||||
notification.value!!.newStreams = filterNewStreams(info.streams)
|
||||
|
||||
feedDatabaseManager.upsertAll(info.uid, info.streams)
|
||||
subscriptionManager.updateFromInfo(info)
|
||||
feedDatabaseManager.upsertAll(info.uid, info.streams)
|
||||
subscriptionManager.updateFromInfo(info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
info.errors.map {
|
||||
FeedLoadService.RequestException(
|
||||
info.uid,
|
||||
"${info.serviceId}:${info.url}",
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(info.uid)
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
info.errors.map {
|
||||
FeedLoadService.RequestException(
|
||||
info.uid,
|
||||
"${info.serviceId}:${info.url}",
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(info.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notification.isOnError -> {
|
||||
val error = notification.error
|
||||
feedResultsHolder.addError(error!!)
|
||||
notification.isOnError -> {
|
||||
val error = notification.error
|
||||
feedResultsHolder.addError(error!!)
|
||||
|
||||
if (error is FeedLoadService.RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
if (error is FeedLoadService.RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
package org.schabi.newpipe.local.history;
|
||||
|
||||
/*
|
||||
* Copyright (C) Mauricio Colli 2018
|
||||
* HistoryRecordManager.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||
import org.schabi.newpipe.database.stream.dao.StreamDAO;
|
||||
import org.schabi.newpipe.database.stream.dao.StreamStateDAO;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
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;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Maybe;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class HistoryRecordManager {
|
||||
private final AppDatabase database;
|
||||
private final StreamDAO streamTable;
|
||||
private final StreamHistoryDAO streamHistoryTable;
|
||||
private final SearchHistoryDAO searchHistoryTable;
|
||||
private final StreamStateDAO streamStateTable;
|
||||
private final SharedPreferences sharedPreferences;
|
||||
private final String searchHistoryKey;
|
||||
private final String streamHistoryKey;
|
||||
|
||||
public HistoryRecordManager(final Context context) {
|
||||
database = NewPipeDatabase.getInstance(context);
|
||||
streamTable = database.streamDAO();
|
||||
streamHistoryTable = database.streamHistoryDAO();
|
||||
searchHistoryTable = database.searchHistoryDAO();
|
||||
streamStateTable = database.streamStateDAO();
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
searchHistoryKey = context.getString(R.string.enable_search_history_key);
|
||||
streamHistoryKey = context.getString(R.string.enable_watch_history_key);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// Watch History
|
||||
///////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Marks a stream item as watched such that it is hidden from the feed if watched videos are
|
||||
* hidden. Adds a history entry and updates the stream progress to 100%.
|
||||
*
|
||||
* @see FeedViewModel#setSaveShowPlayedItems
|
||||
* @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) {
|
||||
if (!isStreamHistoryEnabled()) {
|
||||
return Maybe.empty();
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.blockingGet();
|
||||
duration = completeInfo.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(completeInfo));
|
||||
} else {
|
||||
duration = info.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(info));
|
||||
}
|
||||
|
||||
// Update the stream progress to the full duration of the video
|
||||
final StreamStateEntity 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());
|
||||
}
|
||||
|
||||
public Maybe<Long> onViewed(final StreamInfo info) {
|
||||
if (!isStreamHistoryEnabled()) {
|
||||
return Maybe.empty();
|
||||
}
|
||||
|
||||
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||
final long streamId = streamTable.upsert(new StreamEntity(info));
|
||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
|
||||
if (latestEntry != null) {
|
||||
streamHistoryTable.delete(latestEntry);
|
||||
latestEntry.setAccessDate(currentTime);
|
||||
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
||||
return streamHistoryTable.insert(latestEntry);
|
||||
} else {
|
||||
// just viewed for the first time: set 1 view
|
||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1));
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable deleteStreamHistoryAndState(final long streamId) {
|
||||
return Completable.fromAction(() -> {
|
||||
streamStateTable.deleteState(streamId);
|
||||
streamHistoryTable.deleteStreamHistory(streamId);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deleteWholeStreamHistory() {
|
||||
return Single.fromCallable(streamHistoryTable::deleteAll)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deleteCompleteStreamStateHistory() {
|
||||
return Single.fromCallable(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);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// Search History
|
||||
///////////////////////////////////////////////////////
|
||||
|
||||
public Maybe<Long> onSearched(final int serviceId, final String search) {
|
||||
if (!isSearchHistoryEnabled()) {
|
||||
return Maybe.empty();
|
||||
}
|
||||
|
||||
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search);
|
||||
|
||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||
final SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry();
|
||||
if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) {
|
||||
latestEntry.setCreationDate(currentTime);
|
||||
return (long) searchHistoryTable.update(latestEntry);
|
||||
} else {
|
||||
return searchHistoryTable.insert(newEntry);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deleteSearchHistory(final String search) {
|
||||
return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deleteCompleteSearchHistory() {
|
||||
return Single.fromCallable(searchHistoryTable::deleteAll)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||
final int similarQueryLimit,
|
||||
final int uniqueQueryLimit) {
|
||||
return query.length() > 0
|
||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||
}
|
||||
|
||||
private boolean isSearchHistoryEnabled() {
|
||||
return sharedPreferences.getBoolean(searchHistoryKey, false);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// Stream State History
|
||||
///////////////////////////////////////////////////////
|
||||
|
||||
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)))
|
||||
.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()))
|
||||
.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);
|
||||
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 Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
||||
final List<? extends LocalItem> items) {
|
||||
return Single.fromCallable(() -> {
|
||||
final List<StreamStateEntity> result = new ArrayList<>(items.size());
|
||||
for (final LocalItem item : items) {
|
||||
final long streamId;
|
||||
if (item instanceof StreamStatisticsEntry) {
|
||||
streamId = ((StreamStatisticsEntry) item).getStreamId();
|
||||
} else if (item instanceof PlaylistStreamEntity) {
|
||||
streamId = ((PlaylistStreamEntity) item).getStreamUid();
|
||||
} else if (item instanceof PlaylistStreamEntry) {
|
||||
streamId = ((PlaylistStreamEntry) item).getStreamId();
|
||||
} else {
|
||||
result.add(null);
|
||||
continue;
|
||||
}
|
||||
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
|
||||
.blockingFirst();
|
||||
if (states.isEmpty()) {
|
||||
result.add(null);
|
||||
} else {
|
||||
result.add(states.get(0));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
// Utility
|
||||
///////////////////////////////////////////////////////
|
||||
|
||||
public Single<Integer> removeOrphanedRecords() {
|
||||
return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2023 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.history
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.room3.withWriteTransaction
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.rx3.rxCompletable
|
||||
import kotlinx.coroutines.rx3.rxMaybe
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.extractor.InfoItem
|
||||
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
|
||||
|
||||
class HistoryRecordManager(context: Context) {
|
||||
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private val streamTable = database.streamDAO()
|
||||
private val streamHistoryTable = database.streamHistoryDAO()
|
||||
private val searchHistoryTable = database.searchHistoryDAO()
|
||||
private val streamStateTable = database.streamStateDAO()
|
||||
|
||||
private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
private val searchHistoryKey = context.getString(R.string.enable_search_history_key)
|
||||
private val streamHistoryKey = context.getString(R.string.enable_watch_history_key)
|
||||
|
||||
private val isSearchHistoryEnabled: Boolean
|
||||
get() = sharedPreferences.getBoolean(searchHistoryKey, false)
|
||||
|
||||
private val isStreamHistoryEnabled: Boolean
|
||||
get() = sharedPreferences.getBoolean(streamHistoryKey, false)
|
||||
|
||||
// ///////////////////////////////////////////////////////
|
||||
// Watch History
|
||||
// ///////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Marks a stream item as watched such that it is hidden from the feed if watched videos are
|
||||
* hidden. Adds a history entry and updates the stream progress to 100%.
|
||||
*
|
||||
* @see FeedViewModel.setSaveShowPlayedItems
|
||||
* @param info the item to mark as watched
|
||||
* @return a Maybe containing the ID of the item if successful
|
||||
*/
|
||||
fun markAsWatched(info: StreamInfoItem): Maybe<Long> {
|
||||
if (!isStreamHistoryEnabled) {
|
||||
return Maybe.empty()
|
||||
}
|
||||
|
||||
val currentTime = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
return rxMaybe(Dispatchers.IO) {
|
||||
database.withWriteTransaction {
|
||||
val streamId: Long
|
||||
val duration: Long
|
||||
|
||||
// Duration will not exist if the item was loaded with fast mode, so fetch it
|
||||
// if empty
|
||||
if (info.duration < 0) {
|
||||
val completeInfo = ExtractorHelper.getStreamInfo(
|
||||
info.serviceId,
|
||||
info.url,
|
||||
false
|
||||
).subscribeOn(Schedulers.io()).blockingGet()
|
||||
|
||||
duration = completeInfo.duration
|
||||
streamId = streamTable.upsert(StreamEntity(completeInfo))
|
||||
} else {
|
||||
duration = info.duration
|
||||
streamId = streamTable.upsert(StreamEntity(info))
|
||||
}
|
||||
|
||||
// Update the stream progress to the full duration of the video
|
||||
val entity = StreamStateEntity(
|
||||
streamId,
|
||||
duration * 1000
|
||||
)
|
||||
streamStateTable.upsert(entity)
|
||||
|
||||
// Add a history entry
|
||||
val latestEntry = streamHistoryTable.getLatestEntry(streamId)
|
||||
if (latestEntry == null) {
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 0))
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onViewed(info: StreamInfo): Maybe<Long> {
|
||||
if (!isStreamHistoryEnabled) {
|
||||
return Maybe.empty()
|
||||
}
|
||||
|
||||
val currentTime = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
return rxMaybe(Dispatchers.IO) {
|
||||
database.withWriteTransaction {
|
||||
val streamId = streamTable.upsert(StreamEntity(info))
|
||||
val latestEntry = streamHistoryTable.getLatestEntry(streamId)
|
||||
|
||||
if (latestEntry != null) {
|
||||
streamHistoryTable.delete(latestEntry)
|
||||
latestEntry.accessDate = currentTime
|
||||
latestEntry.repeatCount = latestEntry.repeatCount + 1
|
||||
streamHistoryTable.insert(latestEntry)
|
||||
} else {
|
||||
// just viewed for the first time: set 1 view
|
||||
streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteStreamHistoryAndState(streamId: Long): Completable {
|
||||
return Completable.fromAction {
|
||||
streamStateTable.deleteState(streamId)
|
||||
streamHistoryTable.deleteStreamHistory(streamId)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteWholeStreamHistory(): Single<Int> {
|
||||
return Single.fromCallable { streamHistoryTable.deleteAll() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteCompleteStreamStateHistory(): Single<Int> {
|
||||
return Single.fromCallable { streamStateTable.deleteAll() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getStreamHistorySortedById(): Flowable<MutableList<StreamHistoryEntry>> {
|
||||
return streamHistoryTable.historySortedById.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getStreamStatistics(): Flowable<MutableList<StreamStatisticsEntry>> {
|
||||
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////
|
||||
// Search History
|
||||
// ///////////////////////////////////////////////////////
|
||||
|
||||
fun onSearched(serviceId: Int, search: String): Maybe<Long> {
|
||||
if (!isSearchHistoryEnabled) {
|
||||
return Maybe.empty()
|
||||
}
|
||||
|
||||
val currentTime = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
val newEntry = SearchHistoryEntry(currentTime, serviceId, search)
|
||||
|
||||
// TODO: Do this in a viewmodel once UI is migrated to avoid cancellations
|
||||
return rxMaybe(Dispatchers.IO) {
|
||||
withContext(NonCancellable) {
|
||||
database.withWriteTransaction {
|
||||
val latestEntry = searchHistoryTable.latestEntry
|
||||
if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) {
|
||||
latestEntry.creationDate = currentTime
|
||||
searchHistoryTable.update(latestEntry).toLong()
|
||||
} else {
|
||||
searchHistoryTable.insert(newEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSearchHistory(search: String): Single<Int> {
|
||||
return Single.fromCallable { searchHistoryTable.deleteAllWhereQuery(search) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteCompleteSearchHistory(): Single<Int> {
|
||||
return Single.fromCallable { searchHistoryTable.deleteAll() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getRelatedSearches(
|
||||
query: String,
|
||||
similarQueryLimit: Int,
|
||||
uniqueQueryLimit: Int
|
||||
): Flowable<MutableList<String>> {
|
||||
return if (query.isNotEmpty()) {
|
||||
searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||
} else {
|
||||
searchHistoryTable.getUniqueEntries(uniqueQueryLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////
|
||||
// Stream State History
|
||||
// ///////////////////////////////////////////////////////
|
||||
|
||||
fun loadStreamState(queueItem: PlayQueueItem): Maybe<StreamStateEntity> {
|
||||
return queueItem.stream
|
||||
.map { info -> streamTable.upsert(StreamEntity(info)) }
|
||||
.flatMapPublisher { streamId -> streamStateTable.getState(streamId) }
|
||||
.firstElement()
|
||||
.flatMap { list -> if (list.isEmpty()) Maybe.empty() else Maybe.just(list[0]) }
|
||||
.filter { state -> state.isValid(queueItem.duration) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun loadStreamState(info: StreamInfo): Maybe<StreamStateEntity> {
|
||||
return Single.fromCallable { streamTable.upsert(StreamEntity(info)) }
|
||||
.flatMapPublisher { streamId -> streamStateTable.getState(streamId) }
|
||||
.firstElement()
|
||||
.flatMap { list -> if (list.isEmpty()) Maybe.empty() else Maybe.just(list[0]) }
|
||||
.filter { state -> state.isValid(info.duration) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun loadStreamState(info: InfoItem): Maybe<StreamStateEntity> {
|
||||
return Maybe.fromCallable {
|
||||
streamTable.getStream(info.serviceId.toLong(), info.url)
|
||||
.blockingFirst()
|
||||
.firstOrNull()
|
||||
?.let { entity ->
|
||||
streamStateTable.getState(entity.uid)
|
||||
.blockingFirst()
|
||||
.firstOrNull()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun loadLocalStreamStateBatch(items: List<LocalItem>): Single<List<StreamStateEntity?>> {
|
||||
return Single.fromCallable {
|
||||
items.map { item ->
|
||||
val streamId = when (item) {
|
||||
is StreamStatisticsEntry -> item.streamId
|
||||
is PlaylistStreamEntity -> item.streamUid
|
||||
is PlaylistStreamEntry -> item.streamId
|
||||
else -> return@map null
|
||||
}
|
||||
|
||||
streamStateTable.getState(streamId)
|
||||
.blockingFirst()
|
||||
.firstOrNull()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun saveStreamState(info: StreamInfo, progressMillis: Long): Completable {
|
||||
return rxCompletable(Dispatchers.IO) {
|
||||
database.withWriteTransaction {
|
||||
val streamId = streamTable.upsert(StreamEntity(info))
|
||||
val state = StreamStateEntity(streamId, progressMillis)
|
||||
if (state.isValid(info.duration)) {
|
||||
streamStateTable.upsert(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////
|
||||
// Utility
|
||||
// ///////////////////////////////////////////////////////
|
||||
|
||||
fun removeOrphanedRecords(): Single<Int> {
|
||||
return Single.fromCallable { streamTable.deleteOrphans() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ public class StatisticsPlaylistFragment
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (activity != null) {
|
||||
setTitle(activity.getString(R.string.title_activity_history));
|
||||
displaySortMode(sortMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,9 +384,24 @@ public class StatisticsPlaylistFragment
|
||||
return new SinglePlayQueue(streamInfoItems, index);
|
||||
}
|
||||
|
||||
private void displaySortMode(final StatisticSortMode mode) {
|
||||
final int resourceIdOfSortMode = mode.getResourceId();
|
||||
setTitle(getString(resourceIdOfSortMode));
|
||||
}
|
||||
|
||||
private enum StatisticSortMode {
|
||||
LAST_PLAYED,
|
||||
MOST_PLAYED,
|
||||
LAST_PLAYED(R.string.title_last_played),
|
||||
MOST_PLAYED(R.string.title_most_played);
|
||||
|
||||
private final int resourceId;
|
||||
|
||||
StatisticSortMode(final int theResourceId) {
|
||||
this.resourceId = theResourceId;
|
||||
}
|
||||
|
||||
int getResourceId() {
|
||||
return resourceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import android.os.Parcelable;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -31,6 +30,7 @@ import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
@@ -40,7 +40,6 @@ import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
@@ -55,7 +54,6 @@ 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.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
@@ -72,13 +70,11 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
|
||||
implements PlaylistControlViewHolder, DebounceSavable {
|
||||
@@ -97,6 +93,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
private ItemTouchHelper itemTouchHelper;
|
||||
|
||||
private LocalPlaylistManager playlistManager;
|
||||
private LocalPlaylistViewModel viewModel;
|
||||
private Subscription databaseSubscription;
|
||||
|
||||
private CompositeDisposable disposables;
|
||||
@@ -105,8 +102,6 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
private AtomicBoolean isLoadingComplete;
|
||||
/** Used to debounce saving playlist edits to disk. */
|
||||
private DebounceSaver debounceSaver;
|
||||
/** Flag to prevent simultaneous rewrites of the playlist. */
|
||||
private boolean isRewritingPlaylist = false;
|
||||
|
||||
/**
|
||||
* The pager adapter that the fragment is created from when it is used as frontpage, i.e.
|
||||
@@ -130,6 +125,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
super.onCreate(savedInstanceState);
|
||||
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
|
||||
|
||||
viewModel = new ViewModelProvider(this).get(LocalPlaylistViewModel.class);
|
||||
viewModel.getWorkState().observe(this, state -> {
|
||||
if (state instanceof LocalPlaylistViewModel.WorkState.Loading) {
|
||||
showLoading();
|
||||
} else {
|
||||
hideLoading();
|
||||
if (state instanceof LocalPlaylistViewModel.WorkState.Error error) {
|
||||
showError(new ErrorInfo(error.getThrowable(), UserAction.REQUESTED_BOOKMARK,
|
||||
"Playlist operation"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
disposables = new CompositeDisposable();
|
||||
|
||||
isLoadingComplete = new AtomicBoolean();
|
||||
@@ -369,13 +377,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
|
||||
createRenameDialog();
|
||||
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
|
||||
if (!isRewritingPlaylist) {
|
||||
openRemoveWatchedConfirmationDialog();
|
||||
}
|
||||
openRemoveWatchedConfirmationDialog();
|
||||
} else if (item.getItemId() == R.id.menu_item_remove_duplicates) {
|
||||
if (!isRewritingPlaylist) {
|
||||
openRemoveDuplicatesDialog();
|
||||
}
|
||||
openRemoveDuplicatesDialog();
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
@@ -421,80 +425,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
public void removeWatchedStreams(final boolean removePartiallyWatched) {
|
||||
if (isRewritingPlaylist) {
|
||||
return;
|
||||
}
|
||||
isRewritingPlaylist = true;
|
||||
showLoading();
|
||||
|
||||
final var recordManager = new HistoryRecordManager(getContext());
|
||||
final var historyIdsMaybe = recordManager.getStreamHistorySortedById()
|
||||
.firstElement()
|
||||
// already sorted by ^ getStreamHistorySortedById(), binary search can be used
|
||||
.map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId)
|
||||
.collect(Collectors.toList()));
|
||||
final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId)
|
||||
.firstElement()
|
||||
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
|
||||
// Remove Watched, Functionality data
|
||||
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
|
||||
final boolean isThumbnailPermanent = playlistManager
|
||||
.getIsPlaylistThumbnailPermanent(playlistId);
|
||||
boolean thumbnailVideoRemoved = false;
|
||||
|
||||
final var streamStates = recordManager
|
||||
.loadLocalStreamStateBatch(playlist).blockingGet();
|
||||
|
||||
for (int i = 0; i < playlist.size(); i++) {
|
||||
final var playlistItem = playlist.get(i);
|
||||
final var streamStateEntity = streamStates.get(i);
|
||||
final int indexInHistory = Collections.binarySearch(historyStreamIds,
|
||||
playlistItem.getStreamId());
|
||||
final long duration = playlistItem.toStreamInfoItem().getDuration();
|
||||
|
||||
if (indexInHistory < 0 // stream is not in history
|
||||
// stream is in history but the streamStateEntity is null
|
||||
// if the stream was played for less than 5 seconds, see
|
||||
// StreamStateEntity#PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
|
||||
|| streamStateEntity == null
|
||||
|| (!removePartiallyWatched
|
||||
&& !streamStateEntity.isFinished(duration))) {
|
||||
itemsToKeep.add(playlistItem);
|
||||
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
|
||||
&& playlistManager.getPlaylistThumbnailStreamId(playlistId)
|
||||
== playlistItem.getStreamEntity().getUid()) {
|
||||
thumbnailVideoRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
|
||||
});
|
||||
|
||||
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(flow -> {
|
||||
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
|
||||
final boolean thumbnailVideoRemoved = flow.second;
|
||||
|
||||
itemListAdapter.clearStreamItemList();
|
||||
itemListAdapter.addItems(itemsToKeep);
|
||||
debounceSaver.setHasChangesToSave();
|
||||
saveImmediate();
|
||||
|
||||
if (thumbnailVideoRemoved) {
|
||||
updateThumbnailUrl();
|
||||
}
|
||||
|
||||
final long videoCount = itemListAdapter.getItemsList().size();
|
||||
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
|
||||
if (videoCount == 0) {
|
||||
showEmptyState();
|
||||
}
|
||||
|
||||
hideLoading();
|
||||
isRewritingPlaylist = false;
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
|
||||
"Removing watched videos, partially watched=" + removePartiallyWatched))));
|
||||
viewModel.removeWatchedStreams(playlistId, removePartiallyWatched);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -633,29 +564,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
private void removeDuplicatesInPlaylist() {
|
||||
if (isRewritingPlaylist) {
|
||||
return;
|
||||
}
|
||||
isRewritingPlaylist = true;
|
||||
showLoading();
|
||||
|
||||
final var streamsMaybe = playlistManager
|
||||
.getDistinctPlaylistStreams(playlistId).firstElement();
|
||||
|
||||
|
||||
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(itemsToKeep -> {
|
||||
itemListAdapter.clearStreamItemList();
|
||||
itemListAdapter.addItems(itemsToKeep);
|
||||
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
|
||||
debounceSaver.setHasChangesToSave();
|
||||
saveImmediate();
|
||||
|
||||
hideLoading();
|
||||
isRewritingPlaylist = false;
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
|
||||
"Removing duplicated streams"))));
|
||||
viewModel.removeDuplicates(playlistId);
|
||||
}
|
||||
|
||||
private void deleteItem(final PlaylistStreamEntry item) {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
|
||||
import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
||||
import org.schabi.newpipe.database.stream.dao.StreamDAO;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Maybe;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class LocalPlaylistManager {
|
||||
private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2;
|
||||
|
||||
private final AppDatabase database;
|
||||
private final StreamDAO streamTable;
|
||||
private final PlaylistDAO playlistTable;
|
||||
private final PlaylistStreamDAO playlistStreamTable;
|
||||
|
||||
public LocalPlaylistManager(final AppDatabase db) {
|
||||
database = db;
|
||||
streamTable = db.streamDAO();
|
||||
playlistTable = db.playlistDAO();
|
||||
playlistStreamTable = db.playlistStreamDAO();
|
||||
}
|
||||
|
||||
public Maybe<List<Long>> createPlaylist(final String name, final List<StreamEntity> streams) {
|
||||
// Disallow creation of empty playlists
|
||||
if (streams.isEmpty()) {
|
||||
return Maybe.empty();
|
||||
}
|
||||
|
||||
// Save to the database directly.
|
||||
// Make sure the new playlist is always on the top of bookmark.
|
||||
// The index will be reassigned to non-negative number in BookmarkFragment.
|
||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||
final List<Long> streamIds = streamTable.upsertAll(streams);
|
||||
final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
|
||||
streamIds.get(0), -1);
|
||||
|
||||
return insertJoinEntities(playlistTable.insert(newPlaylist),
|
||||
streamIds, 0);
|
||||
}
|
||||
)).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<List<Long>> appendToPlaylist(final long playlistId,
|
||||
final List<StreamEntity> streams) {
|
||||
return playlistStreamTable.getMaximumIndexOf(playlistId)
|
||||
.firstElement()
|
||||
.map(maxJoinIndex -> database.runInTransaction(() -> {
|
||||
final List<Long> streamIds = streamTable.upsertAll(streams);
|
||||
return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1);
|
||||
}
|
||||
)).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
private List<Long> insertJoinEntities(final long playlistId, final List<Long> streamIds,
|
||||
final int indexOffset) {
|
||||
|
||||
final List<PlaylistStreamEntity> joinEntities = new ArrayList<>(streamIds.size());
|
||||
|
||||
for (int index = 0; index < streamIds.size(); index++) {
|
||||
joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index),
|
||||
index + indexOffset));
|
||||
}
|
||||
return playlistStreamTable.insertAll(joinEntities);
|
||||
}
|
||||
|
||||
public Completable updateJoin(final long playlistId, final List<Long> streamIds) {
|
||||
final List<PlaylistStreamEntity> joinEntities = new ArrayList<>(streamIds.size());
|
||||
for (int i = 0; i < streamIds.size(); i++) {
|
||||
joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i));
|
||||
}
|
||||
|
||||
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
|
||||
playlistStreamTable.deleteBatch(playlistId);
|
||||
playlistStreamTable.insertAll(joinEntities);
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable updatePlaylists(final List<PlaylistMetadataEntry> updateItems,
|
||||
final List<Long> deletedItems) {
|
||||
final List<PlaylistEntity> items = new ArrayList<>(updateItems.size());
|
||||
for (final PlaylistMetadataEntry item : updateItems) {
|
||||
items.add(new PlaylistEntity(item));
|
||||
}
|
||||
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
|
||||
for (final Long uid : deletedItems) {
|
||||
playlistTable.deletePlaylist(uid);
|
||||
}
|
||||
for (final PlaylistEntity item : items) {
|
||||
playlistTable.upsertPlaylist(item);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistStreamEntry>> getDistinctPlaylistStreams(final long playlistId) {
|
||||
return playlistStreamTable
|
||||
.getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlists with attached information about how many times the provided stream is already
|
||||
* contained in each playlist.
|
||||
*
|
||||
* @param streamUrl the stream url for which to check for duplicates
|
||||
* @return a list of {@link PlaylistDuplicatesEntry}
|
||||
*/
|
||||
public Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicates(final String streamUrl) {
|
||||
return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistMetadataEntry>> getPlaylists() {
|
||||
return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistStreamEntry>> getPlaylistStreams(final long playlistId) {
|
||||
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
|
||||
return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false);
|
||||
}
|
||||
|
||||
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
|
||||
final long thumbnailStreamId,
|
||||
final boolean isPermanent) {
|
||||
return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent);
|
||||
}
|
||||
|
||||
public long getPlaylistThumbnailStreamId(final long playlistId) {
|
||||
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId();
|
||||
}
|
||||
|
||||
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
|
||||
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
|
||||
.isThumbnailPermanent();
|
||||
}
|
||||
|
||||
public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) {
|
||||
final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId)
|
||||
.blockingFirst();
|
||||
if (streamId < 0) {
|
||||
return PlaylistEntity.DEFAULT_THUMBNAIL_ID;
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
|
||||
private Maybe<Integer> modifyPlaylist(final long playlistId,
|
||||
@Nullable final String name,
|
||||
final long thumbnailStreamId,
|
||||
final boolean isPermanent) {
|
||||
return playlistTable.getPlaylist(playlistId)
|
||||
.firstElement()
|
||||
.filter(playlistEntities -> !playlistEntities.isEmpty())
|
||||
.map(playlistEntities -> {
|
||||
final PlaylistEntity playlist = playlistEntities.get(0);
|
||||
if (name != null) {
|
||||
playlist.setName(name);
|
||||
}
|
||||
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
|
||||
playlist.setThumbnailStreamId(thumbnailStreamId);
|
||||
playlist.setThumbnailPermanent(isPermanent);
|
||||
}
|
||||
return playlistTable.update(playlist);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<Boolean> hasPlaylists() {
|
||||
return playlistTable.getCount()
|
||||
.firstElement()
|
||||
.map(count -> count > 0)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2024 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import androidx.room3.withWriteTransaction
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.rx3.rxMaybe
|
||||
import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
|
||||
class LocalPlaylistManager(private val database: AppDatabase) {
|
||||
|
||||
private val streamTable = database.streamDAO()
|
||||
private val playlistTable = database.playlistDAO()
|
||||
private val playlistStreamTable = database.playlistStreamDAO()
|
||||
|
||||
fun createPlaylist(name: String, streams: List<StreamEntity>): Maybe<List<Long>> {
|
||||
// Disallow creation of empty playlists
|
||||
if (streams.isEmpty()) {
|
||||
return Maybe.empty()
|
||||
}
|
||||
|
||||
// Save to the database directly.
|
||||
// Make sure the new playlist is always on the top of bookmark.
|
||||
// The index will be reassigned to non-negative number in BookmarkFragment.
|
||||
return rxMaybe(Dispatchers.IO) {
|
||||
database.withWriteTransaction {
|
||||
val streamIds = streamTable.upsertAll(streams)
|
||||
val newPlaylist = PlaylistEntity(
|
||||
name = name,
|
||||
isThumbnailPermanent = false,
|
||||
thumbnailStreamId = streamIds[0],
|
||||
displayIndex = -1
|
||||
)
|
||||
insertJoinEntities(playlistTable.insert(newPlaylist), streamIds, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun appendToPlaylist(playlistId: Long, streams: List<StreamEntity>): Maybe<List<Long>> {
|
||||
return playlistStreamTable.getMaximumIndexOf(playlistId)
|
||||
.firstElement()
|
||||
.map { maxJoinIndex ->
|
||||
runBlocking {
|
||||
database.withWriteTransaction<List<Long>> {
|
||||
val streamIds = streamTable.upsertAll(streams)
|
||||
insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1)
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun insertJoinEntities(
|
||||
playlistId: Long,
|
||||
streamIds: List<Long>,
|
||||
indexOffset: Int
|
||||
): List<Long> {
|
||||
val entities = streamIds.mapIndexed { index, streamId ->
|
||||
PlaylistStreamEntity(playlistId, streamId, index + indexOffset)
|
||||
}
|
||||
return playlistStreamTable.insertAll(entities)
|
||||
}
|
||||
|
||||
fun updateJoin(playlistId: Long, streamIds: List<Long>): Completable {
|
||||
val joinEntities = streamIds.mapIndexed { index, streamId ->
|
||||
PlaylistStreamEntity(playlistId, streamId, index)
|
||||
}
|
||||
|
||||
return Completable.fromRunnable {
|
||||
playlistStreamTable.updatePlaylistStreams(playlistId, joinEntities)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updatePlaylists(
|
||||
updateItems: List<PlaylistMetadataEntry>,
|
||||
deletedItems: List<Long>
|
||||
): Completable {
|
||||
return Completable.fromRunnable {
|
||||
playlistTable.updatePlaylists(updateItems = updateItems, deletedItems = deletedItems)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getDistinctPlaylistStreams(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>> {
|
||||
return playlistStreamTable
|
||||
.getStreamsWithoutDuplicates(playlistId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get playlists with attached information about how many times the provided stream is already
|
||||
* contained in each playlist.
|
||||
*
|
||||
* @param streamUrl the stream url for which to check for duplicates
|
||||
* @return a list of [PlaylistDuplicatesEntry]
|
||||
*/
|
||||
fun getPlaylistDuplicates(streamUrl: String): Flowable<MutableList<PlaylistDuplicatesEntry>> {
|
||||
return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getPlaylists(): Flowable<MutableList<PlaylistMetadataEntry>> {
|
||||
return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getPlaylistStreams(playlistId: Long): Flowable<MutableList<PlaylistStreamEntry>> {
|
||||
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun renamePlaylist(playlistId: Long, name: String): Maybe<Int> {
|
||||
return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false)
|
||||
}
|
||||
|
||||
fun changePlaylistThumbnail(
|
||||
playlistId: Long,
|
||||
thumbnailStreamId: Long,
|
||||
isPermanent: Boolean
|
||||
): Maybe<Int> {
|
||||
return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent)
|
||||
}
|
||||
|
||||
fun getPlaylistThumbnailStreamId(playlistId: Long): Long {
|
||||
return playlistTable.getPlaylist(playlistId).blockingFirst()[0].thumbnailStreamId
|
||||
}
|
||||
|
||||
fun getIsPlaylistThumbnailPermanent(playlistId: Long): Boolean {
|
||||
return playlistTable.getPlaylist(playlistId).blockingFirst()[0].isThumbnailPermanent
|
||||
}
|
||||
|
||||
fun getAutomaticPlaylistThumbnailStreamId(playlistId: Long): Long {
|
||||
val streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId)
|
||||
.blockingFirst()
|
||||
return if (streamId < 0) PlaylistEntity.DEFAULT_THUMBNAIL_ID else streamId
|
||||
}
|
||||
|
||||
private fun modifyPlaylist(
|
||||
playlistId: Long,
|
||||
name: String?,
|
||||
thumbnailStreamId: Long,
|
||||
isPermanent: Boolean
|
||||
): Maybe<Int> {
|
||||
return playlistTable.getPlaylist(playlistId)
|
||||
.firstElement()
|
||||
.filter { playlistEntities -> playlistEntities.isNotEmpty() }
|
||||
.map { playlistEntities ->
|
||||
val playlist = playlistEntities[0]
|
||||
if (name != null) {
|
||||
playlist.name = name
|
||||
}
|
||||
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
|
||||
playlist.thumbnailStreamId = thumbnailStreamId
|
||||
playlist.isThumbnailPermanent = isPermanent
|
||||
}
|
||||
playlistTable.update(playlist)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun hasPlaylists(): Maybe<Boolean> {
|
||||
return playlistTable.count
|
||||
.firstElement()
|
||||
.map { count -> count > 0 }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val THUMBNAIL_ID_LEAVE_UNCHANGED = -2L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Pair
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.util.Collections
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager
|
||||
|
||||
class LocalPlaylistViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val playlistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(application))
|
||||
private val recordManager = HistoryRecordManager(application)
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val _workState = MutableLiveData<WorkState>(WorkState.Idle)
|
||||
val workState: LiveData<WorkState> = _workState
|
||||
|
||||
sealed class WorkState {
|
||||
object Idle : WorkState()
|
||||
object Loading : WorkState()
|
||||
data class Error(val throwable: Throwable) : WorkState()
|
||||
object Success : WorkState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes watched streams from the playlist.
|
||||
* This operation is performed in the background and survives fragment destruction
|
||||
* as it is not disposed when the ViewModel is cleared (to fix #8888).
|
||||
*/
|
||||
fun removeWatchedStreams(playlistId: Long, removePartiallyWatched: Boolean) {
|
||||
_workState.value = WorkState.Loading
|
||||
|
||||
val historyIdsMaybe = recordManager.getStreamHistorySortedById()
|
||||
.firstElement()
|
||||
.map { historyList ->
|
||||
historyList.map { it.streamId }
|
||||
}
|
||||
|
||||
val streamsMaybe = playlistManager.getPlaylistStreams(playlistId)
|
||||
.firstElement()
|
||||
.zipWith(historyIdsMaybe) { playlist, historyStreamIds ->
|
||||
val itemsToKeep = mutableListOf<PlaylistStreamEntry>()
|
||||
val isThumbnailPermanent = playlistManager.getIsPlaylistThumbnailPermanent(playlistId)
|
||||
var thumbnailVideoRemoved = false
|
||||
|
||||
val streamStates = recordManager.loadLocalStreamStateBatch(playlist).blockingGet()
|
||||
|
||||
for (i in playlist.indices) {
|
||||
val playlistItem = playlist[i]
|
||||
val streamStateEntity = streamStates[i]
|
||||
val indexInHistory = Collections.binarySearch(historyStreamIds, playlistItem.streamId)
|
||||
val duration = playlistItem.toStreamInfoItem().duration
|
||||
|
||||
if (indexInHistory < 0 || streamStateEntity == null ||
|
||||
(!removePartiallyWatched && !streamStateEntity.isFinished(duration))
|
||||
) {
|
||||
itemsToKeep.add(playlistItem)
|
||||
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved &&
|
||||
playlistManager.getPlaylistThumbnailStreamId(playlistId) ==
|
||||
playlistItem.streamEntity.uid
|
||||
) {
|
||||
thumbnailVideoRemoved = true
|
||||
}
|
||||
}
|
||||
|
||||
Pair(itemsToKeep, thumbnailVideoRemoved)
|
||||
}
|
||||
|
||||
val disposable = streamsMaybe
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMapCompletable { flow ->
|
||||
val itemsToKeep = flow.first
|
||||
val thumbnailVideoRemoved = flow.second
|
||||
val streamIds = itemsToKeep.map { it.streamId }
|
||||
|
||||
playlistManager.updateJoin(playlistId, streamIds)
|
||||
.andThen(
|
||||
Completable.fromAction {
|
||||
if (thumbnailVideoRemoved) {
|
||||
updateThumbnailUrl(playlistId, itemsToKeep)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ _workState.value = WorkState.Success },
|
||||
{ _workState.value = WorkState.Error(it) }
|
||||
)
|
||||
|
||||
disposables.add(disposable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes duplicate streams from the playlist.
|
||||
*/
|
||||
fun removeDuplicates(playlistId: Long) {
|
||||
_workState.value = WorkState.Loading
|
||||
|
||||
val disposable = playlistManager.getDistinctPlaylistStreams(playlistId)
|
||||
.firstElement()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMapCompletable { itemsToKeep ->
|
||||
val streamIds = itemsToKeep.map { it.streamId }
|
||||
playlistManager.updateJoin(playlistId, streamIds)
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ _workState.value = WorkState.Success },
|
||||
{ _workState.value = WorkState.Error(it) }
|
||||
)
|
||||
|
||||
disposables.add(disposable)
|
||||
}
|
||||
|
||||
private fun updateThumbnailUrl(playlistId: Long, itemsToKeep: List<PlaylistStreamEntry>) {
|
||||
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
|
||||
return
|
||||
}
|
||||
|
||||
val thumbnailStreamId = if (itemsToKeep.isNotEmpty()) {
|
||||
itemsToKeep[0].streamEntity.uid
|
||||
} else {
|
||||
PlaylistEntity.DEFAULT_THUMBNAIL_ID
|
||||
}
|
||||
|
||||
playlistManager.changePlaylistThumbnail(playlistId, thumbnailStreamId, false).blockingGet()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// We deliberately do not clear disposables here to allow pending
|
||||
// database mutations to finish even if the user leaves the screen.
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import org.schabi.newpipe.database.AppDatabase
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
|
||||
|
||||
class RemotePlaylistManager(private val database: AppDatabase) {
|
||||
class RemotePlaylistManager(database: AppDatabase) {
|
||||
private val playlistRemoteTable = database.playlistRemoteDAO()
|
||||
|
||||
val playlists: Flowable<MutableList<PlaylistRemoteEntity>>
|
||||
@@ -38,10 +38,10 @@ class RemotePlaylistManager(private val database: AppDatabase) {
|
||||
deletedItems: List<Long>
|
||||
): Completable {
|
||||
return Completable.fromRunnable {
|
||||
database.runInTransaction {
|
||||
deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) }
|
||||
updateItems.forEach { playlistRemoteTable.upsert(it) }
|
||||
}
|
||||
playlistRemoteTable.updatePlaylists(
|
||||
updateItems = updateItems,
|
||||
deletedItems = deletedItems
|
||||
)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.withWriteTransaction
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
@@ -50,11 +51,11 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo?>>) {
|
||||
suspend fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo?>>) {
|
||||
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
|
||||
subscriptionTable.upsertAll(listEntities)
|
||||
|
||||
database.runInTransaction {
|
||||
database.withWriteTransaction {
|
||||
infoList.forEachIndexed { index, info ->
|
||||
// There may be no tabs on the channel to refresh the feed from
|
||||
if (info.second == null) return@forEachIndexed
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
@@ -67,6 +68,13 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
|
||||
private boolean seeking;
|
||||
|
||||
/**
|
||||
* The calculated max width for {@code R.id.queue_total_duration}
|
||||
* used to set a fixed width for the TextView to prevent {@code R.id.artist_name}
|
||||
* from resetting to left when updating the duration text.
|
||||
*/
|
||||
private int queueDurationWidth;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
@@ -366,6 +374,63 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
}
|
||||
}
|
||||
|
||||
private void updateQueueTime(final int currentProgress) {
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
queueControlBinding.queueTotalDuration.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
final PlayQueue playQueue = player.getPlayQueue();
|
||||
final List<PlayQueueItem> streams = playQueue.getStreams();
|
||||
final int currentIndex = playQueue.getIndex();
|
||||
|
||||
if (streams.isEmpty() || currentIndex < 0 || currentIndex >= streams.size()) {
|
||||
queueControlBinding.queueTotalDuration.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
final long before = streams.subList(0, currentIndex).stream()
|
||||
.mapToLong(PlayQueueItem::getDuration)
|
||||
.sum();
|
||||
|
||||
final long total = streams.stream()
|
||||
.mapToLong(PlayQueueItem::getDuration)
|
||||
.sum();
|
||||
|
||||
final long queueProgress = before + currentProgress / 1000L;
|
||||
final String totalDuration = Localization.getDurationString(total);
|
||||
|
||||
// Calculate the max width for the queue time to prevent resetting the artist marquee's
|
||||
// scrolling position when the artist name is longer than the space available.
|
||||
// Without a fixed width the scrolling position is reset every second when updating
|
||||
// the text and thus the width of queueTotalDuration.
|
||||
|
||||
// Use the widest digit for the progress part while preserving its duration format.
|
||||
final StringBuilder widestProgressDuration = new StringBuilder(totalDuration.length());
|
||||
for (int i = 0; i < totalDuration.length(); i++) {
|
||||
final char character = totalDuration.charAt(i);
|
||||
widestProgressDuration.append(Character.isDigit(character) ? '8' : character);
|
||||
}
|
||||
|
||||
final TextView queueDuration = queueControlBinding.queueTotalDuration;
|
||||
final float progressWidth = queueDuration.getPaint()
|
||||
.measureText(widestProgressDuration.toString());
|
||||
final float totalWidth = queueDuration.getPaint().measureText(totalDuration);
|
||||
final float separatorWidth = queueDuration.getPaint().measureText(" / ");
|
||||
|
||||
final int durationWidth = Math.round(progressWidth + separatorWidth + totalWidth);
|
||||
|
||||
if (queueDurationWidth != durationWidth) {
|
||||
queueDuration.setWidth(durationWidth);
|
||||
queueDurationWidth = durationWidth;
|
||||
}
|
||||
|
||||
queueDuration.setText(
|
||||
Localization.getDurationString(queueProgress) + " / " + totalDuration
|
||||
);
|
||||
queueDuration.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Component On-Click Listener
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
@@ -498,6 +563,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
queueControlBinding.currentTime.getLayoutParams();
|
||||
currentTimeParams.width = queueControlBinding.endTime.getWidth();
|
||||
queueControlBinding.currentTime.setLayoutParams(currentTimeParams);
|
||||
updateQueueTime(currentProgress);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -186,7 +186,9 @@ public final class Player implements PlaybackListener, Listener {
|
||||
// Playback
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
// play queue might be null e.g. while player is starting
|
||||
/**
|
||||
* The play queue might be {@code null} e.g. while player is starting
|
||||
*/
|
||||
@Nullable
|
||||
private PlayQueue playQueue;
|
||||
|
||||
@@ -230,8 +232,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
private PlayerType playerType = PlayerType.MAIN;
|
||||
private int currentState = STATE_PREFLIGHT;
|
||||
|
||||
// audio only mode does not mean that player type is background, but that the player was
|
||||
// minimized to background but will resume automatically to the original player type
|
||||
/**
|
||||
* Audio-only mode does not mean that player type is background, but that the player was
|
||||
* minimized to background and will change to the original player type automatically.
|
||||
*/
|
||||
private boolean isAudioOnly = false;
|
||||
private boolean isPrepared = false;
|
||||
|
||||
@@ -634,6 +638,14 @@ public final class Player implements PlaybackListener, Listener {
|
||||
simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK);
|
||||
simpleExoPlayer.setHandleAudioBecomingNoisy(true);
|
||||
|
||||
// Enable ExoPlayer automatic audio focus management
|
||||
simpleExoPlayer.setAudioAttributes(
|
||||
new com.google.android.exoplayer2.audio.AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
true);
|
||||
|
||||
audioReactor = new AudioReactor(context, simpleExoPlayer);
|
||||
|
||||
registerBroadcastReceiver();
|
||||
@@ -926,7 +938,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
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
|
||||
// thumbnail loading code, this would be skipped only when both bitmaps are `null`, since
|
||||
// onThumbnailLoaded won't be called twice with the same nonnull bitmap by Coil's target.
|
||||
if (currentThumbnail != bitmap) {
|
||||
currentThumbnail = bitmap;
|
||||
@@ -1173,10 +1185,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
UIs.call(PlayerUi::onPrepared);
|
||||
|
||||
if (playWhenReady && !isMuted()) {
|
||||
audioReactor.requestAudioFocus();
|
||||
}
|
||||
}
|
||||
|
||||
private void onBlocked() {
|
||||
@@ -1324,11 +1332,18 @@ public final class Player implements PlaybackListener, Listener {
|
||||
public void toggleMute() {
|
||||
final boolean wasMuted = isMuted();
|
||||
simpleExoPlayer.setVolume(wasMuted ? 1 : 0);
|
||||
if (wasMuted) {
|
||||
audioReactor.requestAudioFocus();
|
||||
} else {
|
||||
audioReactor.abandonAudioFocus();
|
||||
}
|
||||
|
||||
// Update audio focus
|
||||
// When wasMuted is true, we want to play sound, so ExoPlayer has to handle audio focus in
|
||||
// this case; when wasMuted is false, we don't want to play sound, so no audio focus should
|
||||
// be done
|
||||
simpleExoPlayer.setAudioAttributes(
|
||||
new com.google.android.exoplayer2.audio.AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
wasMuted);
|
||||
|
||||
UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted));
|
||||
notifyPlaybackUpdateToListeners();
|
||||
}
|
||||
@@ -1511,7 +1526,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
* <li>From {@link PlaybackException#ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE BAD_IO} to
|
||||
* {@link PlaybackException#ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED UNSUPPORTED_FORMATS}:
|
||||
* If the stream source is validated by the extractor but not recognized by the player,
|
||||
* then we can try to recover playback by signalling an error on the {@link PlayQueue}.</li>
|
||||
* then we can try to recover playback by signaling an error on the {@link PlayQueue}.</li>
|
||||
* <li>For {@link PlaybackException#ERROR_CODE_TIMEOUT PLAYER_TIMEOUT},
|
||||
* {@link PlaybackException#ERROR_CODE_IO_UNSPECIFIED MEDIA_SOURCE_RESOLVER_TIMEOUT} and
|
||||
* {@link PlaybackException#ERROR_CODE_IO_NETWORK_CONNECTION_FAILED NO_NETWORK}:
|
||||
@@ -1530,7 +1545,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
*/
|
||||
// Any error code not explicitly covered here are either unrelated to NewPipe use case
|
||||
// (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should
|
||||
// shutdown.
|
||||
// shut down.
|
||||
@SuppressWarnings("SwitchIntDef")
|
||||
@Override
|
||||
public void onPlayerError(@NonNull final PlaybackException error) {
|
||||
@@ -1740,10 +1755,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMuted()) {
|
||||
audioReactor.requestAudioFocus();
|
||||
}
|
||||
|
||||
if (currentState == STATE_COMPLETED) {
|
||||
if (playQueue.getIndex() == 0) {
|
||||
seekToDefault();
|
||||
@@ -1755,7 +1766,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
if (isStopped()) {
|
||||
// Some phones suspend a paused player after 10 minutes. This causes the player to
|
||||
// enter STATE_IDLE, causing playback to fail. So we try to recover from that here.
|
||||
setRecovery();
|
||||
reloadPlayQueueManager();
|
||||
}
|
||||
|
||||
@@ -1771,7 +1781,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
return;
|
||||
}
|
||||
|
||||
audioReactor.abandonAudioFocus();
|
||||
simpleExoPlayer.pause();
|
||||
saveStreamProgressState();
|
||||
}
|
||||
@@ -1868,7 +1877,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnError(e -> {
|
||||
if (DEBUG) {
|
||||
e.printStackTrace();
|
||||
Log.e(TAG, "Failed to save stream progress state", e);
|
||||
}
|
||||
})
|
||||
.onErrorComplete()
|
||||
@@ -2236,7 +2245,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
reloadPlayQueueManager();
|
||||
});
|
||||
|
||||
// Disable or enable video and subtitles renderers depending of the
|
||||
// Disable or enable video and subtitles renderers depending on the
|
||||
// videoAndSubtitlesEnabled value
|
||||
trackSelector.setParameters(trackSelector.buildUponParameters()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoAndSubtitlesEnabled)
|
||||
@@ -2349,7 +2358,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
try {
|
||||
return !exoPlayerIsNull() && simpleExoPlayer.isCurrentMediaItemDynamic();
|
||||
} catch (final IndexOutOfBoundsException e) {
|
||||
// Why would this even happen =(... but lets log it anyway, better safe than sorry
|
||||
// Why would this even happen =(... but let's log it anyway, better safe than sorry
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e);
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* <p>
|
||||
* If media requests are relative, the URI from which the manifest comes from (either the
|
||||
* manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the
|
||||
* content will be not playable, as it will be an invalid URL, or it may be treat as something
|
||||
* content will be not playable, as it will be an invalid URL, or it may be treated as something
|
||||
* unexpected, for instance as a file for
|
||||
* {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s.
|
||||
* </p>
|
||||
|
||||
@@ -12,14 +12,9 @@ import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAU
|
||||
import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader;
|
||||
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
|
||||
import static com.google.android.exoplayer2.util.Util.castNonNull;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getVisionOsUserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isVisionOsStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
|
||||
import static java.lang.Math.min;
|
||||
|
||||
import android.net.Uri;
|
||||
@@ -661,8 +656,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
||||
}
|
||||
}
|
||||
|
||||
if (isWebStreamingUrl(requestUrl)
|
||||
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
|
||||
if (isWebStreamingUrl(requestUrl)) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
|
||||
@@ -672,15 +666,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
||||
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers");
|
||||
|
||||
if (isAndroidStreamingUrl(requestUrl)) {
|
||||
// Improvement which may be done: find the content country used to request YouTube
|
||||
// contents to add it in the user agent instead of using the default
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
|
||||
getAndroidUserAgent(null));
|
||||
} else if (isIosStreamingUrl(requestUrl)) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
|
||||
getIosUserAgent(null));
|
||||
} else if (isVisionOsStreamingUrl(requestUrl)) {
|
||||
if (isVisionOsStreamingUrl(requestUrl)) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
|
||||
getVisionOsUserAgent(null));
|
||||
} else {
|
||||
|
||||
@@ -258,7 +258,7 @@ class PopupPlayerGestureListener(
|
||||
|
||||
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
|
||||
val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent)
|
||||
// Check if an view is in expected state and if not animate it into the correct state
|
||||
// Check if a view is in expected state and if not animate it into the correct state
|
||||
if (binding.closingOverlay.isVisible != showClosingOverlayView) {
|
||||
binding.closingOverlay.animate(showClosingOverlayView, 200)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
package org.schabi.newpipe.player.helper;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.AudioManager;
|
||||
import android.media.audiofx.AudioEffect;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media.AudioFocusRequestCompat;
|
||||
import androidx.media.AudioManagerCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.analytics.AnalyticsListener;
|
||||
|
||||
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener {
|
||||
public class AudioReactor implements AnalyticsListener {
|
||||
|
||||
private static final String TAG = "AudioFocusReactor";
|
||||
|
||||
private static final int DUCK_DURATION = 1500;
|
||||
private static final float DUCK_AUDIO_TO = .2f;
|
||||
|
||||
private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
|
||||
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
|
||||
|
||||
private final ExoPlayer player;
|
||||
private final Context context;
|
||||
private final AudioManager audioManager;
|
||||
|
||||
private final AudioFocusRequestCompat request;
|
||||
|
||||
public AudioReactor(@NonNull final Context context,
|
||||
@NonNull final ExoPlayer player) {
|
||||
this.player = player;
|
||||
this.context = context;
|
||||
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
|
||||
player.addAnalyticsListener(this);
|
||||
|
||||
request = new AudioFocusRequestCompat.Builder(FOCUS_GAIN_TYPE)
|
||||
//.setAcceptsDelayedFocusGain(true)
|
||||
.setWillPauseWhenDucked(true)
|
||||
.setOnAudioFocusChangeListener(this)
|
||||
.build();
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
abandonAudioFocus();
|
||||
player.removeAnalyticsListener(this);
|
||||
notifyAudioSessionUpdate(false, player.getAudioSessionId());
|
||||
}
|
||||
@@ -57,14 +37,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||
// Audio Manager
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public void requestAudioFocus() {
|
||||
AudioManagerCompat.requestAudioFocus(audioManager, request);
|
||||
}
|
||||
|
||||
public void abandonAudioFocus() {
|
||||
AudioManagerCompat.abandonAudioFocusRequest(audioManager, request);
|
||||
}
|
||||
|
||||
public int getVolume() {
|
||||
return audioManager.getStreamVolume(STREAM_TYPE);
|
||||
}
|
||||
@@ -77,73 +49,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||
return AudioManagerCompat.getStreamMaxVolume(audioManager, STREAM_TYPE);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// AudioFocus
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onAudioFocusChange(final int focusChange) {
|
||||
Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]");
|
||||
switch (focusChange) {
|
||||
case AudioManager.AUDIOFOCUS_GAIN:
|
||||
onAudioFocusGain();
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
|
||||
onAudioFocusLossCanDuck();
|
||||
break;
|
||||
case AudioManager.AUDIOFOCUS_LOSS:
|
||||
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
|
||||
onAudioFocusLoss();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void onAudioFocusGain() {
|
||||
Log.d(TAG, "onAudioFocusGain() called");
|
||||
player.setVolume(DUCK_AUDIO_TO);
|
||||
animateAudio(DUCK_AUDIO_TO, 1.0f);
|
||||
|
||||
if (PlayerHelper.isResumeAfterAudioFocusGain(context)) {
|
||||
player.play();
|
||||
}
|
||||
}
|
||||
|
||||
private void onAudioFocusLoss() {
|
||||
Log.d(TAG, "onAudioFocusLoss() called");
|
||||
player.pause();
|
||||
}
|
||||
|
||||
private void onAudioFocusLossCanDuck() {
|
||||
Log.d(TAG, "onAudioFocusLossCanDuck() called");
|
||||
// Set the volume to 1/10 on ducking
|
||||
player.setVolume(DUCK_AUDIO_TO);
|
||||
}
|
||||
|
||||
private void animateAudio(final float from, final float to) {
|
||||
final ValueAnimator valueAnimator = new ValueAnimator();
|
||||
valueAnimator.setFloatValues(from, to);
|
||||
valueAnimator.setDuration(AudioReactor.DUCK_DURATION);
|
||||
valueAnimator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationStart(final Animator animation) {
|
||||
player.setVolume(from);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationCancel(final Animator animation) {
|
||||
player.setVolume(to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationEnd(final Animator animation) {
|
||||
player.setVolume(to);
|
||||
}
|
||||
});
|
||||
valueAnimator.addUpdateListener(animation ->
|
||||
player.setVolume(((float) animation.getAnimatedValue())));
|
||||
valueAnimator.start();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Audio Processing
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@@ -116,7 +116,10 @@ public final class PlayerHelper {
|
||||
@NonNull
|
||||
public static String captionLanguageOf(@NonNull final Context context,
|
||||
@NonNull final SubtitlesStream subtitles) {
|
||||
final String displayName = subtitles.getDisplayLanguageName();
|
||||
final String trackName = subtitles.getName();
|
||||
final String displayName = trackName != null && !trackName.isBlank()
|
||||
? trackName
|
||||
: subtitles.getDisplayLanguageName();
|
||||
return displayName + (subtitles.isAutoGenerated()
|
||||
? " (" + context.getString(R.string.caption_auto_generated) + ")" : "");
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
|
||||
* <br><br>
|
||||
* If the exception is not known, e.g. {@link java.net.UnknownHostException} or some
|
||||
* other network issue, then no source info is refreshed and
|
||||
* {@link #maybeThrowSourceInfoRefreshError()} be will triggered.
|
||||
* {@link #maybeThrowSourceInfoRefreshError()} will be triggered.
|
||||
* <br><br>
|
||||
* Note that this method is called only once until {@link #releaseSourceInternal()} is called,
|
||||
* so if no action is done in here, playback will stall unless
|
||||
|
||||
@@ -15,7 +15,7 @@ public interface ManagedMediaSource extends MediaSource {
|
||||
* {@link ManagedMediaSource}, then it should be replaced.
|
||||
* @param isInterruptable specifies if this {@link ManagedMediaSource} potentially
|
||||
* being played.
|
||||
* @return whether this could be replaces
|
||||
* @return whether this could be replaced
|
||||
*/
|
||||
boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, boolean isInterruptable);
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ public interface PlaybackListener {
|
||||
/**
|
||||
* Called when the play queue can no longer be played or used.
|
||||
* Currently, this means the play queue is empty and complete.
|
||||
* Signals to the listener that it should shutdown.
|
||||
* Signals to the listener that it should shut down.
|
||||
* <p>
|
||||
* May be called at any time.
|
||||
* </p>
|
||||
|
||||
@@ -8,11 +8,11 @@ import com.google.android.exoplayer2.video.PlaceholderSurface;
|
||||
|
||||
/**
|
||||
* Prevent error message: 'Unrecoverable player error occurred'
|
||||
* In case of rotation some users see this kind of an error which is preventable
|
||||
* In case of rotation some users see this kind of error which is preventable
|
||||
* having a Callback that handles the lifecycle of the surface.
|
||||
* <p>
|
||||
* How?: In case we are no longer able to write to the surface eg. through rotation/putting in
|
||||
* background we set set a DummySurface. Although it it works on API >= 23 only.
|
||||
* How?: In case we are no longer able to write to the surface e.g. through rotation/putting in
|
||||
* background we set a DummySurface. Although it works on API >= 23 only.
|
||||
* Result: we get a little video interruption (audio is still fine) but we won't get the
|
||||
* 'Unrecoverable player error occurred' error message.
|
||||
* <p>
|
||||
|
||||
@@ -373,7 +373,7 @@ public abstract class PlayQueue implements Serializable {
|
||||
* 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>.
|
||||
* Vice versa if an item after the currently playing is moved <b>BEFORE</b>.
|
||||
* </p>
|
||||
*
|
||||
* @param source the original index of the item
|
||||
|
||||
@@ -38,6 +38,8 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -185,6 +187,24 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
||||
|
||||
binding.getRoot().addOnLayoutChangeListener(this);
|
||||
|
||||
// fitsSystemWindows only pads the controls by the system bars, but with gesture navigation
|
||||
// the system reserves a taller area at the bottom for its gestures. In fullscreen, keep
|
||||
// the controls out of that area, otherwise dragging the seekbar triggers system gestures.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.playbackControlRoot,
|
||||
(view, windowInsets) -> {
|
||||
final WindowInsetsCompat result =
|
||||
ViewCompat.onApplyWindowInsets(view, windowInsets);
|
||||
if (isFullscreen) {
|
||||
final int gestureBottom = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.mandatorySystemGestures()).bottom;
|
||||
if (gestureBottom > view.getPaddingBottom()) {
|
||||
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(),
|
||||
view.getPaddingRight(), gestureBottom);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
binding.moreOptionsButton.setOnLongClickListener(v -> {
|
||||
player.getFragmentListener()
|
||||
.ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked);
|
||||
@@ -205,6 +225,8 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
||||
context.getContentResolver().unregisterContentObserver(settingsContentObserver);
|
||||
|
||||
binding.getRoot().removeOnLayoutChangeListener(this);
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.playbackControlRoot, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -243,7 +243,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
||||
//region Popup position and size
|
||||
|
||||
/**
|
||||
* Check if {@link #popupLayoutParams}' position is within a arbitrary boundary
|
||||
* Check if {@link #popupLayoutParams}' position is within an arbitrary boundary
|
||||
* that goes from (0, 0) to (screenWidth, screenHeight).
|
||||
* <p>
|
||||
* If it's out of these boundaries, {@link #popupLayoutParams}' position is changed
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.Menu;
|
||||
@@ -65,7 +66,9 @@ import org.schabi.newpipe.databinding.PlayerBinding;
|
||||
import org.schabi.newpipe.extractor.MediaFormat;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamSegment;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.views.ChaptersSeekBar;
|
||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
@@ -85,6 +88,7 @@ import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -147,6 +151,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
|
||||
new SeekbarPreviewThumbnailHolder();
|
||||
|
||||
@NonNull
|
||||
private List<StreamSegment> currentChapters = Collections.emptyList();
|
||||
@Nullable
|
||||
private StreamSegment lastChapterForHaptic = null;
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Constructor, setup, destroy
|
||||
@@ -589,6 +598,18 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
binding.currentSeekbarPreviewThumbnail,
|
||||
binding.subtitleView::getWidth);
|
||||
|
||||
// Chapter title tooltip + haptic feedback at chapter boundaries
|
||||
if (!currentChapters.isEmpty()) {
|
||||
final StreamSegment chapter = getChapterAtMs(progress);
|
||||
if (chapter != null && chapter.getTitle() != null) {
|
||||
binding.currentChapterTitle.setText(chapter.getTitle());
|
||||
}
|
||||
if (chapter != lastChapterForHaptic) {
|
||||
lastChapterForHaptic = chapter;
|
||||
seekBar.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
|
||||
}
|
||||
}
|
||||
|
||||
adjustSeekbarPreviewContainer();
|
||||
}
|
||||
|
||||
@@ -642,6 +663,10 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
AnimationType.SCALE_AND_ALPHA);
|
||||
animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION,
|
||||
AnimationType.SCALE_AND_ALPHA);
|
||||
if (!currentChapters.isEmpty()) {
|
||||
animate(binding.currentChapterTitle, true, DEFAULT_CONTROLS_DURATION,
|
||||
AnimationType.SCALE_AND_ALPHA);
|
||||
}
|
||||
}
|
||||
|
||||
@Override // seekbar listener
|
||||
@@ -658,6 +683,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress()));
|
||||
animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||||
animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||||
animate(binding.currentChapterTitle, false, 200, AnimationType.SCALE_AND_ALPHA);
|
||||
|
||||
if (player.getCurrentState() == STATE_PAUSED_SEEK) {
|
||||
player.changeState(STATE_BUFFERING);
|
||||
@@ -668,6 +694,25 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
|
||||
showControlsThenHide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chapter active at the given playback position, or {@code null} if
|
||||
* {@code currentChapters} is empty.
|
||||
*
|
||||
* @param positionMs playback position in milliseconds
|
||||
* @return the {@link StreamSegment} whose window contains {@code positionMs}
|
||||
*/
|
||||
@Nullable
|
||||
private StreamSegment getChapterAtMs(final long positionMs) {
|
||||
StreamSegment result = null;
|
||||
for (final StreamSegment seg : currentChapters) {
|
||||
if (seg.getStartTimeSeconds() * 1000L > positionMs) {
|
||||
break;
|
||||
}
|
||||
result = seg;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -765,7 +810,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
|
||||
/**
|
||||
* Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action
|
||||
* that will be performed when the button is clicked..
|
||||
* that will be performed when the button is clicked.
|
||||
* @param action the action that is performed when the play/pause button is clicked
|
||||
*/
|
||||
private void updatePlayPauseButton(final PlayButtonAction action) {
|
||||
@@ -1036,6 +1081,14 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
binding.channelTextView.setText(info.getUploaderName());
|
||||
|
||||
this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames());
|
||||
|
||||
// Chapter markers on seekbar
|
||||
currentChapters = info.getStreamSegments() != null
|
||||
? info.getStreamSegments() : Collections.emptyList();
|
||||
lastChapterForHaptic = null;
|
||||
((ChaptersSeekBar) binding.playbackSeekBar)
|
||||
.setChapters(currentChapters, info.getDuration());
|
||||
binding.currentChapterTitle.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void updateStreamRelatedViews() {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import net.newpipe.app.platform.DebugActions
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
|
||||
|
||||
/**
|
||||
* Host-side implementation of the debug actions used by the Compose debug settings screen.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class ComposeDebugActions(private val context: Context) : DebugActions {
|
||||
|
||||
private val leakCanary = loadLeakCanary()
|
||||
|
||||
override val isLeakCanaryAvailable: Boolean
|
||||
get() = leakCanary != null
|
||||
|
||||
override fun showMemoryLeaks() {
|
||||
// The injected context is the application context, so the activity needs its own task.
|
||||
leakCanary?.newLeakDisplayActivityIntent
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
?.let(context::startActivity)
|
||||
}
|
||||
|
||||
override fun checkNewStreams() = NotificationWorker.runNow(context)
|
||||
|
||||
override fun createErrorNotification() = ErrorUtil.createNotification(context, dummyErrorInfo())
|
||||
|
||||
override fun reportDummyError() = ErrorUtil.openActivity(context, dummyErrorInfo())
|
||||
|
||||
private fun dummyErrorInfo() = ErrorInfo(
|
||||
RuntimeException(DUMMY_EXCEPTION_TEXT),
|
||||
UserAction.UI_ERROR,
|
||||
DUMMY_EXCEPTION_TEXT
|
||||
)
|
||||
|
||||
private fun loadLeakCanary(): DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI? = runCatching {
|
||||
Class.forName(DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
|
||||
.getDeclaredConstructor()
|
||||
.newInstance() as DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI
|
||||
}.getOrNull()
|
||||
|
||||
private companion object {
|
||||
const val DUMMY_EXCEPTION_TEXT = "Dummy"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.nononsenseapps.filepicker.Utils
|
||||
import java.io.IOException
|
||||
import net.newpipe.app.platform.DownloadFolderError
|
||||
import net.newpipe.app.platform.DownloadStorageHost
|
||||
import net.newpipe.app.platform.FolderPickResult
|
||||
import org.schabi.newpipe.streams.io.StoredDirectoryHelper
|
||||
import org.schabi.newpipe.util.FilePickerActivityHelper
|
||||
|
||||
/**
|
||||
* Host-side storage handling for the Compose download settings screen
|
||||
*/
|
||||
class ComposeDownloadStorage(private val context: Context) : DownloadStorageHost {
|
||||
|
||||
override val useStorageAccessFramework: Boolean
|
||||
get() = NewPipeSettings.useStorageAccessFramework(context)
|
||||
|
||||
override fun createFolderPickerIntent(): Intent = StoredDirectoryHelper.getPicker(context)
|
||||
|
||||
override fun acquireFolder(folder: Uri): FolderPickResult {
|
||||
if (FilePickerActivityHelper.isOwnFileUri(context, folder)) {
|
||||
val target = Utils.getFileForUri(folder)
|
||||
return when {
|
||||
target.canWrite() -> FolderPickResult.Picked(Uri.fromFile(target).toString())
|
||||
else -> FolderPickResult.Failed(DownloadFolderError.EXTERNAL_STORAGE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
context.grantUriPermission(
|
||||
context.packageName,
|
||||
folder,
|
||||
StoredDirectoryHelper.PERMISSION_FLAGS
|
||||
)
|
||||
if (!StoredDirectoryHelper(context, folder, null).canWrite()) {
|
||||
throw IOException("No write permissions on $folder")
|
||||
}
|
||||
Log.i(TAG, "Acquiring tree success from $folder")
|
||||
FolderPickResult.Picked(folder.toString())
|
||||
} catch (error: IOException) {
|
||||
Log.e(TAG, "Error acquiring tree from $folder", error)
|
||||
FolderPickResult.Failed(DownloadFolderError.NOT_WRITABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ComposeDownloadStorage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import net.newpipe.app.platform.HistoryActions
|
||||
import org.schabi.newpipe.DownloaderImpl
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager
|
||||
import org.schabi.newpipe.util.InfoCache
|
||||
|
||||
class ComposeHistoryActions(private val context: Context) : HistoryActions {
|
||||
|
||||
private val recordManager = HistoryRecordManager(context)
|
||||
|
||||
override fun wipeMetadataCache() = InfoCache.getInstance().clearCache()
|
||||
|
||||
override suspend fun clearWatchHistory() = runDeletion(REQUEST_WATCH_HISTORY) {
|
||||
recordManager.deleteCompleteStreamStateHistory().await()
|
||||
recordManager.deleteWholeStreamHistory().await()
|
||||
recordManager.removeOrphanedRecords().await()
|
||||
}
|
||||
|
||||
override suspend fun clearPlaybackStates() = runDeletion(REQUEST_PLAYBACK_STATES) {
|
||||
recordManager.deleteCompleteStreamStateHistory().await()
|
||||
}
|
||||
|
||||
override suspend fun clearSearchHistory() = runDeletion(REQUEST_SEARCH_HISTORY) {
|
||||
recordManager.deleteCompleteSearchHistory().await()
|
||||
}
|
||||
|
||||
override fun clearRecaptchaCookies() = DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, "")
|
||||
|
||||
private suspend fun runDeletion(request: String, deletion: suspend () -> Unit): Boolean = try {
|
||||
deletion()
|
||||
true
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (error: Exception) {
|
||||
ErrorUtil.openActivity(
|
||||
context,
|
||||
ErrorInfo(error, UserAction.DELETE_FROM_HISTORY, request)
|
||||
)
|
||||
false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REQUEST_WATCH_HISTORY = "Delete from history"
|
||||
const val REQUEST_PLAYBACK_STATES = "Delete playback states"
|
||||
const val REQUEST_SEARCH_HISTORY = "Delete search history"
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceFragmentCompat;
|
||||
|
||||
@@ -34,7 +36,6 @@ import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighli
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener;
|
||||
import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KeyboardUtil;
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
@@ -302,14 +303,19 @@ public class SettingsActivity extends AppCompatActivity implements
|
||||
menuSearchItem.setVisible(!active);
|
||||
}
|
||||
|
||||
final var keyboardController = new SoftwareKeyboardControllerCompat(searchEditText);
|
||||
if (active) {
|
||||
getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME)
|
||||
final FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
|
||||
final Fragment current = getSupportFragmentManager()
|
||||
.findFragmentById(FRAGMENT_HOLDER_ID);
|
||||
if (current != null) {
|
||||
ft.remove(current);
|
||||
}
|
||||
ft.add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME)
|
||||
.addToBackStack(PreferenceSearchFragment.NAME)
|
||||
.commit();
|
||||
|
||||
KeyboardUtil.showKeyboard(this, searchEditText);
|
||||
keyboardController.show();
|
||||
} else if (searchFragment != null) {
|
||||
hideSearchFragment();
|
||||
getSupportFragmentManager()
|
||||
@@ -317,7 +323,8 @@ public class SettingsActivity extends AppCompatActivity implements
|
||||
PreferenceSearchFragment.NAME,
|
||||
FragmentManager.POP_BACK_STACK_INCLUSIVE);
|
||||
|
||||
KeyboardUtil.hideKeyboard(this, searchEditText);
|
||||
keyboardController.hide();
|
||||
searchEditText.clearFocus();
|
||||
}
|
||||
|
||||
resetSearchText();
|
||||
|
||||
@@ -221,6 +221,39 @@ public final class SettingMigrations {
|
||||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_8_9 = new Migration(8, 9) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// Support for courses and podcasts tabs were added.
|
||||
// If the user changed the displayed tabs the new ones need to be added to the list
|
||||
// of displayed tabs.
|
||||
final Set<String> enabledTabs = sp.getStringSet(context.getString(
|
||||
R.string.show_channel_tabs_key), null);
|
||||
if (enabledTabs != null) {
|
||||
enabledTabs.add(context.getString(R.string.show_channel_tabs_courses));
|
||||
enabledTabs.add(context.getString(R.string.show_channel_tabs_podcasts));
|
||||
sp.edit()
|
||||
.putStringSet(context.getString(R.string.show_channel_tabs_key),
|
||||
enabledTabs)
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_9_10 = new Migration(9, 10) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// New Updates on KAO campaign should be displayed right away
|
||||
final var lastCheckKey = context.getString(R.string.kao_last_checked_key);
|
||||
final Long lastCheckedEpochMillis = sp.getLong(lastCheckKey, 0L);
|
||||
if (lastCheckedEpochMillis != 0) {
|
||||
sp.edit()
|
||||
.putLong(lastCheckKey, 0L)
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List of all implemented migrations.
|
||||
* <p>
|
||||
@@ -236,12 +269,14 @@ public final class SettingMigrations {
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10
|
||||
};
|
||||
|
||||
/**
|
||||
* Version number for preferences. Must be incremented every time a migration is necessary.
|
||||
*/
|
||||
private static final int VERSION = 8;
|
||||
private static final int VERSION = 10;
|
||||
|
||||
|
||||
static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
|
||||
@@ -27,6 +27,8 @@ public final class ChannelTabHelper {
|
||||
case ChannelTabs.LIKES:
|
||||
case ChannelTabs.SHORTS:
|
||||
case ChannelTabs.LIVESTREAMS:
|
||||
case ChannelTabs.PODCASTS:
|
||||
case ChannelTabs.COURSES:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
@@ -65,6 +67,10 @@ public final class ChannelTabHelper {
|
||||
return R.string.show_channel_tabs_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.show_channel_tabs_likes;
|
||||
case ChannelTabs.PODCASTS:
|
||||
return R.string.show_channel_tabs_podcasts;
|
||||
case ChannelTabs.COURSES:
|
||||
return R.string.show_channel_tabs_courses;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -83,6 +89,10 @@ public final class ChannelTabHelper {
|
||||
return R.string.fetch_channel_tabs_livestreams;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.fetch_channel_tabs_likes;
|
||||
case ChannelTabs.PODCASTS:
|
||||
return R.string.fetch_channel_tabs_podcasts;
|
||||
case ChannelTabs.COURSES:
|
||||
return R.string.fetch_channel_tabs_courses;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -107,6 +117,10 @@ public final class ChannelTabHelper {
|
||||
return R.string.channel_tab_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.channel_tab_likes;
|
||||
case ChannelTabs.PODCASTS:
|
||||
return R.string.channel_tab_podcasts;
|
||||
case ChannelTabs.COURSES:
|
||||
return R.string.channel_tab_courses;
|
||||
default:
|
||||
return R.string.unknown_content;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public final class DeviceUtils {
|
||||
* support media tunneling to match the <strong>upcoming</strong> version code.</p>
|
||||
* @see #shouldSupportMediaTunneling()
|
||||
*/
|
||||
public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994;
|
||||
public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 1015;
|
||||
|
||||
// region: devices not supporting media tunneling / media tunneling blacklist
|
||||
/**
|
||||
@@ -120,6 +120,16 @@ public final class DeviceUtils {
|
||||
* #10122</a></p>
|
||||
*/
|
||||
private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW");
|
||||
/**
|
||||
* <p>JMGO N1S 4K.</p>
|
||||
* <p>Blacklist reason: fullscreen crash</p>
|
||||
*/
|
||||
private static final boolean LONAVLA = Build.DEVICE.equals("lonavla");
|
||||
/**
|
||||
* <p>TCL 65Q651G.</p>
|
||||
* <p>Blacklist reason: fullscreen crash</p>
|
||||
*/
|
||||
private static final boolean G10 = Build.DEVICE.equals("G10");
|
||||
// endregion
|
||||
|
||||
private DeviceUtils() {
|
||||
@@ -334,7 +344,9 @@ public final class DeviceUtils {
|
||||
&& !BRAVIA_ATV3_4K
|
||||
&& !PH7M_EU_5596
|
||||
&& !TX_50JXW834
|
||||
&& !HMB9213NW;
|
||||
&& !HMB9213NW
|
||||
&& !LONAVLA
|
||||
&& !G10;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -315,7 +315,7 @@ public final class ExtractorHelper {
|
||||
.append(Localization.DOT_SEPARATOR);
|
||||
}
|
||||
|
||||
String content = metaInfo.getContent().getContent().trim();
|
||||
String content = metaInfo.getContent().content().trim();
|
||||
if (content.endsWith(".")) {
|
||||
content = content.substring(0, content.length() - 1); // remove . at end
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package org.schabi.newpipe.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
/**
|
||||
* Utility class for the Android keyboard.
|
||||
* <p>
|
||||
* See also <a href="https://stackoverflow.com/q/1109022">https://stackoverflow.com/q/1109022</a>
|
||||
* </p>
|
||||
*/
|
||||
public final class KeyboardUtil {
|
||||
|
||||
/**
|
||||
* Flag for {@link #hideKeyboard(Activity, EditText)} to force the keyboard to hide.
|
||||
* This is required for non-touch devices like Fire TV where the system
|
||||
* flags the focus as an explicit user action.
|
||||
*/
|
||||
private static final int CLEAR_SOFT_INPUT_FORCED = 0;
|
||||
|
||||
private KeyboardUtil() {
|
||||
}
|
||||
|
||||
public static void showKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editText.requestFocus()) {
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
|
||||
if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) {
|
||||
/*
|
||||
* Sometimes the keyboard can't be shown because Android's ImeFocusController is in
|
||||
* a incorrect state e.g. when animations are disabled or the unfocus event of the
|
||||
* previous view arrives in the wrong moment (see #7647 for details).
|
||||
* The invalid state can be fixed by to re-focusing the editText.
|
||||
*/
|
||||
editText.clearFocus();
|
||||
editText.requestFocus();
|
||||
|
||||
// Try again
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void hideKeyboard(final Activity activity, final EditText editText) {
|
||||
if (activity == null || editText == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final InputMethodManager imm = ContextCompat.getSystemService(activity,
|
||||
InputMethodManager.class);
|
||||
if (imm != null) {
|
||||
imm.hideSoftInputFromWindow(editText.getWindowToken(), CLEAR_SOFT_INPUT_FORCED);
|
||||
}
|
||||
|
||||
editText.clearFocus();
|
||||
}
|
||||
}
|
||||
@@ -123,10 +123,4 @@ object PoTokenProviderImpl : PoTokenProvider {
|
||||
|
||||
return PoTokenResult(visitorData, playerPot, streamingPot)
|
||||
}
|
||||
|
||||
override fun getWebEmbedClientPoToken(videoId: String): PoTokenResult? = null
|
||||
|
||||
override fun getAndroidClientPoToken(videoId: String): PoTokenResult? = null
|
||||
|
||||
override fun getIosClientPoToken(videoId: String): PoTokenResult? = null
|
||||
}
|
||||
@@ -67,17 +67,18 @@ public final class TextLinkifier {
|
||||
@Nullable final String relatedStreamUrl,
|
||||
@NonNull final CompositeDisposable disposables,
|
||||
@Nullable final Consumer<TextView> onCompletion) {
|
||||
switch (description.getType()) {
|
||||
case Description.HTML:
|
||||
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
|
||||
switch (description.type()) {
|
||||
case Description.Type.HTML:
|
||||
TextLinkifier.fromHtml(textView, description.content(), htmlCompatFlag,
|
||||
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||
break;
|
||||
case Description.MARKDOWN:
|
||||
TextLinkifier.fromMarkdown(textView, description.getContent(),
|
||||
case Description.Type.MARKDOWN:
|
||||
TextLinkifier.fromMarkdown(textView, description.content(),
|
||||
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||
break;
|
||||
case Description.PLAIN_TEXT: default:
|
||||
TextLinkifier.fromPlainText(textView, description.getContent(),
|
||||
case Description.Type.PLAIN_TEXT:
|
||||
default:
|
||||
TextLinkifier.fromPlainText(textView, description.content(),
|
||||
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package org.schabi.newpipe.views;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffXfermode;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamSegment;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A {@link FocusAwareSeekBar} that renders narrow transparent gaps at chapter boundaries,
|
||||
* giving the seekbar a segmented "chopped" appearance.
|
||||
* Call {@link #setChapters(List, long)} whenever a new stream loads.
|
||||
*/
|
||||
public final class ChaptersSeekBar extends FocusAwareSeekBar {
|
||||
|
||||
private static final float GAP_WIDTH_DP = 2f;
|
||||
|
||||
private final Paint gapPaint = new Paint();
|
||||
|
||||
@NonNull private List<StreamSegment> chapters = Collections.emptyList();
|
||||
private long durationSeconds = 0;
|
||||
|
||||
public ChaptersSeekBar(@NonNull final Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ChaptersSeekBar(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public ChaptersSeekBar(@NonNull final Context context,
|
||||
@Nullable final AttributeSet attrs,
|
||||
final int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
gapPaint.setStyle(Paint.Style.FILL);
|
||||
gapPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores chapter data for rendering segment gaps.
|
||||
*
|
||||
* @param newChapters list of {@link StreamSegment}s; may be empty but never null
|
||||
* @param newDurationSecs total duration in seconds; used to compute fractional positions
|
||||
*/
|
||||
public void setChapters(@NonNull final List<StreamSegment> newChapters,
|
||||
final long newDurationSecs) {
|
||||
chapters = newChapters;
|
||||
durationSeconds = newDurationSecs;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(@NonNull final Canvas canvas) {
|
||||
if (chapters.isEmpty() || durationSeconds <= 0) {
|
||||
super.onDraw(canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw the seekbar into an offscreen layer so CLEAR mode can punch transparent gaps
|
||||
final int sc = canvas.saveLayer(null, null);
|
||||
super.onDraw(canvas);
|
||||
|
||||
final float density = getResources().getDisplayMetrics().density;
|
||||
final float gapHalfWidth = (GAP_WIDTH_DP * density) / 2f;
|
||||
final int paddingLeft = getPaddingLeft();
|
||||
final float trackWidth = getWidth() - paddingLeft - getPaddingRight();
|
||||
|
||||
if (trackWidth > 0) {
|
||||
for (final StreamSegment seg : chapters) {
|
||||
final int startSec = seg.getStartTimeSeconds();
|
||||
// Skip the very first position and anything at or past the end
|
||||
if (startSec <= 0 || startSec >= durationSeconds) {
|
||||
continue;
|
||||
}
|
||||
final float x = paddingLeft + (startSec / (float) durationSeconds) * trackWidth;
|
||||
canvas.drawRect(x - gapHalfWidth, 0, x + gapHalfWidth, getHeight(), gapPaint);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.restoreToCount(sc);
|
||||
|
||||
// Redraw the thumb on top so it visually overlaps the gaps
|
||||
final Drawable thumb = getThumb();
|
||||
if (thumb != null) {
|
||||
final int thumbSave = canvas.save();
|
||||
canvas.translate(getPaddingLeft() - getThumbOffset(), getPaddingTop());
|
||||
thumb.draw(canvas);
|
||||
canvas.restoreToCount(thumbSave);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import org.schabi.newpipe.util.DeviceUtils;
|
||||
* (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to
|
||||
* work with it.
|
||||
*/
|
||||
public final class FocusAwareSeekBar extends AppCompatSeekBar {
|
||||
public class FocusAwareSeekBar extends AppCompatSeekBar {
|
||||
private NestedListener listener;
|
||||
|
||||
private ViewTreeObserver treeObserver;
|
||||
|
||||
@@ -16,6 +16,7 @@ import java.io.InterruptedIOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.nio.channels.ClosedByInterruptException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import us.shandian.giga.get.DownloadMission.HttpError;
|
||||
|
||||
@@ -135,6 +136,7 @@ public class DownloadMissionRecover extends Thread {
|
||||
for (final AudioStream audio : mExtractor.getAudioStreams()) {
|
||||
if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate()
|
||||
&& audio.getFormat() == mRecovery.getFormat()
|
||||
&& Objects.equals(audio.getAudioTrackId(), mRecovery.getDesired())
|
||||
&& audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) {
|
||||
url = audio.getContent();
|
||||
break;
|
||||
|
||||
Loaded 100 of 446 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user