Compare commits
117 Commits
master
...
volodymyr/
Author | SHA1 | Date | |
---|---|---|---|
47151c7119 | |||
56c129aa4f | |||
68cbe3c4b1 | |||
81e864c0e1 | |||
0e9ac4d125 | |||
9191a06600 | |||
dc61c3efea | |||
11f480f206 | |||
9619e7517b | |||
3ddc6e931a | |||
6b5831dc1c | |||
3fc002f3fd | |||
edb5c6bcfa | |||
5bc975b2c9 | |||
d79b5c9a83 | |||
|
a8d5841c7c | ||
|
352dfbbc9b | ||
|
78f4f0480d | ||
|
d86adc52f3 | ||
|
9a3fcda3b8 | ||
|
4c9db2845b | ||
|
2c19a4770d | ||
|
d1883a7e00 | ||
|
d81e6ea31e | ||
|
74fb44ddcf | ||
|
0820f8167f | ||
|
40872f5ea7 | ||
|
149924ffcb | ||
|
82dc4cf3a0 | ||
|
279cc0e5ed | ||
|
3ecf324bb3 | ||
|
0c6b47f42c | ||
|
b11f05f175 | ||
|
724b32b434 | ||
|
c81eea54d8 | ||
|
ae82c83eef | ||
|
17dc2c064f | ||
|
0e4c95def9 | ||
|
c96f7d41f3 | ||
|
6fedca0df7 | ||
|
892efdd3ab | ||
|
7d43d5d3da | ||
|
41d3da9146 | ||
|
835186a321 | ||
|
7f6b500c82 | ||
|
1ef2b3a977 | ||
|
0538b3b468 | ||
|
e57c7bda5d | ||
|
24d90e9ec8 | ||
|
b74cb59602 | ||
|
84a27f3d9f | ||
|
7118ba6819 | ||
|
2c1fc964bf | ||
|
b2fd8d62a1 | ||
|
809a730198 | ||
|
e18769ab3a | ||
|
4a2beaa147 | ||
|
bee4123402 | ||
|
b871d937a3 | ||
|
b66d2fe146 | ||
|
22c21ad249 | ||
|
9707081ab9 | ||
|
d6bae3cd07 | ||
|
c51c061f43 | ||
|
8b8ebe9410 | ||
|
308447a5ba | ||
|
89df9d69ff | ||
|
fbe570d62f | ||
|
2fa6c43615 | ||
|
688d98d68f | ||
|
3a32d67087 | ||
|
7a2b4014f4 | ||
|
fb3c0da6af | ||
|
451806c547 | ||
|
703ed43996 | ||
|
9c38d9f4ef | ||
|
0576eacfdd | ||
|
24c99f03b9 | ||
|
ffa5044e23 | ||
|
7db7024cb3 | ||
|
ca795f298a | ||
|
65faba312d | ||
|
b05201a9fa | ||
|
0a1085ce03 | ||
|
41e2bed6b3 | ||
|
4611284247 | ||
|
1b691f8e81 | ||
|
7e222e8fc4 | ||
|
736594ed23 | ||
|
b7d1cabf72 | ||
|
c6ae17e41d | ||
|
cd41a1b234 | ||
|
899bb822a5 | ||
|
6c03d0a700 | ||
|
6768c22139 | ||
|
2b369df57d | ||
|
8542c8f7d1 | ||
|
fc5b2d4563 | ||
|
ffb4631854 | ||
|
29cf7c97c3 | ||
|
491ed77a32 | ||
|
5b199b52b4 | ||
|
9d19157654 | ||
|
3dabf5f16f | ||
|
e610a274d5 | ||
|
27880f5212 | ||
|
39dd30b762 | ||
|
edf5d0c613 | ||
|
975fc2f303 | ||
|
aa85d71b87 | ||
|
cce24cd829 | ||
|
cad63d465d | ||
|
f5fa063bc0 | ||
|
c6abcdeb2f | ||
|
a72ab331dc | ||
|
fa126de97f | ||
|
ca2452edb6 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -74,7 +74,7 @@ body:
|
||||
- type: input
|
||||
id: reproduction-repo
|
||||
attributes:
|
||||
label: Reproduction
|
||||
label: Reproduction Link
|
||||
description: Provide a link to a repository with a reproduction of the bug, this is optional but it will make us to fix the bug faster
|
||||
placeholder: Reproduction Repository
|
||||
value: "repository link"
|
||||
|
7
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
7
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -49,4 +49,11 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Support
|
||||
|
||||
If this functionality is important to you and you need it, contact [TheWidlarzGroup](https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=feature-request#Contact) - [`hi@thewidlarzgroup.com`](mailto:hi@thewidlarzgroup.com)
|
||||
|
||||
|
293
.github/scripts/validate.js
vendored
Normal file
293
.github/scripts/validate.js
vendored
Normal file
@ -0,0 +1,293 @@
|
||||
const FIELD_MAPPINGS = {
|
||||
Platform: 'What platforms are you having the problem on?',
|
||||
Version: 'Version',
|
||||
SystemVersion: 'System Version',
|
||||
DeviceType: 'On what device are you experiencing the issue?',
|
||||
Architecture: 'Architecture',
|
||||
Description: 'What happened?',
|
||||
ReproductionLink: 'Reproduction Link',
|
||||
Reproduction: 'Reproduction',
|
||||
};
|
||||
|
||||
const PLATFORM_LABELS = {
|
||||
iOS: 'Platform: iOS',
|
||||
visionOS: 'Platform: iOS',
|
||||
'Apple tvOS': 'Platform: iOS',
|
||||
Android: 'Platform: Android',
|
||||
'Android TV': 'Platform: Android',
|
||||
Windows: 'Platform: Windows',
|
||||
web: 'Platform: Web',
|
||||
};
|
||||
|
||||
const BOT_LABELS = [
|
||||
'Missing Info',
|
||||
'Repro Provided',
|
||||
'Missing Repro',
|
||||
'Waiting for Review',
|
||||
'Newer Version Available',
|
||||
...Object.values(PLATFORM_LABELS),
|
||||
];
|
||||
|
||||
const SKIP_LABEL = 'No Validation';
|
||||
|
||||
const MESSAGE = {
|
||||
FEATURE_REQUEST: `Thank you for your feature request. We will review it and get back to you if we need more information.`,
|
||||
BUG_REPORT: `Thank you for your bug report. We will review it and get back to you if we need more information.`,
|
||||
MISSING_INFO: (missingFields) => {
|
||||
return `Thank you for your issue report. Please note that the following information is missing or incomplete:\n\n${missingFields
|
||||
.map((field) => `- ${field.replace('missing-', '')}`)
|
||||
.join(
|
||||
'\n',
|
||||
)}\n\nPlease update your issue with this information to help us address it more effectively.
|
||||
\n > Note: issues without complete information have a lower priority`;
|
||||
},
|
||||
OUTDATED_VERSION: (issueVersion, latestVersion) => {
|
||||
return (
|
||||
`There is a newer version of the library available. ` +
|
||||
`You are using version ${issueVersion}, while the latest stable version is ${latestVersion}. ` +
|
||||
`Please update to the latest version and check if the issue still exists.` +
|
||||
`\n > Note: If the issue still exists, please update the issue report with the latest information.`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const checkLatestVersion = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://registry.npmjs.org/react-native-video/latest',
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.version;
|
||||
} catch (error) {
|
||||
console.error('Error checking latest version:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldValue = (body, field) => {
|
||||
if (!FIELD_MAPPINGS[field]) {
|
||||
console.warn('Field not supported:', field);
|
||||
return '';
|
||||
}
|
||||
|
||||
const fieldValue = FIELD_MAPPINGS[field];
|
||||
|
||||
const sections = body.split('###');
|
||||
const section = sections.find((section) => {
|
||||
// Find the section that contains the field
|
||||
// For Reproduction, we need to make sure that we don't match Reproduction Link
|
||||
if (field === 'Reproduction') {
|
||||
return (
|
||||
section.includes(fieldValue) && !section.includes('Reproduction Link')
|
||||
);
|
||||
}
|
||||
|
||||
return section.includes(fieldValue);
|
||||
});
|
||||
|
||||
return section ? section.replace(fieldValue, '').trim() : '';
|
||||
};
|
||||
|
||||
const validateBugReport = async (body, labels) => {
|
||||
const selectedPlatforms = getFieldValue(body, 'Platform')
|
||||
.split(',')
|
||||
.map((p) => p.trim());
|
||||
|
||||
if (selectedPlatforms.length === 0) {
|
||||
labels.add('missing-platform');
|
||||
} else {
|
||||
selectedPlatforms.forEach((platform) => {
|
||||
const label = PLATFORM_LABELS[platform];
|
||||
if (label) {
|
||||
labels.add(label);
|
||||
} else {
|
||||
console.warn('Platform not supported', platform);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const version = getFieldValue(body, 'Version');
|
||||
if (version) {
|
||||
const words = version.split(' ');
|
||||
const versionPattern = /\d+\.\d+\.\d+/;
|
||||
const isVersionValid = words.some((word) => versionPattern.test(word));
|
||||
|
||||
if (!isVersionValid) {
|
||||
labels.add('missing-version');
|
||||
}
|
||||
|
||||
const latestVersion = await checkLatestVersion();
|
||||
if (latestVersion && latestVersion !== version) {
|
||||
labels.add(`outdated-version-${version}-${latestVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'SystemVersion',
|
||||
invalidValue:
|
||||
'What version of the system is using device that you are experiencing the issue?',
|
||||
},
|
||||
{name: 'DeviceType'},
|
||||
{name: 'Architecture'},
|
||||
{name: 'Description', invalidValue: 'A bug happened!'},
|
||||
{name: 'Reproduction', invalidValue: 'Step to reproduce this bug are:'},
|
||||
{name: 'ReproductionLink', invalidValue: 'repository link'},
|
||||
];
|
||||
|
||||
fields.forEach(({name, invalidValue}) => {
|
||||
const value = getFieldValue(body, name);
|
||||
if (!value || value === invalidValue) {
|
||||
const fieldName = FIELD_MAPPINGS[name];
|
||||
labels.add(`missing-${fieldName.toLowerCase()}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const validateFeatureRequest = (body, labels) => {
|
||||
// Implement feature request validation logic here
|
||||
};
|
||||
|
||||
const handleIssue = async ({github, context}) => {
|
||||
const {issue} = context.payload;
|
||||
const {body} = issue;
|
||||
const labels = new Set(issue.labels.map((label) => label.name));
|
||||
|
||||
if (labels.has(SKIP_LABEL)) {
|
||||
console.log('Skiping Issue Validation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear out labels that are added by the bot
|
||||
BOT_LABELS.forEach((label) => labels.delete(label));
|
||||
|
||||
const isBug = labels.has('bug');
|
||||
const isFeature = labels.has('feature');
|
||||
|
||||
if (isFeature) {
|
||||
await handleFeatureRequest({github, context, body, labels});
|
||||
} else if (isBug) {
|
||||
await handleBugReport({github, context, body, labels});
|
||||
} else {
|
||||
console.warn('Issue is not a bug or feature request');
|
||||
}
|
||||
|
||||
await updateIssueLabels({github, context, labels});
|
||||
};
|
||||
|
||||
const handleFeatureRequest = async ({github, context, body, labels}) => {
|
||||
validateFeatureRequest(body, labels);
|
||||
|
||||
const comment = MESSAGE.FEATURE_REQUEST;
|
||||
await createComment({github, context, body: comment});
|
||||
};
|
||||
|
||||
const handleBugReport = async ({github, context, body, labels}) => {
|
||||
await validateBugReport(body, labels);
|
||||
|
||||
if (Array.from(labels).some((label) => label.startsWith('missing-'))) {
|
||||
await handleMissingInformation({github, context, labels});
|
||||
} else {
|
||||
await handleValidReport({github, context, labels});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMissingInformation = async ({github, context, labels}) => {
|
||||
const missingFields = Array.from(labels).filter((label) =>
|
||||
label.startsWith('missing-'),
|
||||
);
|
||||
|
||||
const outdatedVersionLabel = Array.from(labels).find((label) =>
|
||||
label.startsWith('outdated-version'),
|
||||
);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
let comment = MESSAGE.MISSING_INFO(missingFields);
|
||||
|
||||
if (outdatedVersionLabel) {
|
||||
const [, , issueVersion, latestVersion] = outdatedVersionLabel.split('-');
|
||||
comment += `\n\n ${MESSAGE.OUTDATED_VERSION(
|
||||
issueVersion,
|
||||
latestVersion,
|
||||
)}`;
|
||||
}
|
||||
|
||||
await hidePreviousComments({github, context});
|
||||
await createComment({github, context, body: comment});
|
||||
}
|
||||
|
||||
updateLabelsForMissingInfo(labels);
|
||||
};
|
||||
|
||||
const handleValidReport = async ({github, context, labels}) => {
|
||||
let comment = MESSAGE.BUG_REPORT;
|
||||
|
||||
const outdatedVersionLabel = Array.from(labels).find((label) =>
|
||||
label.startsWith('outdated-version'),
|
||||
);
|
||||
|
||||
if (outdatedVersionLabel) {
|
||||
const [, , issueVersion, latestVersion] = outdatedVersionLabel.split('-');
|
||||
comment += `\n\n ${MESSAGE.OUTDATED_VERSION(issueVersion, latestVersion)}`;
|
||||
labels.add('Newer Version Available');
|
||||
}
|
||||
|
||||
await hidePreviousComments({github, context});
|
||||
await createComment({github, context, body: comment});
|
||||
labels.add('Repro Provided');
|
||||
labels.add('Waiting for Review');
|
||||
};
|
||||
|
||||
const createComment = async ({github, context, body}) => {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
const updateIssueLabels = async ({github, context, labels}) => {
|
||||
const labelsToAdd = Array.from(labels).filter(
|
||||
(label) => !label.startsWith('missing-') && !label.startsWith('outdated-'),
|
||||
);
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
};
|
||||
|
||||
const hidePreviousComments = async ({github, context}) => {
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
});
|
||||
|
||||
const botComments = comments.data.filter(
|
||||
(comment) => comment.user.type === 'Bot',
|
||||
);
|
||||
|
||||
for (const comment of botComments) {
|
||||
// Don't format string - it will broke the markdown
|
||||
const hiddenBody = `
|
||||
<details>
|
||||
<summary>Previous bot comment (click to expand)</summary>
|
||||
|
||||
${comment.body}
|
||||
|
||||
</details>`;
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: comment.id,
|
||||
body: hiddenBody,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = handleIssue;
|
60
.github/stale.yml
vendored
60
.github/stale.yml
vendored
@ -1,60 +0,0 @@
|
||||
# Configuration for probot-stale - https://github.com/probot/stale
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 3
|
||||
|
||||
# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
|
||||
onlyLabels: []
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
|
||||
# Set to true to ignore issues in a project (defaults to false)
|
||||
exemptProjects: true
|
||||
|
||||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Set to true to ignore issues with an assignee (defaults to false)
|
||||
exemptAssignees: true
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions. If you are having a similar problem, please open a
|
||||
new issue and reference this one instead of commenting on a stale or closed
|
||||
issue.
|
||||
|
||||
# Comment to post when removing the stale label.
|
||||
unmarkComment: false
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: false
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 50
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
only: issues
|
||||
|
||||
# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls':
|
||||
# pulls:
|
||||
# daysUntilStale: 30
|
||||
# markComment: >
|
||||
# This pull request has been automatically marked as stale because it has not had
|
||||
# recent activity. It will be closed if no further activity occurs. Thank you
|
||||
# for your contributions.
|
||||
|
||||
# issues:
|
||||
# exemptLabels:
|
||||
# - confirmed
|
24
.github/workflows/stale.yml
vendored
Normal file
24
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity. If there won't be any activity in the next 14 days, this issue will be closed automatically."
|
||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "feature,Accepted,good first issue"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
19
.github/workflows/validate-issue.yml
vendored
Normal file
19
.github/workflows/validate-issue.yml
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
name: Issue Validator and Labeler
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
jobs:
|
||||
validate-and-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Validate Issue Template and Add Labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const script = require('./.github/scripts/validate.js')
|
||||
await script({github, context})
|
102
CHANGELOG.md
102
CHANGELOG.md
@ -1,5 +1,107 @@
|
||||
|
||||
|
||||
## [6.6.4](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.3...v6.6.4) (2024-10-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **android:** add live video label configuration ([#4190](https://github.com/TheWidlarzGroup/react-native-video/issues/4190)) ([149924f](https://github.com/TheWidlarzGroup/react-native-video/commit/149924ffcb0cbdeaa8c671ebb4b3b6115920131a))
|
||||
|
||||
## [6.6.3](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.2...v6.6.3) (2024-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** bad rotation handling ([#4205](https://github.com/TheWidlarzGroup/react-native-video/issues/4205)) ([3ecf324](https://github.com/TheWidlarzGroup/react-native-video/commit/3ecf324bb30208ab8efbf00958ebd4590ddf8d39))
|
||||
* **docs:** invalid URLs in updating section ([#4201](https://github.com/TheWidlarzGroup/react-native-video/issues/4201)) ([c81eea5](https://github.com/TheWidlarzGroup/react-native-video/commit/c81eea54d8291c5131fd59a93f198e0fd5f3673c))
|
||||
* **ios:** Add safety checks and remove some of the ! in types declaration ([#4182](https://github.com/TheWidlarzGroup/react-native-video/issues/4182)) ([ae82c83](https://github.com/TheWidlarzGroup/react-native-video/commit/ae82c83eef2fc7c383fd844c7471613e4ac1c7ee))
|
||||
* **tvos:** typo ([#4204](https://github.com/TheWidlarzGroup/react-native-video/issues/4204)) ([b11f05f](https://github.com/TheWidlarzGroup/react-native-video/commit/b11f05f1753a4cb963b94d1e1d8d1f6c37af2a9d))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **android:** allow to hide specific controls ([#4183](https://github.com/TheWidlarzGroup/react-native-video/issues/4183)) ([279cc0e](https://github.com/TheWidlarzGroup/react-native-video/commit/279cc0e5ed712488fc3c153c62b14f13048103f2))
|
||||
|
||||
## [6.6.2](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.1...v6.6.2) (2024-09-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **iOS:** rewrite DRM Module ([#4136](https://github.com/TheWidlarzGroup/react-native-video/issues/4136)) ([0e4c95d](https://github.com/TheWidlarzGroup/react-native-video/commit/0e4c95def968a4091fdd18d07215ba592eec99cb))
|
||||
|
||||
## [6.6.1](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.6.0...v6.6.1) (2024-09-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ios:** fix side loaded text track management ([#4180](https://github.com/TheWidlarzGroup/react-native-video/issues/4180)) ([7d43d5d](https://github.com/TheWidlarzGroup/react-native-video/commit/7d43d5d3da72495e94468756be21442f96cc7a89))
|
||||
|
||||
# [6.6.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.5.0...v6.6.0) (2024-09-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** ensure maxbitrate & selectedVideoTrack interact correctly ([#4155](https://github.com/TheWidlarzGroup/react-native-video/issues/4155)) ([7f6b500](https://github.com/TheWidlarzGroup/react-native-video/commit/7f6b500c82122325c326b6dcacaf7af8039b2b33))
|
||||
* **android:** ensure pause is well tken in account after onEnd ([#4147](https://github.com/TheWidlarzGroup/react-native-video/issues/4147)) ([b2fd8d6](https://github.com/TheWidlarzGroup/react-native-video/commit/b2fd8d62a10ee64e6208b43120ca9231008309c2))
|
||||
* **expo-plugin:** add check for existing service in AndroidManifest for notification controls ([#4172](https://github.com/TheWidlarzGroup/react-native-video/issues/4172)) ([0538b3b](https://github.com/TheWidlarzGroup/react-native-video/commit/0538b3b46801a535c76cf52db28cee76f2aeb0c5))
|
||||
* **ios:** ensure onBandwidthUpdate is reported only when value change ([#4149](https://github.com/TheWidlarzGroup/react-native-video/issues/4149)) ([809a730](https://github.com/TheWidlarzGroup/react-native-video/commit/809a73019836f95385891c2bba5c72b0610ffcb1))
|
||||
* **ios:** losing subtitle selection on foreground ([#3707](https://github.com/TheWidlarzGroup/react-native-video/issues/3707)) ([bee4123](https://github.com/TheWidlarzGroup/react-native-video/commit/bee4123402f4bc08dd2eb19ab0011ffdc795d0e3))
|
||||
* **JS:** improve loader api to allow function call instead of component ([#4171](https://github.com/TheWidlarzGroup/react-native-video/issues/4171)) ([835186a](https://github.com/TheWidlarzGroup/react-native-video/commit/835186a321e1940932a045a59e26e43a040fa334))
|
||||
* refactor side loaded text tracks management ([#4158](https://github.com/TheWidlarzGroup/react-native-video/issues/4158)) ([84a27f3](https://github.com/TheWidlarzGroup/react-native-video/commit/84a27f3d9f90624af3c5c3cbff50d754bab9baa4))
|
||||
* **sample:** remove warning on ios with NavigationBar ([#4148](https://github.com/TheWidlarzGroup/react-native-video/issues/4148)) ([e18769a](https://github.com/TheWidlarzGroup/react-native-video/commit/e18769ab3a6a7f4ebc459ab550f105f4d18f8201))
|
||||
* **visionOS:** remove unsupported apis ([#4154](https://github.com/TheWidlarzGroup/react-native-video/issues/4154)) ([2c1fc96](https://github.com/TheWidlarzGroup/react-native-video/commit/2c1fc964bf2cb97624c8cc37ff8138465619fc61))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **android:** upgrade dependencies / media3 1.4.1 / androidxCore to 1.13.1 / androidxActivity 1.8.2 ([#4173](https://github.com/TheWidlarzGroup/react-native-video/issues/4173)) ([e57c7bd](https://github.com/TheWidlarzGroup/react-native-video/commit/e57c7bda5df7d624d90b20620859b8a4eb3f76b7))
|
||||
|
||||
# [6.5.0](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.5...v6.5.0) (2024-09-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** show the status bar and navigation bar after exiting full-screen mode ([#4112](https://github.com/TheWidlarzGroup/react-native-video/issues/4112)) ([8b8ebe9](https://github.com/TheWidlarzGroup/react-native-video/commit/8b8ebe9410e95085e5602393c2ce3de814df4a96))
|
||||
* **android:** add subtitleStyle.subtitlesFollowVideo prop to control subtitles positionning ([#4133](https://github.com/TheWidlarzGroup/react-native-video/issues/4133)) ([2fa6c43](https://github.com/TheWidlarzGroup/react-native-video/commit/2fa6c43615c1bc0a3bbcb5f472ffaeb8ae16a1af))
|
||||
* **android:** hide surfaceView for loading time when shutter is hidden ([#4060](https://github.com/TheWidlarzGroup/react-native-video/issues/4060)) ([65faba3](https://github.com/TheWidlarzGroup/react-native-video/commit/65faba312d23de981972d2b6ffecefbc87ecac61))
|
||||
* **expo-plugin:** adding bg mode if none exist yet ([#4126](https://github.com/TheWidlarzGroup/react-native-video/issues/4126)) ([451806c](https://github.com/TheWidlarzGroup/react-native-video/commit/451806c547591fbe5714b133e704ffac9efb05d8))
|
||||
* **ios:** Add handler for Earpods play/pause command ([#4116](https://github.com/TheWidlarzGroup/react-native-video/issues/4116)) ([9c38d9f](https://github.com/TheWidlarzGroup/react-native-video/commit/9c38d9f4ef42c3e275ee39a08aa227e6b976fdb2))
|
||||
* **ios:** build fail due to an unwrapped value ([#4101](https://github.com/TheWidlarzGroup/react-native-video/issues/4101)) ([0a1085c](https://github.com/TheWidlarzGroup/react-native-video/commit/0a1085ce03152d58d98da408dbe79e76fa5ebc1a))
|
||||
* **ios:** ensure behavior is correct with empty text track list ([#4123](https://github.com/TheWidlarzGroup/react-native-video/issues/4123)) ([3a32d67](https://github.com/TheWidlarzGroup/react-native-video/commit/3a32d67087c39bcf7904043d15a2fdba65307f4e))
|
||||
* **ios:** ensure we don't disable tracks when not necessary (causes black screen) ([#4130](https://github.com/TheWidlarzGroup/react-native-video/issues/4130)) ([89df9d6](https://github.com/TheWidlarzGroup/react-native-video/commit/89df9d69ff96f7d6ff3d493bf1a3eb9c3da51c3c))
|
||||
* **ios:** fix onBandwidth update event (old ios api is deprecated and doens't work) ([#4140](https://github.com/TheWidlarzGroup/react-native-video/issues/4140)) ([d6bae3c](https://github.com/TheWidlarzGroup/react-native-video/commit/d6bae3cd076018f07556ab27af2779479bc7ff7d))
|
||||
* **sample:** update dependencies to fix local asset playback ([#4121](https://github.com/TheWidlarzGroup/react-native-video/issues/4121)) ([7a2b401](https://github.com/TheWidlarzGroup/react-native-video/commit/7a2b4014f40758a025fcd6b388448d3559ec6a4a))
|
||||
* set does not have `find` method ([#4110](https://github.com/TheWidlarzGroup/react-native-video/issues/4110)) ([7db7024](https://github.com/TheWidlarzGroup/react-native-video/commit/7db7024cb36ea34289fddf5c7f66e7b4d7827146))
|
||||
* **tvos:** fix build (and update sample) ([#4134](https://github.com/TheWidlarzGroup/react-native-video/issues/4134)) ([688d98d](https://github.com/TheWidlarzGroup/react-native-video/commit/688d98d68f888a59bde1ee33aa844ac63c9026a5))
|
||||
* **VisionOS:** do not access to isExternalPlaybackActive on VisionOS ([#4109](https://github.com/TheWidlarzGroup/react-native-video/issues/4109)) ([0576eac](https://github.com/TheWidlarzGroup/react-native-video/commit/0576eacfddb32c4dcc072b6fd3cbf74cf25946a4))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ads localize ([#4113](https://github.com/TheWidlarzGroup/react-native-video/issues/4113)) ([703ed43](https://github.com/TheWidlarzGroup/react-native-video/commit/703ed4399667e0142704d19686563dd62fb4883d))
|
||||
* **android:** Support Common Media Client Data (CMCD) ([#4034](https://github.com/TheWidlarzGroup/react-native-video/issues/4034)) ([ca795f2](https://github.com/TheWidlarzGroup/react-native-video/commit/ca795f298a99a183b81561ef7e09d8d1e8addaf5))
|
||||
* **android:** support hiding Exoplayer video duration on android ([#4090](https://github.com/TheWidlarzGroup/react-native-video/issues/4090)) ([41e2bed](https://github.com/TheWidlarzGroup/react-native-video/commit/41e2bed6b36f74a28d7dd640414c6d5ccbec0399))
|
||||
* Correct isBehindLiveWindow Error Handling ([#4143](https://github.com/TheWidlarzGroup/react-native-video/issues/4143)) ([22c21ad](https://github.com/TheWidlarzGroup/react-native-video/commit/22c21ad249879fe4ff8fb119384ebc82766106c3))
|
||||
|
||||
## [6.4.5](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.4...v6.4.5) (2024-08-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **android:** resolve a release issue with DefaultDashChunkSource ([#4097](https://github.com/TheWidlarzGroup/react-native-video/issues/4097)) ([7e222e8](https://github.com/TheWidlarzGroup/react-native-video/commit/7e222e8fc4f3c47a1c9cd2fbf5ff012bcbe98a7f))
|
||||
|
||||
* refactor(android): migrate DefaultDashChunkSource to Kotlin (#4078) (b7d1cabf)
|
||||
* fix(ios): remove resume logic in notification seek closure (#4068) (c6ae17e4)
|
||||
* chore(doc): update document (props & method) (#4072) (cd41a1b2)
|
||||
* fix(android): build warnings (#4058) (899bb822)
|
||||
* infra: update feature request form (#4065) (6c03d0a7)
|
||||
* fix(ios): override source metadata with custom metadata (#4050) (38aa2b05)
|
||||
* fix(android): return the value as a float for the getCurrentPosition function (#4054) (af0302b1)
|
||||
* refactor(android): migrate ReactExoplayerViewManager to Kotlin (#4011) (74c6dd62)
|
||||
* fix(android): viewType is ignored when its value is ViewType.TEXTURE (#4031) (22cfd6ce)
|
||||
* fix(ios): metadata update race (#4033) (08a57a3b)
|
||||
* fix(ios): updated getLicense call to work with new syntax, and fixed spelling error (#4014) (#4042) (2348c5e4)
|
||||
|
||||
## [6.4.3](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.2...v6.4.3) (2024-07-24)
|
||||
|
||||
|
||||
|
@ -51,9 +51,9 @@ We have an discord server where you can ask questions and get help. [Join the di
|
||||
|
||||
## Enterprise Support
|
||||
<p>
|
||||
📱 <i>react-native-video</i> is provided <i>as it is</i>. For enterprise support or other business inquiries, <a href="https://www.thewidlarzgroup.com/">please contact us 🤝</a>. We can help you with the integration, customization and maintenance. We are providing both free and commercial support for this project. let's build something awesome together! 🚀
|
||||
📱 <i>react-native-video</i> is provided <i>as it is</i>. For enterprise support or other business inquiries, <a href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=readme#Contact">please contact us 🤝</a>. We can help you with the integration, customization and maintenance. We are providing both free and commercial support for this project. let's build something awesome together! 🚀
|
||||
</p>
|
||||
<a href="https://www.thewidlarzgroup.com/">
|
||||
<a href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=readme">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/baners/twg-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./docs/assets/baners/twg-light.png" />
|
||||
|
@ -216,7 +216,7 @@ dependencies {
|
||||
//noinspection GradleDynamicVersion
|
||||
implementation "com.facebook.react:react-native:+"
|
||||
|
||||
implementation "androidx.core:core:$androidxCore_version"
|
||||
implementation "androidx.core:core-ktx:$androidxCore_version"
|
||||
implementation "androidx.activity:activity-ktx:$androidxActivity_version"
|
||||
|
||||
// For media playback using ExoPlayer
|
||||
|
@ -4,12 +4,12 @@ RNVideo_targetSdkVersion=34
|
||||
RNVideo_compileSdkVersion=34
|
||||
RNVideo_ndkversion=26.1.10909125
|
||||
RNVideo_buildToolsVersion=34.0.0
|
||||
RNVideo_media3Version=1.3.1
|
||||
RNVideo_media3Version=1.4.1
|
||||
RNVideo_useExoplayerIMA=false
|
||||
RNVideo_useExoplayerRtsp=false
|
||||
RNVideo_useExoplayerSmoothStreaming=true
|
||||
RNVideo_useExoplayerDash=true
|
||||
RNVideo_useExoplayerHls=true
|
||||
RNVideo_androidxCoreVersion=1.9.0
|
||||
RNVideo_androidxActivityVersion=1.7.0
|
||||
RNVideo_androidxCoreVersion=1.13.1
|
||||
RNVideo_androidxActivityVersion=1.8.2
|
||||
RNVideo_buildFromMedia3Source=false
|
||||
|
@ -11,9 +11,17 @@ import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.source.ads.AdsLoader;
|
||||
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
|
||||
|
||||
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ImaAdsLoader implements AdsLoader {
|
||||
private final ImaSdkSettings imaSdkSettings;
|
||||
|
||||
public ImaAdsLoader(ImaSdkSettings imaSdkSettings) {
|
||||
this.imaSdkSettings = imaSdkSettings;
|
||||
}
|
||||
|
||||
public void setPlayer(ExoPlayer ignoredPlayer) {
|
||||
}
|
||||
|
||||
@ -45,6 +53,7 @@ public class ImaAdsLoader implements AdsLoader {
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private ImaSdkSettings imaSdkSettings;
|
||||
public Builder(Context ignoredThemedReactContext) {
|
||||
}
|
||||
|
||||
@ -56,6 +65,11 @@ public class ImaAdsLoader implements AdsLoader {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder setImaSdkSettings(ImaSdkSettings imaSdkSettings) {
|
||||
this.imaSdkSettings = imaSdkSettings;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ImaAdsLoader build() {
|
||||
return null;
|
||||
}
|
||||
|
46
android/src/main/java/com/brentvatne/common/api/AdsProps.kt
Normal file
46
android/src/main/java/com/brentvatne/common/api/AdsProps.kt
Normal file
@ -0,0 +1,46 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
class AdsProps {
|
||||
var adTagUrl: Uri? = null
|
||||
var adLanguage: String? = null
|
||||
|
||||
/** return true if this and src are equals */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is AdsProps) return false
|
||||
return (
|
||||
adTagUrl == other.adTagUrl &&
|
||||
adLanguage == other.adLanguage
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PROP_AD_TAG_URL = "adTagUrl"
|
||||
private const val PROP_AD_LANGUAGE = "adLanguage"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): AdsProps {
|
||||
val adsProps = AdsProps()
|
||||
DebugLog.w("olivier", "uri: parse AdsProps")
|
||||
|
||||
if (src != null) {
|
||||
val uriString = ReactBridgeUtils.safeGetString(src, PROP_AD_TAG_URL)
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
adsProps.adTagUrl = null
|
||||
} else {
|
||||
adsProps.adTagUrl = Uri.parse(uriString)
|
||||
}
|
||||
val languageString = ReactBridgeUtils.safeGetString(src, PROP_AD_LANGUAGE)
|
||||
if (!TextUtils.isEmpty(languageString)) {
|
||||
adsProps.adLanguage = languageString
|
||||
}
|
||||
}
|
||||
return adsProps
|
||||
}
|
||||
}
|
||||
}
|
51
android/src/main/java/com/brentvatne/common/api/CMCDProps.kt
Normal file
51
android/src/main/java/com/brentvatne/common/api/CMCDProps.kt
Normal file
@ -0,0 +1,51 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.ReadableType
|
||||
|
||||
data class CMCDProps(
|
||||
val cmcdObject: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdRequest: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdSession: List<Pair<String, Any>> = emptyList(),
|
||||
val cmcdStatus: List<Pair<String, Any>> = emptyList(),
|
||||
val mode: Int = 1
|
||||
) {
|
||||
companion object {
|
||||
private const val PROP_CMCD_OBJECT = "object"
|
||||
private const val PROP_CMCD_REQUEST = "request"
|
||||
private const val PROP_CMCD_SESSION = "session"
|
||||
private const val PROP_CMCD_STATUS = "status"
|
||||
private const val PROP_CMCD_MODE = "mode"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): CMCDProps? {
|
||||
if (src == null) return null
|
||||
|
||||
return CMCDProps(
|
||||
cmcdObject = parseKeyValuePairs(src.getArray(PROP_CMCD_OBJECT)),
|
||||
cmcdRequest = parseKeyValuePairs(src.getArray(PROP_CMCD_REQUEST)),
|
||||
cmcdSession = parseKeyValuePairs(src.getArray(PROP_CMCD_SESSION)),
|
||||
cmcdStatus = parseKeyValuePairs(src.getArray(PROP_CMCD_STATUS)),
|
||||
mode = safeGetInt(src, PROP_CMCD_MODE, 1)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseKeyValuePairs(array: ReadableArray?): List<Pair<String, Any>> {
|
||||
if (array == null) return emptyList()
|
||||
|
||||
return (0 until array.size()).mapNotNull { i ->
|
||||
val item = array.getMap(i)
|
||||
val key = item?.getString("key")
|
||||
val value = when (item?.getType("value")) {
|
||||
ReadableType.Number -> item.getDouble("value")
|
||||
ReadableType.String -> item.getString("value")
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (key != null && value != null) Pair(key, value) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -5,18 +5,42 @@ import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
class ControlsConfig {
|
||||
var hideSeekBar: Boolean = false
|
||||
var hideDuration: Boolean = false
|
||||
var hidePosition: Boolean = false
|
||||
var hidePlayPause: Boolean = false
|
||||
var hideForward: Boolean = false
|
||||
var hideRewind: Boolean = false
|
||||
var hideNext: Boolean = false
|
||||
var hidePrevious: Boolean = false
|
||||
var hideFullscreen: Boolean = false
|
||||
var hideNavigationBarOnFullScreenMode: Boolean = true
|
||||
var hideNotificationBarOnFullScreenMode: Boolean = true
|
||||
var liveLabel: String? = null
|
||||
var hideSettingButton: Boolean = true
|
||||
|
||||
var seekIncrementMS: Int = 10000
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): ControlsConfig {
|
||||
fun parse(controlsConfig: ReadableMap?): ControlsConfig {
|
||||
val config = ControlsConfig()
|
||||
|
||||
if (src != null) {
|
||||
config.hideSeekBar = ReactBridgeUtils.safeGetBool(src, "hideSeekBar", false)
|
||||
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(src, "seekIncrementMS", 10000)
|
||||
if (controlsConfig != null) {
|
||||
config.hideSeekBar = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSeekBar", false)
|
||||
config.hideDuration = ReactBridgeUtils.safeGetBool(controlsConfig, "hideDuration", false)
|
||||
config.hidePosition = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePosition", false)
|
||||
config.hidePlayPause = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePlayPause", false)
|
||||
config.hideForward = ReactBridgeUtils.safeGetBool(controlsConfig, "hideForward", false)
|
||||
config.hideRewind = ReactBridgeUtils.safeGetBool(controlsConfig, "hideRewind", false)
|
||||
config.hideNext = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNext", false)
|
||||
config.hidePrevious = ReactBridgeUtils.safeGetBool(controlsConfig, "hidePrevious", false)
|
||||
config.hideFullscreen = ReactBridgeUtils.safeGetBool(controlsConfig, "hideFullscreen", false)
|
||||
config.seekIncrementMS = ReactBridgeUtils.safeGetInt(controlsConfig, "seekIncrementMS", 10000)
|
||||
config.hideNavigationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNavigationBarOnFullScreenMode", true)
|
||||
config.hideNotificationBarOnFullScreenMode = ReactBridgeUtils.safeGetBool(controlsConfig, "hideNotificationBarOnFullScreenMode", true)
|
||||
config.liveLabel = ReactBridgeUtils.safeGetString(controlsConfig, "liveLabel", null)
|
||||
config.hideSettingButton = ReactBridgeUtils.safeGetBool(controlsConfig, "hideSettingButton", true)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
package com.brentvatne.common.api
|
||||
|
||||
import androidx.annotation.IntDef
|
||||
import java.lang.annotation.Retention
|
||||
import java.lang.annotation.RetentionPolicy
|
||||
import kotlin.annotation.Retention
|
||||
|
||||
internal object ResizeMode {
|
||||
/**
|
||||
@ -42,7 +41,7 @@ internal object ResizeMode {
|
||||
else -> RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@IntDef(
|
||||
RESIZE_MODE_FIT,
|
||||
RESIZE_MODE_FIXED_WIDTH,
|
||||
|
@ -11,12 +11,18 @@ import com.facebook.react.bridge.ReadableMap
|
||||
class SideLoadedTextTrackList {
|
||||
var tracks = ArrayList<SideLoadedTextTrack>()
|
||||
|
||||
/** return true if this and src are equals */
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other !is SideLoadedTextTrackList) return false
|
||||
return tracks == other.tracks
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(src: ReadableArray?): SideLoadedTextTrackList? {
|
||||
if (src == null) {
|
||||
return null
|
||||
}
|
||||
var sideLoadedTextTrackList = SideLoadedTextTrackList()
|
||||
val sideLoadedTextTrackList = SideLoadedTextTrackList()
|
||||
for (i in 0 until src.size()) {
|
||||
val textTrack: ReadableMap = src.getMap(i)
|
||||
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))
|
||||
|
@ -14,6 +14,7 @@ import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetBool
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetInt
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetMap
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils.safeGetString
|
||||
import com.brentvatne.react.BuildConfig
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.util.Locale
|
||||
import java.util.Objects
|
||||
@ -38,6 +39,9 @@ class Source {
|
||||
/** Will crop content end at specified position */
|
||||
var cropEndMs: Int = -1
|
||||
|
||||
/** Will virtually consider that content before contentStartTime is a preroll ad */
|
||||
var contentStartTime: Int = -1
|
||||
|
||||
/** Allow to force stream content, necessary when uri doesn't contain content type (.mlp4, .m3u, ...) */
|
||||
var extension: String? = null
|
||||
|
||||
@ -57,6 +61,21 @@ class Source {
|
||||
*/
|
||||
var textTracksAllowChunklessPreparation: Boolean = false
|
||||
|
||||
/**
|
||||
* CMCD properties linked to the source
|
||||
*/
|
||||
var cmcdProps: CMCDProps? = null
|
||||
|
||||
/**
|
||||
* Ads playback properties
|
||||
*/
|
||||
var adsProps: AdsProps? = null
|
||||
|
||||
/**
|
||||
* The list of sideLoaded text tracks
|
||||
*/
|
||||
var sideLoadedTextTracks: SideLoadedTextTrackList? = null
|
||||
|
||||
override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers)
|
||||
|
||||
/** return true if this and src are equals */
|
||||
@ -68,7 +87,11 @@ class Source {
|
||||
cropEndMs == other.cropEndMs &&
|
||||
startPositionMs == other.startPositionMs &&
|
||||
extension == other.extension &&
|
||||
drmProps == other.drmProps
|
||||
drmProps == other.drmProps &&
|
||||
contentStartTime == other.contentStartTime &&
|
||||
cmcdProps == other.cmcdProps &&
|
||||
sideLoadedTextTracks == other.sideLoadedTextTracks &&
|
||||
adsProps == other.adsProps
|
||||
)
|
||||
}
|
||||
|
||||
@ -127,11 +150,15 @@ class Source {
|
||||
private const val PROP_SRC_START_POSITION = "startPosition"
|
||||
private const val PROP_SRC_CROP_START = "cropStart"
|
||||
private const val PROP_SRC_CROP_END = "cropEnd"
|
||||
private const val PROP_SRC_CONTENT_START_TIME = "contentStartTime"
|
||||
private const val PROP_SRC_TYPE = "type"
|
||||
private const val PROP_SRC_METADATA = "metadata"
|
||||
private const val PROP_SRC_HEADERS = "requestHeaders"
|
||||
private const val PROP_SRC_DRM = "drm"
|
||||
private const val PROP_SRC_CMCD = "cmcd"
|
||||
private const val PROP_SRC_ADS = "ad"
|
||||
private const val PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION = "textTracksAllowChunklessPreparation"
|
||||
private const val PROP_SRC_TEXT_TRACKS = "textTracks"
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
private fun getUriFromAssetId(context: Context, uriString: String): Uri? {
|
||||
@ -187,9 +214,15 @@ class Source {
|
||||
source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
|
||||
source.cropStartMs = safeGetInt(src, PROP_SRC_CROP_START, -1)
|
||||
source.cropEndMs = safeGetInt(src, PROP_SRC_CROP_END, -1)
|
||||
source.contentStartTime = safeGetInt(src, PROP_SRC_CONTENT_START_TIME, -1)
|
||||
source.extension = safeGetString(src, PROP_SRC_TYPE, null)
|
||||
source.drmProps = parse(safeGetMap(src, PROP_SRC_DRM))
|
||||
source.cmcdProps = CMCDProps.parse(safeGetMap(src, PROP_SRC_CMCD))
|
||||
if (BuildConfig.USE_EXOPLAYER_IMA) {
|
||||
source.adsProps = AdsProps.parse(safeGetMap(src, PROP_SRC_ADS))
|
||||
}
|
||||
source.textTracksAllowChunklessPreparation = safeGetBool(src, PROP_SRC_TEXT_TRACKS_ALLOW_CHUNKLESS_PREPARATION, true)
|
||||
source.sideLoadedTextTracks = SideLoadedTextTrackList.parse(safeGetArray(src, PROP_SRC_TEXT_TRACKS))
|
||||
|
||||
val propSrcHeadersArray = safeGetArray(src, PROP_SRC_HEADERS)
|
||||
if (propSrcHeadersArray != null) {
|
||||
|
@ -6,7 +6,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
/**
|
||||
* Helper file to parse SubtitleStyle prop and build a dedicated class
|
||||
*/
|
||||
class SubtitleStyle private constructor() {
|
||||
class SubtitleStyle public constructor() {
|
||||
var fontSize = -1
|
||||
private set
|
||||
var paddingLeft = 0
|
||||
@ -19,6 +19,8 @@ class SubtitleStyle private constructor() {
|
||||
private set
|
||||
var opacity = 1f
|
||||
private set
|
||||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
@ -27,6 +29,7 @@ class SubtitleStyle private constructor() {
|
||||
private const val PROP_PADDING_LEFT = "paddingLeft"
|
||||
private const val PROP_PADDING_RIGHT = "paddingRight"
|
||||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
@ -37,6 +40,7 @@ class SubtitleStyle private constructor() {
|
||||
subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0)
|
||||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
|
@ -65,11 +65,11 @@ class VideoEventEmitter {
|
||||
audioTracks: ArrayList<Track>,
|
||||
textTracks: ArrayList<Track>,
|
||||
videoTracks: ArrayList<VideoTrack>,
|
||||
trackId: String
|
||||
trackId: String?
|
||||
) -> Unit
|
||||
lateinit var onVideoError: (errorString: String, exception: Exception, errorCode: String) -> Unit
|
||||
lateinit var onVideoProgress: (currentPosition: Long, bufferedDuration: Long, seekableDuration: Long, currentPlaybackTime: Double) -> Unit
|
||||
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String) -> Unit
|
||||
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
|
||||
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
|
||||
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
|
||||
lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit
|
||||
@ -110,7 +110,7 @@ class VideoEventEmitter {
|
||||
|
||||
val naturalSize: WritableMap = aspectRatioToNaturalSize(videoWidth, videoHeight)
|
||||
putMap("naturalSize", naturalSize)
|
||||
putString("trackId", trackId)
|
||||
trackId?.let { putString("trackId", it) }
|
||||
putArray("videoTracks", videoTracksToArray(videoTracks))
|
||||
putArray("audioTracks", audioTracksToArray(audioTracks))
|
||||
putArray("textTracks", textTracksToArray(textTracks))
|
||||
@ -155,9 +155,13 @@ class VideoEventEmitter {
|
||||
onVideoBandwidthUpdate = { bitRateEstimate, height, width, trackId ->
|
||||
event.dispatch(EventTypes.EVENT_BANDWIDTH) {
|
||||
putDouble("bitrate", bitRateEstimate.toDouble())
|
||||
putInt("width", width)
|
||||
putInt("height", height)
|
||||
putString("trackId", trackId)
|
||||
if (width > 0) {
|
||||
putInt("width", width)
|
||||
}
|
||||
if (height > 0) {
|
||||
putInt("height", height)
|
||||
}
|
||||
trackId?.let { putString("trackId", it) }
|
||||
}
|
||||
}
|
||||
onVideoPlaybackStateChanged = { isPlaying, isSeeking ->
|
||||
@ -216,7 +220,7 @@ class VideoEventEmitter {
|
||||
putArray(
|
||||
"metadata",
|
||||
Arguments.createArray().apply {
|
||||
metadataArrayList.forEachIndexed { i, metadata ->
|
||||
metadataArrayList.forEachIndexed { _, metadata ->
|
||||
pushMap(
|
||||
Arguments.createMap().apply {
|
||||
putString("identifier", metadata.identifier)
|
||||
@ -310,7 +314,7 @@ class VideoEventEmitter {
|
||||
|
||||
private fun videoTracksToArray(videoTracks: java.util.ArrayList<VideoTrack>?): WritableArray =
|
||||
Arguments.createArray().apply {
|
||||
videoTracks?.forEachIndexed { i, vTrack ->
|
||||
videoTracks?.forEachIndexed { _, vTrack ->
|
||||
pushMap(
|
||||
Arguments.createMap().apply {
|
||||
putInt("width", vTrack.width)
|
||||
@ -343,15 +347,19 @@ class VideoEventEmitter {
|
||||
|
||||
private fun aspectRatioToNaturalSize(videoWidth: Int, videoHeight: Int): WritableMap =
|
||||
Arguments.createMap().apply {
|
||||
putInt("width", videoWidth)
|
||||
putInt("height", videoHeight)
|
||||
val orientation = if (videoWidth > videoHeight) {
|
||||
"landscape"
|
||||
} else if (videoWidth < videoHeight) {
|
||||
"portrait"
|
||||
} else {
|
||||
"square"
|
||||
if (videoWidth > 0) {
|
||||
putInt("width", videoWidth)
|
||||
}
|
||||
if (videoHeight > 0) {
|
||||
putInt("height", videoHeight)
|
||||
}
|
||||
|
||||
val orientation = when {
|
||||
videoWidth > videoHeight -> "landscape"
|
||||
videoWidth < videoHeight -> "portrait"
|
||||
else -> "square"
|
||||
}
|
||||
|
||||
putString("orientation", orientation)
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ package com.brentvatne.common.toolbox
|
||||
import com.facebook.react.bridge.Dynamic
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.util.HashMap
|
||||
|
||||
/*
|
||||
* Toolbox to safe parsing of <Video props
|
||||
@ -54,6 +53,17 @@ object ReactBridgeUtils {
|
||||
|
||||
@JvmStatic fun safeGetFloat(map: ReadableMap?, key: String?): Float = safeGetFloat(map, key, 0.0f)
|
||||
|
||||
@JvmStatic fun safeParseInt(value: String?, default: Int): Int {
|
||||
if (value == null) {
|
||||
return default
|
||||
}
|
||||
return try {
|
||||
value.toInt()
|
||||
} catch (e: java.lang.Exception) {
|
||||
default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* toStringMap converts a [ReadableMap] into a HashMap.
|
||||
*
|
||||
|
@ -2,6 +2,7 @@ package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.FrameLayout
|
||||
import androidx.media3.common.Format
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import kotlin.math.abs
|
||||
|
||||
@ -94,4 +95,12 @@ class AspectRatioFrameLayout(context: Context) : FrameLayout(context) {
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateAspectRatio(format: Format) {
|
||||
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
|
||||
when (format.rotationDegrees) {
|
||||
90, 270 -> videoAspectRatio = if (format.width == 0) 1f else (format.height * format.pixelWidthHeightRatio) / format.width
|
||||
else -> videoAspectRatio = if (format.height == 0) 1f else (format.width * format.pixelWidthHeightRatio) / format.height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
41
android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt
Normal file
41
android/src/main/java/com/brentvatne/exoplayer/CMCDConfig.kt
Normal file
@ -0,0 +1,41 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.upstream.CmcdConfiguration
|
||||
import com.brentvatne.common.api.CMCDProps
|
||||
import com.google.common.collect.ImmutableListMultimap
|
||||
|
||||
class CMCDConfig(private val props: CMCDProps) {
|
||||
fun toCmcdConfigurationFactory(): CmcdConfiguration.Factory = CmcdConfiguration.Factory(::createCmcdConfiguration)
|
||||
|
||||
private fun createCmcdConfiguration(mediaItem: MediaItem): CmcdConfiguration =
|
||||
CmcdConfiguration(
|
||||
java.util.UUID.randomUUID().toString(),
|
||||
mediaItem.mediaId,
|
||||
object : CmcdConfiguration.RequestConfig {
|
||||
override fun getCustomData(): ImmutableListMultimap<String, String> = buildCustomData()
|
||||
},
|
||||
props.mode
|
||||
)
|
||||
|
||||
private fun buildCustomData(): ImmutableListMultimap<String, String> =
|
||||
ImmutableListMultimap.builder<String, String>().apply {
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_OBJECT, props.cmcdObject)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_REQUEST, props.cmcdRequest)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_SESSION, props.cmcdSession)
|
||||
addFormattedData(this, CmcdConfiguration.KEY_CMCD_STATUS, props.cmcdStatus)
|
||||
}.build()
|
||||
|
||||
private fun addFormattedData(builder: ImmutableListMultimap.Builder<String, String>, key: String, dataList: List<Pair<String, Any>>) {
|
||||
dataList.forEach { (dataKey, dataValue) ->
|
||||
builder.put(key, formatKeyValue(dataKey, dataValue))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatKeyValue(key: String, value: Any): String =
|
||||
when (value) {
|
||||
is String -> "$key=\"$value\""
|
||||
is Number -> "$key=$value"
|
||||
else -> throw IllegalArgumentException("Unsupported value type: ${value::class.java}")
|
||||
}
|
||||
}
|
@ -1,292 +0,0 @@
|
||||
package com.brentvatne.exoplayer;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.AdViewProvider;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.Tracks;
|
||||
import androidx.media3.common.VideoSize;
|
||||
import androidx.media3.common.text.Cue;
|
||||
import androidx.media3.common.util.Assertions;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.ui.SubtitleView;
|
||||
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.TextureView;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.brentvatne.common.api.ResizeMode;
|
||||
import com.brentvatne.common.api.SubtitleStyle;
|
||||
import com.brentvatne.common.api.ViewType;
|
||||
import com.brentvatne.common.toolbox.DebugLog;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@SuppressLint("ViewConstructor")
|
||||
public final class ExoPlayerView extends FrameLayout implements AdViewProvider {
|
||||
private final static String TAG = "ExoPlayerView";
|
||||
private View surfaceView;
|
||||
private final View shutterView;
|
||||
private final SubtitleView subtitleLayout;
|
||||
private final AspectRatioFrameLayout layout;
|
||||
private final ComponentListener componentListener;
|
||||
private ExoPlayer player;
|
||||
private final Context context;
|
||||
private final ViewGroup.LayoutParams layoutParams;
|
||||
private final FrameLayout adOverlayFrameLayout;
|
||||
|
||||
private @ViewType.ViewType int viewType = ViewType.VIEW_TYPE_SURFACE;
|
||||
private boolean hideShutterView = false;
|
||||
|
||||
public ExoPlayerView(Context context) {
|
||||
super(context, null, 0);
|
||||
|
||||
this.context = context;
|
||||
|
||||
layoutParams = new ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT);
|
||||
|
||||
componentListener = new ComponentListener();
|
||||
|
||||
FrameLayout.LayoutParams aspectRatioParams = new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT);
|
||||
aspectRatioParams.gravity = Gravity.CENTER;
|
||||
layout = new AspectRatioFrameLayout(context);
|
||||
layout.setLayoutParams(aspectRatioParams);
|
||||
|
||||
shutterView = new View(getContext());
|
||||
shutterView.setLayoutParams(layoutParams);
|
||||
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black));
|
||||
|
||||
subtitleLayout = new SubtitleView(context);
|
||||
subtitleLayout.setLayoutParams(layoutParams);
|
||||
subtitleLayout.setUserDefaultStyle();
|
||||
subtitleLayout.setUserDefaultTextSize();
|
||||
|
||||
updateSurfaceView(viewType);
|
||||
|
||||
adOverlayFrameLayout = new FrameLayout(context);
|
||||
|
||||
layout.addView(shutterView, 1, layoutParams);
|
||||
layout.addView(adOverlayFrameLayout, 2, layoutParams);
|
||||
|
||||
addViewInLayout(layout, 0, aspectRatioParams);
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams);
|
||||
}
|
||||
|
||||
private void clearVideoView() {
|
||||
if (surfaceView instanceof TextureView) {
|
||||
player.clearVideoTextureView((TextureView) surfaceView);
|
||||
} else if (surfaceView instanceof SurfaceView) {
|
||||
player.clearVideoSurfaceView((SurfaceView) surfaceView);
|
||||
}
|
||||
}
|
||||
|
||||
private void setVideoView() {
|
||||
if (surfaceView instanceof TextureView) {
|
||||
player.setVideoTextureView((TextureView) surfaceView);
|
||||
} else if (surfaceView instanceof SurfaceView) {
|
||||
player.setVideoSurfaceView((SurfaceView) surfaceView);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
return player != null && player.isPlaying();
|
||||
}
|
||||
|
||||
public void setSubtitleStyle(SubtitleStyle style) {
|
||||
// ensure we reset subtile style before reapplying it
|
||||
subtitleLayout.setUserDefaultStyle();
|
||||
subtitleLayout.setUserDefaultTextSize();
|
||||
|
||||
if (style.getFontSize() > 0) {
|
||||
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.getFontSize());
|
||||
}
|
||||
subtitleLayout.setPadding(style.getPaddingLeft(), style.getPaddingTop(), style.getPaddingRight(), style.getPaddingBottom());
|
||||
if (style.getOpacity() != 0) {
|
||||
subtitleLayout.setAlpha(style.getOpacity());
|
||||
subtitleLayout.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
subtitleLayout.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void setShutterColor(Integer color) {
|
||||
shutterView.setBackgroundColor(color);
|
||||
}
|
||||
|
||||
public void updateSurfaceView(@ViewType.ViewType int viewType) {
|
||||
this.viewType = viewType;
|
||||
boolean viewNeedRefresh = false;
|
||||
if (viewType == ViewType.VIEW_TYPE_SURFACE || viewType == ViewType.VIEW_TYPE_SURFACE_SECURE) {
|
||||
if (!(surfaceView instanceof SurfaceView)) {
|
||||
surfaceView = new SurfaceView(context);
|
||||
viewNeedRefresh = true;
|
||||
}
|
||||
((SurfaceView)surfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE);
|
||||
} else if (viewType == ViewType.VIEW_TYPE_TEXTURE) {
|
||||
if (!(surfaceView instanceof TextureView)) {
|
||||
surfaceView = new TextureView(context);
|
||||
viewNeedRefresh = true;
|
||||
}
|
||||
// Support opacity properly:
|
||||
((TextureView) surfaceView).setOpaque(false);
|
||||
} else {
|
||||
DebugLog.wtf(TAG, "wtf is this texture " + viewType);
|
||||
}
|
||||
if (viewNeedRefresh) {
|
||||
surfaceView.setLayoutParams(layoutParams);
|
||||
|
||||
if (layout.getChildAt(0) != null) {
|
||||
layout.removeViewAt(0);
|
||||
}
|
||||
layout.addView(surfaceView, 0, layoutParams);
|
||||
|
||||
if (this.player != null) {
|
||||
setVideoView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateShutterViewVisibility() {
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestLayout() {
|
||||
super.requestLayout();
|
||||
post(measureAndLayout);
|
||||
}
|
||||
|
||||
// AdsLoader.AdViewProvider implementation.
|
||||
|
||||
@Override
|
||||
public ViewGroup getAdViewGroup() {
|
||||
return Assertions.checkNotNull(adOverlayFrameLayout, "exo_ad_overlay must be present for ad playback");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
|
||||
* player will be called and previous
|
||||
* assignments are overridden.
|
||||
*
|
||||
* @param player The {@link ExoPlayer} to use.
|
||||
*/
|
||||
public void setPlayer(ExoPlayer player) {
|
||||
if (this.player == player) {
|
||||
return;
|
||||
}
|
||||
if (this.player != null) {
|
||||
this.player.removeListener(componentListener);
|
||||
clearVideoView();
|
||||
}
|
||||
this.player = player;
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
if (player != null) {
|
||||
setVideoView();
|
||||
player.addListener(componentListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
|
||||
*
|
||||
* @param resizeMode The resize mode.
|
||||
*/
|
||||
public void setResizeMode(@ResizeMode.Mode int resizeMode) {
|
||||
if (layout != null && layout.getResizeMode() != resizeMode) {
|
||||
layout.setResizeMode(resizeMode);
|
||||
post(measureAndLayout);
|
||||
}
|
||||
}
|
||||
|
||||
public void setHideShutterView(boolean hideShutterView) {
|
||||
this.hideShutterView = hideShutterView;
|
||||
updateShutterViewVisibility();
|
||||
}
|
||||
|
||||
private final Runnable measureAndLayout = () -> {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY));
|
||||
layout(getLeft(), getTop(), getRight(), getBottom());
|
||||
};
|
||||
|
||||
private void updateForCurrentTrackSelections(Tracks tracks) {
|
||||
if (tracks == null) {
|
||||
return;
|
||||
}
|
||||
ImmutableList<Tracks.Group> groups = tracks.getGroups();
|
||||
for (Tracks.Group group: groups) {
|
||||
if (group.getType() == C.TRACK_TYPE_VIDEO && group.length > 0) {
|
||||
// get the first track of the group to identify aspect ratio
|
||||
Format format = group.getTrackFormat(0);
|
||||
|
||||
// There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it
|
||||
switch (format.rotationDegrees) {
|
||||
// update aspect ratio !
|
||||
case 90:
|
||||
case 270:
|
||||
layout.setVideoAspectRatio(format.width == 0 ? 1 : (format.height * format.pixelWidthHeightRatio) / format.width);
|
||||
default:
|
||||
layout.setVideoAspectRatio(format.height == 0 ? 1 : (format.width * format.pixelWidthHeightRatio) / format.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// no video tracks, in that case refresh shutterView visibility
|
||||
shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE);
|
||||
}
|
||||
|
||||
public void invalidateAspectRatio() {
|
||||
// Resetting aspect ratio will force layout refresh on next video size changed
|
||||
layout.invalidateAspectRatio();
|
||||
}
|
||||
|
||||
private final class ComponentListener implements Player.Listener {
|
||||
|
||||
@Override
|
||||
public void onCues(@NonNull List<Cue> cues) {
|
||||
subtitleLayout.setCues(cues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVideoSizeChanged(VideoSize videoSize) {
|
||||
boolean isInitialRatio = layout.getVideoAspectRatio() == 0;
|
||||
if (videoSize.height == 0 || videoSize.width == 0) {
|
||||
// When changing video track we receive an ghost state with height / width = 0
|
||||
// No need to resize the view in that case
|
||||
return;
|
||||
}
|
||||
layout.setVideoAspectRatio((videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height);
|
||||
|
||||
// React native workaround for measuring and layout on initial load.
|
||||
if (isInitialRatio) {
|
||||
post(measureAndLayout);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderedFirstFrame() {
|
||||
shutterView.setVisibility(INVISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTracksChanged(@NonNull Tracks tracks) {
|
||||
updateForCurrentTrackSelections(tracks);
|
||||
}
|
||||
}
|
||||
}
|
338
android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
Normal file
338
android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
Normal file
@ -0,0 +1,338 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AdViewProvider
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
import com.brentvatne.common.api.ViewType
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
|
||||
@UnstableApi
|
||||
class ExoPlayerView(private val context: Context) :
|
||||
FrameLayout(context, null, 0),
|
||||
AdViewProvider {
|
||||
|
||||
private var surfaceView: View? = null
|
||||
private var shutterView: View
|
||||
private var subtitleLayout: SubtitleView
|
||||
private var layout: AspectRatioFrameLayout
|
||||
private var componentListener: ComponentListener
|
||||
private var player: ExoPlayer? = null
|
||||
private var layoutParams: ViewGroup.LayoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
private var adOverlayFrameLayout: FrameLayout
|
||||
val isPlaying: Boolean
|
||||
get() = player != null && player?.isPlaying == true
|
||||
|
||||
@ViewType.ViewType
|
||||
private var viewType = ViewType.VIEW_TYPE_SURFACE
|
||||
private var hideShutterView = false
|
||||
|
||||
private var localStyle = SubtitleStyle()
|
||||
|
||||
init {
|
||||
componentListener = ComponentListener()
|
||||
|
||||
val aspectRatioParams = LayoutParams(
|
||||
LayoutParams.MATCH_PARENT,
|
||||
LayoutParams.MATCH_PARENT
|
||||
)
|
||||
aspectRatioParams.gravity = Gravity.CENTER
|
||||
layout = AspectRatioFrameLayout(context)
|
||||
layout.layoutParams = aspectRatioParams
|
||||
|
||||
shutterView = View(context)
|
||||
shutterView.layoutParams = layoutParams
|
||||
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black))
|
||||
|
||||
subtitleLayout = SubtitleView(context)
|
||||
subtitleLayout.layoutParams = layoutParams
|
||||
subtitleLayout.setUserDefaultStyle()
|
||||
subtitleLayout.setUserDefaultTextSize()
|
||||
|
||||
updateSurfaceView(viewType)
|
||||
|
||||
adOverlayFrameLayout = FrameLayout(context)
|
||||
|
||||
layout.addView(shutterView, 1, layoutParams)
|
||||
if (localStyle.subtitlesFollowVideo) {
|
||||
layout.addView(subtitleLayout, layoutParams)
|
||||
layout.addView(adOverlayFrameLayout, layoutParams)
|
||||
}
|
||||
|
||||
addViewInLayout(layout, 0, aspectRatioParams)
|
||||
if (!localStyle.subtitlesFollowVideo) {
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearVideoView() {
|
||||
when (val view = surfaceView) {
|
||||
is TextureView -> player?.clearVideoTextureView(view)
|
||||
|
||||
is SurfaceView -> player?.clearVideoSurfaceView(view)
|
||||
|
||||
else -> {
|
||||
Log.w(
|
||||
"clearVideoView",
|
||||
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setVideoView() {
|
||||
when (val view = surfaceView) {
|
||||
is TextureView -> player?.setVideoTextureView(view)
|
||||
|
||||
is SurfaceView -> player?.setVideoSurfaceView(view)
|
||||
|
||||
else -> {
|
||||
Log.w(
|
||||
"setVideoView",
|
||||
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
// ensure we reset subtitle style before reapplying it
|
||||
subtitleLayout.setUserDefaultStyle()
|
||||
subtitleLayout.setUserDefaultTextSize()
|
||||
|
||||
if (style.fontSize > 0) {
|
||||
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
|
||||
}
|
||||
subtitleLayout.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingTop,
|
||||
style.paddingBottom
|
||||
)
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleLayout.alpha = style.opacity
|
||||
subtitleLayout.visibility = View.VISIBLE
|
||||
} else {
|
||||
subtitleLayout.visibility = View.GONE
|
||||
}
|
||||
if (localStyle.subtitlesFollowVideo != style.subtitlesFollowVideo) {
|
||||
// No need to manipulate layout if value didn't change
|
||||
if (style.subtitlesFollowVideo) {
|
||||
removeViewInLayout(subtitleLayout)
|
||||
layout.addView(subtitleLayout, layoutParams)
|
||||
} else {
|
||||
layout.removeViewInLayout(subtitleLayout)
|
||||
addViewInLayout(subtitleLayout, 1, layoutParams, false)
|
||||
}
|
||||
requestLayout()
|
||||
}
|
||||
localStyle = style
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
shutterView.setBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
|
||||
this.viewType = viewType
|
||||
var viewNeedRefresh = false
|
||||
when (viewType) {
|
||||
ViewType.VIEW_TYPE_SURFACE, ViewType.VIEW_TYPE_SURFACE_SECURE -> {
|
||||
if (surfaceView !is SurfaceView) {
|
||||
surfaceView = SurfaceView(context)
|
||||
viewNeedRefresh = true
|
||||
}
|
||||
(surfaceView as SurfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE)
|
||||
}
|
||||
|
||||
ViewType.VIEW_TYPE_TEXTURE -> {
|
||||
if (surfaceView !is TextureView) {
|
||||
surfaceView = TextureView(context)
|
||||
viewNeedRefresh = true
|
||||
}
|
||||
// Support opacity properly:
|
||||
(surfaceView as TextureView).isOpaque = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
DebugLog.wtf(TAG, "Unexpected texture view type: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
if (viewNeedRefresh) {
|
||||
surfaceView?.layoutParams = layoutParams
|
||||
|
||||
if (layout.getChildAt(0) != null) {
|
||||
layout.removeViewAt(0)
|
||||
}
|
||||
layout.addView(surfaceView, 0, layoutParams)
|
||||
|
||||
if (this.player != null) {
|
||||
setVideoView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideShutterView() {
|
||||
shutterView.setVisibility(INVISIBLE)
|
||||
surfaceView?.setAlpha(1f)
|
||||
}
|
||||
|
||||
private fun showShutterView() {
|
||||
shutterView.setVisibility(VISIBLE)
|
||||
surfaceView?.setAlpha(0f)
|
||||
}
|
||||
|
||||
fun showAds() {
|
||||
adOverlayFrameLayout.setVisibility(View.VISIBLE)
|
||||
}
|
||||
|
||||
fun hideAds() {
|
||||
adOverlayFrameLayout.setVisibility(View.GONE)
|
||||
}
|
||||
|
||||
fun updateShutterViewVisibility() {
|
||||
shutterView.visibility = if (this.hideShutterView) {
|
||||
View.INVISIBLE
|
||||
} else {
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
post(measureAndLayout)
|
||||
}
|
||||
|
||||
// AdsLoader.AdViewProvider implementation.
|
||||
override fun getAdViewGroup(): ViewGroup =
|
||||
Assertions.checkNotNull(
|
||||
adOverlayFrameLayout,
|
||||
"exo_ad_overlay must be present for ad playback"
|
||||
)
|
||||
|
||||
/**
|
||||
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
|
||||
* player will be called and previous
|
||||
* assignments are overridden.
|
||||
*
|
||||
* @param player The {@link ExoPlayer} to use.
|
||||
*/
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
if (this.player == player) {
|
||||
return
|
||||
}
|
||||
if (this.player != null) {
|
||||
this.player!!.removeListener(componentListener)
|
||||
clearVideoView()
|
||||
}
|
||||
this.player = player
|
||||
|
||||
updateShutterViewVisibility()
|
||||
if (player != null) {
|
||||
setVideoView()
|
||||
player.addListener(componentListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
|
||||
*
|
||||
* @param resizeMode The resize mode.
|
||||
*/
|
||||
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
|
||||
if (layout.resizeMode != resizeMode) {
|
||||
layout.resizeMode = resizeMode
|
||||
post(measureAndLayout)
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideShutterView(hideShutterView: Boolean) {
|
||||
this.hideShutterView = hideShutterView
|
||||
updateShutterViewVisibility()
|
||||
}
|
||||
|
||||
private val measureAndLayout: Runnable = Runnable {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private fun updateForCurrentTrackSelections(tracks: Tracks?) {
|
||||
if (tracks == null) {
|
||||
return
|
||||
}
|
||||
val groups = tracks.groups
|
||||
|
||||
for (group in groups) {
|
||||
if (group.type == C.TRACK_TYPE_VIDEO && group.length > 0) {
|
||||
// get the first track of the group to identify aspect ratio
|
||||
val format = group.getTrackFormat(0)
|
||||
layout.updateAspectRatio(format)
|
||||
return
|
||||
}
|
||||
}
|
||||
// no video tracks, in that case refresh shutterView visibility
|
||||
updateShutterViewVisibility()
|
||||
}
|
||||
|
||||
fun invalidateAspectRatio() {
|
||||
// Resetting aspect ratio will force layout refresh on next video size changed
|
||||
layout.invalidateAspectRatio()
|
||||
}
|
||||
|
||||
private inner class ComponentListener : Player.Listener {
|
||||
override fun onCues(cues: List<Cue>) {
|
||||
subtitleLayout.setCues(cues)
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.height == 0 || videoSize.width == 0) {
|
||||
// When changing video track we receive an ghost state with height / width = 0
|
||||
// No need to resize the view in that case
|
||||
return
|
||||
}
|
||||
// Here we use updateForCurrentTrackSelections to have a consistent behavior.
|
||||
// according to: https://github.com/androidx/media/issues/1207
|
||||
// sometimes media3 send bad Video size information
|
||||
player?.let {
|
||||
updateForCurrentTrackSelections(it.currentTracks)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
shutterView.visibility = INVISIBLE
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
updateForCurrentTrackSelections(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
}
|
||||
}
|
@ -6,11 +6,17 @@ import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.media3.ui.LegacyPlayerControlView
|
||||
import com.brentvatne.common.api.ControlsConfig
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@ -20,14 +26,22 @@ class FullScreenPlayerView(
|
||||
private val exoPlayerView: ExoPlayerView,
|
||||
private val reactExoplayerView: ReactExoplayerView,
|
||||
private val playerControlView: LegacyPlayerControlView?,
|
||||
private val onBackPressedCallback: OnBackPressedCallback
|
||||
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar_Fullscreen) {
|
||||
private val onBackPressedCallback: OnBackPressedCallback,
|
||||
private val controlsConfig: ControlsConfig
|
||||
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
|
||||
|
||||
private var parent: ViewGroup? = null
|
||||
private val containerView = FrameLayout(context)
|
||||
private val mKeepScreenOnHandler = Handler(Looper.getMainLooper())
|
||||
private val mKeepScreenOnUpdater = KeepScreenOnUpdater(this)
|
||||
|
||||
// As this view is fullscreen we need to save initial state and restore it afterward
|
||||
// Following variables save UI state when open the view
|
||||
// restoreUIState, will reapply these values
|
||||
private var initialSystemBarsBehavior: Int? = null
|
||||
private var initialNavigationBarIsVisible: Boolean? = null
|
||||
private var initialNotificationBarIsVisible: Boolean? = null
|
||||
|
||||
private class KeepScreenOnUpdater(fullScreenPlayerView: FullScreenPlayerView) : Runnable {
|
||||
private val mFullscreenPlayer = WeakReference(fullScreenPlayerView)
|
||||
|
||||
@ -59,10 +73,15 @@ class FullScreenPlayerView(
|
||||
|
||||
init {
|
||||
setContentView(containerView, generateDefaultLayoutParams())
|
||||
}
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
onBackPressedCallback.handleOnBackPressed()
|
||||
|
||||
window?.let {
|
||||
val inset = WindowInsetsControllerCompat(it, it.decorView)
|
||||
initialSystemBarsBehavior = inset.systemBarsBehavior
|
||||
initialNavigationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
|
||||
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
|
||||
initialNotificationBarIsVisible = ViewCompat.getRootWindowInsets(it.decorView)
|
||||
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@ -75,6 +94,7 @@ class FullScreenPlayerView(
|
||||
parent?.removeView(it)
|
||||
containerView.addView(it, generateDefaultLayoutParams())
|
||||
}
|
||||
updateNavigationBarVisibility()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
@ -89,6 +109,20 @@ class FullScreenPlayerView(
|
||||
}
|
||||
parent?.requestLayout()
|
||||
parent = null
|
||||
onBackPressedCallback.handleOnBackPressed()
|
||||
restoreSystemUI()
|
||||
}
|
||||
|
||||
// restore system UI state
|
||||
private fun restoreSystemUI() {
|
||||
window?.let {
|
||||
updateNavigationBarVisibility(
|
||||
it,
|
||||
initialNavigationBarIsVisible,
|
||||
initialNotificationBarIsVisible,
|
||||
initialSystemBarsBehavior
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
|
||||
@ -127,4 +161,69 @@ class FullScreenPlayerView(
|
||||
layoutParams.setMargins(0, 0, 0, 0)
|
||||
return layoutParams
|
||||
}
|
||||
|
||||
private fun updateBarVisibility(
|
||||
inset: WindowInsetsControllerCompat,
|
||||
type: Int,
|
||||
shouldHide: Boolean?,
|
||||
initialVisibility: Boolean?,
|
||||
systemBarsBehavior: Int? = null
|
||||
) {
|
||||
shouldHide?.takeIf { it != initialVisibility }?.let {
|
||||
if (it) {
|
||||
inset.hide(type)
|
||||
systemBarsBehavior?.let { behavior -> inset.systemBarsBehavior = behavior }
|
||||
} else {
|
||||
inset.show(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move the UI to fullscreen.
|
||||
// if you change this code, remember to check that the UI is well restored in restoreUIState
|
||||
private fun updateNavigationBarVisibility(
|
||||
window: Window,
|
||||
hideNavigationBarOnFullScreenMode: Boolean?,
|
||||
hideNotificationBarOnFullScreenMode: Boolean?,
|
||||
systemBarsBehavior: Int?
|
||||
) {
|
||||
// Configure the behavior of the hidden system bars.
|
||||
val inset = WindowInsetsControllerCompat(window, window.decorView)
|
||||
|
||||
// Update navigation bar visibility and apply systemBarsBehavior if hiding
|
||||
updateBarVisibility(
|
||||
inset,
|
||||
WindowInsetsCompat.Type.navigationBars(),
|
||||
hideNavigationBarOnFullScreenMode,
|
||||
initialNavigationBarIsVisible,
|
||||
systemBarsBehavior
|
||||
)
|
||||
|
||||
// Update notification bar visibility (no need for systemBarsBehavior here)
|
||||
updateBarVisibility(
|
||||
inset,
|
||||
WindowInsetsCompat.Type.statusBars(),
|
||||
hideNotificationBarOnFullScreenMode,
|
||||
initialNotificationBarIsVisible
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateNavigationBarVisibility() {
|
||||
window?.let {
|
||||
updateNavigationBarVisibility(
|
||||
it,
|
||||
controlsConfig.hideNavigationBarOnFullScreenMode,
|
||||
controlsConfig.hideNotificationBarOnFullScreenMode,
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
)
|
||||
}
|
||||
if (controlsConfig.hideNotificationBarOnFullScreenMode) {
|
||||
val liveContainer = playerControlView?.findViewById<LinearLayout?>(com.brentvatne.react.R.id.exo_live_container)
|
||||
liveContainer?.let {
|
||||
val layoutParams = it.layoutParams as LinearLayout.LayoutParams
|
||||
layoutParams.topMargin = 40
|
||||
it.layoutParams = layoutParams
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,11 @@
|
||||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.brentvatne.common.api.BufferConfig
|
||||
import com.brentvatne.common.api.BufferingStrategy
|
||||
import com.brentvatne.common.api.ControlsConfig
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SideLoadedTextTrackList
|
||||
import com.brentvatne.common.api.Source
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
import com.brentvatne.common.api.ViewType
|
||||
@ -16,7 +13,6 @@ import com.brentvatne.common.react.EventTypes
|
||||
import com.brentvatne.common.toolbox.DebugLog
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.brentvatne.react.ReactNativeVideoManager
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.uimanager.ViewGroupManager
|
||||
@ -28,7 +24,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
private const val TAG = "ExoViewManager"
|
||||
private const val REACT_CLASS = "RCTVideo"
|
||||
private const val PROP_SRC = "src"
|
||||
private const val PROP_AD_TAG_URL = "adTagUrl"
|
||||
private const val PROP_RESIZE_MODE = "resizeMode"
|
||||
private const val PROP_REPEAT = "repeat"
|
||||
private const val PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack"
|
||||
@ -37,7 +32,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
private const val PROP_SELECTED_TEXT_TRACK = "selectedTextTrack"
|
||||
private const val PROP_SELECTED_TEXT_TRACK_TYPE = "type"
|
||||
private const val PROP_SELECTED_TEXT_TRACK_VALUE = "value"
|
||||
private const val PROP_TEXT_TRACKS = "textTracks"
|
||||
private const val PROP_PAUSED = "paused"
|
||||
private const val PROP_MUTED = "muted"
|
||||
private const val PROP_AUDIO_OUTPUT = "audioOutput"
|
||||
@ -51,7 +45,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
private const val PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount"
|
||||
private const val PROP_MAXIMUM_BIT_RATE = "maxBitRate"
|
||||
private const val PROP_PLAY_IN_BACKGROUND = "playInBackground"
|
||||
private const val PROP_CONTENT_START_TIME = "contentStartTime"
|
||||
private const val PROP_DISABLE_FOCUS = "disableFocus"
|
||||
private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy"
|
||||
private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError"
|
||||
@ -92,22 +85,7 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
@ReactProp(name = PROP_SRC)
|
||||
fun setSrc(videoView: ReactExoplayerView, src: ReadableMap?) {
|
||||
val context = videoView.context.applicationContext
|
||||
val source = Source.parse(src, context)
|
||||
if (source.uri == null) {
|
||||
videoView.clearSrc()
|
||||
} else {
|
||||
videoView.setSrc(source)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_AD_TAG_URL)
|
||||
fun setAdTagUrl(videoView: ReactExoplayerView, uriString: String?) {
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
videoView.setAdTagUrl(null)
|
||||
return
|
||||
}
|
||||
val adTagUrl = Uri.parse(uriString)
|
||||
videoView.setAdTagUrl(adTagUrl)
|
||||
videoView.setSrc(Source.parse(src, context))
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_RESIZE_MODE)
|
||||
@ -169,12 +147,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setSelectedTextTrack(typeString, value)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_TEXT_TRACKS)
|
||||
fun setTextTracks(videoView: ReactExoplayerView, textTracks: ReadableArray?) {
|
||||
val sideLoadedTextTracks = SideLoadedTextTrackList.parse(textTracks)
|
||||
videoView.setTextTracks(sideLoadedTextTracks)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_PAUSED, defaultBoolean = false)
|
||||
fun setPaused(videoView: ReactExoplayerView, paused: Boolean) {
|
||||
videoView.setPausedModifier(paused)
|
||||
@ -235,11 +207,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setFocusable(focusable)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_CONTENT_START_TIME, defaultInt = -1)
|
||||
fun setContentStartTime(videoView: ReactExoplayerView, contentStartTime: Int) {
|
||||
videoView.setContentStartTime(contentStartTime)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_BUFFERING_STRATEGY)
|
||||
fun setBufferingStrategy(videoView: ReactExoplayerView, bufferingStrategy: String) {
|
||||
val strategy = BufferingStrategy.parse(bufferingStrategy)
|
||||
@ -276,9 +243,9 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
|
||||
videoView.setSubtitleStyle(SubtitleStyle.parse(src))
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = 0)
|
||||
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = Color.BLACK)
|
||||
fun setShutterColor(videoView: ReactExoplayerView, color: Int) {
|
||||
videoView.setShutterColor(if (color == 0) Color.BLACK else color)
|
||||
videoView.setShutterColor(color)
|
||||
}
|
||||
|
||||
@ReactProp(name = PROP_BUFFER_CONFIG)
|
||||
|
@ -63,6 +63,7 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
|
||||
mediaSessionsList[player] = mediaSession
|
||||
addSession(mediaSession)
|
||||
startForeground(mediaSession.player.hashCode(), buildNotification(mediaSession))
|
||||
}
|
||||
|
||||
fun unregisterPlayer(player: ExoPlayer) {
|
||||
@ -95,6 +96,10 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
|
||||
override fun onDestroy() {
|
||||
cleanup()
|
||||
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@ -121,7 +126,7 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
private fun buildNotification(session: MediaSession): Notification {
|
||||
val returnToPlayer = Intent(this, sourceActivity).apply {
|
||||
val returnToPlayer = Intent(this, sourceActivity ?: this.javaClass).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
|
||||
@ -179,17 +184,17 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setSmallIcon(androidx.media3.session.R.drawable.media3_icon_circular_play)
|
||||
// Add media control buttons that invoke intents in your media service
|
||||
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_back, "Seek Backward", seekBackwardPendingIntent) // #0
|
||||
.addAction(androidx.media3.session.R.drawable.media3_icon_rewind, "Seek Backward", seekBackwardPendingIntent) // #0
|
||||
.addAction(
|
||||
if (session.player.isPlaying) {
|
||||
androidx.media3.session.R.drawable.media3_notification_pause
|
||||
androidx.media3.session.R.drawable.media3_icon_pause
|
||||
} else {
|
||||
androidx.media3.session.R.drawable.media3_notification_play
|
||||
androidx.media3.session.R.drawable.media3_icon_play
|
||||
},
|
||||
"Toggle Play",
|
||||
togglePlayPendingIntent
|
||||
) // #1
|
||||
.addAction(androidx.media3.session.R.drawable.media3_notification_seek_forward, "Seek Forward", seekForwardPendingIntent) // #2
|
||||
.addAction(androidx.media3.session.R.drawable.media3_icon_fast_forward, "Seek Forward", seekForwardPendingIntent) // #2
|
||||
// Apply the media style template
|
||||
.setStyle(MediaStyleNotificationHelper.MediaStyle(session).setShowActionsInCompactView(0, 1, 2))
|
||||
.setContentTitle(session.player.mediaMetadata.title)
|
||||
@ -209,9 +214,6 @@ class VideoPlaybackService : MediaSessionService() {
|
||||
private fun hideAllNotifications() {
|
||||
val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.cancelAll()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.deleteNotificationChannel(NOTIFICATION_CHANEL_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
|
@ -1,10 +1,12 @@
|
||||
package com.brentvatne.react
|
||||
|
||||
import com.brentvatne.common.api.Source
|
||||
import com.brentvatne.exoplayer.ReactExoplayerView
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.UiThreadUtil
|
||||
import com.facebook.react.uimanager.UIManagerHelper
|
||||
import com.facebook.react.uimanager.common.UIManagerType
|
||||
@ -42,6 +44,7 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
@Suppress("UNUSED_PARAMETER") // codegen compatibility
|
||||
fun seekCmd(reactTag: Int, time: Float, tolerance: Float) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.seekTo((time * 1000f).roundToInt().toLong())
|
||||
@ -62,6 +65,13 @@ class VideoManagerModule(reactContext: ReactApplicationContext?) : ReactContextB
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setSourceCmd(reactTag: Int, source: ReadableMap?) {
|
||||
performOnPlayerView(reactTag) {
|
||||
it?.setSrc(Source.parse(source, reactApplicationContext))
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getCurrentPosition(reactTag: Int, promise: Promise) {
|
||||
performOnPlayerView(reactTag) {
|
||||
|
@ -0,0 +1,22 @@
|
||||
package com.google.ads.interactivemedia.v3.api;
|
||||
|
||||
public abstract class ImaSdkFactory {
|
||||
private static ImaSdkFactory instance;
|
||||
|
||||
public abstract ImaSdkSettings createImaSdkSettings();
|
||||
|
||||
public static ImaSdkFactory getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new ConcreteImaSdkFactory();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
class ConcreteImaSdkFactory extends ImaSdkFactory {
|
||||
|
||||
@Override
|
||||
public ImaSdkSettings createImaSdkSettings() {
|
||||
return new ConcreteImaSdkSettings();
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
package com.google.ads.interactivemedia.v3.api;
|
||||
|
||||
import androidx.annotation.InspectableProperty;
|
||||
|
||||
public abstract class ImaSdkSettings {
|
||||
public abstract String getLanguage();
|
||||
public abstract void setLanguage(String language);
|
||||
}
|
||||
|
||||
// Concrete Implementation
|
||||
class ConcreteImaSdkSettings extends ImaSdkSettings {
|
||||
|
||||
private String language;
|
||||
|
||||
@Override
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setLanguage(String language) {
|
||||
this.language = language;
|
||||
}
|
||||
}
|
6
android/src/main/res/drawable/circle.xml
Normal file
6
android/src/main/res/drawable/circle.xml
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/red"/>
|
||||
<size android:width="10dp" android:height="10dp"/>
|
||||
</shape>
|
@ -1,17 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_height="match_parent"
|
||||
android:layoutDirection="ltr"
|
||||
android:background="@color/midnight_black"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="@dimen/live_wrapper_margin_top"
|
||||
android:id="@+id/exo_live_container">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/exo_live_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="@dimen/position_duration_horizontal_padding"
|
||||
android:src="@drawable/circle" />
|
||||
|
||||
<TextView android:id="@+id/exo_live_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="@dimen/position_duration_text_size"
|
||||
android:textStyle="bold"
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/white"/>
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:paddingTop="@dimen/controller_wrapper_padding_top"
|
||||
android:layout_gravity="bottom"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageButton android:id="@+id/exo_prev"
|
||||
@ -70,6 +101,13 @@
|
||||
android:includeFontPadding="false"
|
||||
android:textColor="@color/silver_gray"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/exo_settings"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/ExoStyledControls.Button.Bottom.Settings"
|
||||
/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/exo_fullscreen"
|
||||
style="@style/ExoMediaButton.FullScreen"
|
||||
|
@ -2,4 +2,6 @@
|
||||
<resources>
|
||||
<color name="silver_gray">#FFBEBEBE</color>
|
||||
<color name="midnight_black">#CC000000</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
<color name="red">#FF0000</color>
|
||||
</resources>
|
@ -3,6 +3,7 @@
|
||||
<!-- margin & padding-->
|
||||
<dimen name="controller_wrapper_padding_top">4dp</dimen>
|
||||
<dimen name="seekBar_wrapper_margin_top">4dp</dimen>
|
||||
<dimen name="live_wrapper_margin_top">12dp</dimen>
|
||||
<dimen name="position_duration_horizontal_padding">4dp</dimen>
|
||||
<dimen name="full_screen_margin">4dp</dimen>
|
||||
|
||||
|
@ -16,4 +16,10 @@
|
||||
<string name="error_drm_unsupported_scheme">This device does not support the required DRM scheme</string>
|
||||
|
||||
<string name="error_drm_unknown">An unknown DRM error occurred</string>
|
||||
|
||||
<string name="settings">Settings</string>
|
||||
|
||||
<string name="playback_speed">Playback Speed</string>
|
||||
|
||||
<string name="select_playback_speed">Select Playback Speed</string>
|
||||
</resources>
|
||||
|
BIN
docs/bun.lockb
BIN
docs/bun.lockb
Binary file not shown.
@ -23,3 +23,16 @@ Example:
|
||||
onReceiveAdEvent={event => console.log(event)}
|
||||
...
|
||||
```
|
||||
|
||||
### Localization
|
||||
To change the language of the IMA SDK, you need to pass `adLanguage` prop to `Video` component. List of supported languages, you can find [here](https://developers.google.com/interactive-media-ads/docs/sdks/android/client-side/localization#locale-codes)
|
||||
|
||||
By default, ios will use system language and android will use `en`
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
...
|
||||
adLanguage="fr"
|
||||
...
|
||||
```
|
||||
|
@ -137,6 +137,20 @@ You can specify the DRM type, either by string or using the exported DRMType enu
|
||||
Valid values are, for Android: DRMType.WIDEVINE / DRMType.PLAYREADY / DRMType.CLEARKEY.
|
||||
for iOS: DRMType.FAIRPLAY
|
||||
|
||||
### `localSourceEncryptionKeyScheme`
|
||||
|
||||
<PlatformsList types={['iOS']} />
|
||||
|
||||
Set the url scheme for stream encryption key for local assets
|
||||
|
||||
Type: String
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
localSourceEncryptionKeyScheme="my-offline-key"
|
||||
```
|
||||
|
||||
## Common Usage Scenarios
|
||||
|
||||
### Send cookies to license server
|
||||
|
@ -67,7 +67,7 @@ Example:
|
||||
|
||||
### `onBandwidthUpdate`
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
Callback function that is called when the available bandwidth changes.
|
||||
|
||||
@ -103,7 +103,7 @@ Note: On Android, you must set the [reportBandwidth](#reportbandwidth) prop to e
|
||||
|
||||
### `onBuffer`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
Callback function that is called when the player buffers.
|
||||
|
||||
@ -219,16 +219,20 @@ Payload: none
|
||||
|
||||
Callback function that is called when the media is loaded and ready to play.
|
||||
|
||||
|
||||
NOTE: tracks (`audioTracks`, `textTracks` & `videoTracks`) are not available on the web.
|
||||
|
||||
Payload:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
|-------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| currentTime | number | Time in seconds where the media will start |
|
||||
| duration | number | Length of the media in seconds |
|
||||
| naturalSize | object | Properties:<br/> _ width - Width in pixels that the video was encoded at<br/> _ height - Height in pixels that the video was encoded at<br/> \* orientation - "portrait", "landscape" or "square" |
|
||||
| audioTracks | array | An array of audio track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track |
|
||||
| textTracks | array | An array of text track info objects with the following properties:<br/> _ index - Index number<br/> _ title - Description of the track<br/> _ language - 2 letter [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) or 3 letter [ISO 639-2](https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes) language code<br/> _ type - Mime type of track |
|
||||
| videoTracks | array | An array of video track info objects with the following properties:<br/> _ trackId - ID for the track<br/> _ bitrate - Bit rate in bits per second<br/> _ codecs - Comma separated list of codecs<br/> _ height - Height of the video<br/> \* width - Width of the video |
|
||||
| trackId | string | Provide key information about the video track, typically including: `Resolution`, `Bitrate`. |
|
||||
|
||||
Example:
|
||||
|
||||
@ -260,7 +264,8 @@ Example:
|
||||
{ index: 0, bitrate: 3987904, codecs: "avc1.640028", height: 720, trackId: "f1-v1-x3", width: 1280 },
|
||||
{ index: 1, bitrate: 7981888, codecs: "avc1.640028", height: 1080, trackId: "f2-v1-x3", width: 1920 },
|
||||
{ index: 2, bitrate: 1994979, codecs: "avc1.4d401f", height: 480, trackId: "f3-v1-x3", width: 848 }
|
||||
]
|
||||
],
|
||||
trackId: "720p 2400kbps"
|
||||
}
|
||||
```
|
||||
|
||||
@ -290,7 +295,7 @@ Example:
|
||||
|
||||
### `onPlaybackStateChanged`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Callback function that is called when the playback state changes.
|
||||
|
||||
@ -461,7 +466,7 @@ Payload: none
|
||||
|
||||
### `onSeek`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'Windows UWP']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'Windows UWP', 'web']} />
|
||||
|
||||
Callback function that is called when a seek completes.
|
||||
|
||||
@ -602,7 +607,7 @@ Example:
|
||||
|
||||
### `onVolumeChange`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Callback function that is called when the volume of player changes.
|
||||
|
||||
|
@ -6,7 +6,7 @@ This page shows the list of available methods
|
||||
|
||||
### `dismissFullscreenPlayer`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`dismissFullscreenPlayer(): Promise<void>`
|
||||
|
||||
@ -17,7 +17,7 @@ Take the player out of fullscreen mode.
|
||||
|
||||
### `pause`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`pause(): Promise<void>`
|
||||
|
||||
@ -25,7 +25,7 @@ Pause the video.
|
||||
|
||||
### `presentFullscreenPlayer`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`presentFullscreenPlayer(): Promise<void>`
|
||||
|
||||
@ -40,7 +40,7 @@ On Android, this puts the navigation controls in fullscreen mode. It is not a co
|
||||
|
||||
### `resume`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`resume(): Promise<void>`
|
||||
|
||||
@ -100,7 +100,7 @@ tolerance is the max distance in milliseconds from the seconds position that's a
|
||||
|
||||
### `setVolume`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`setVolume(value): Promise<void>`
|
||||
|
||||
@ -108,17 +108,27 @@ This function will change the volume exactly like [volume](./props#volume) prope
|
||||
|
||||
### `getCurrentPosition`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`getCurrentPosition(): Promise<number>`
|
||||
|
||||
This function retrieves and returns the precise current position of the video playback, measured in seconds.
|
||||
This function will throw an error if player is not initialized.
|
||||
|
||||
### `setFullScreen`
|
||||
|
||||
### `setSource`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
`setSource(source: ReactVideoSource): Promise<void>`
|
||||
|
||||
This function will change the source exactly like [source](./props#source) property.
|
||||
Changing source with this function will overide source provided as props.
|
||||
|
||||
### `setFullScreen`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`setFullScreen(fullscreen): Promise<void>`
|
||||
|
||||
If you set it to `true`, the player enters fullscreen mode. If you set it to `false`, the player exits fullscreen mode.
|
||||
@ -127,6 +137,13 @@ On iOS, this displays the video in a fullscreen view controller with controls.
|
||||
|
||||
On Android, this puts the navigation controls in fullscreen mode. It is not a complete fullscreen implementation, so you will still need to apply a style that makes the width and height match your screen dimensions to get a fullscreen video.
|
||||
|
||||
### `nativeHtmlVideoRef`
|
||||
|
||||
<PlatformsList types={['web']} />
|
||||
|
||||
A ref to the underlying html video element. This can be used if you need to integrate a 3d party, web only video library (like hls.js, shaka, video.js...).
|
||||
|
||||
|
||||
### Example Usage
|
||||
|
||||
```tsx
|
||||
@ -141,9 +158,9 @@ const someCoolFunctions = async () => {
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
videoRef.current.dismissFullscreenPlayer();
|
||||
|
||||
// pause or play the video
|
||||
videoRef.current.play();
|
||||
// pause or resume the video
|
||||
videoRef.current.pause();
|
||||
videoRef.current.resume();
|
||||
|
||||
// save video to your Photos with current filter prop
|
||||
const response = await videoRef.current.save();
|
||||
@ -178,7 +195,7 @@ Possible values are:
|
||||
|
||||
### `isCodecSupported`
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
<PlatformsList types={['Android', 'web']} />
|
||||
|
||||
Indicates whether the provided codec is supported level supported by device.
|
||||
|
||||
|
@ -8,6 +8,9 @@ This page shows the list of available properties to configure player
|
||||
|
||||
### `adTagUrl`
|
||||
|
||||
> [!WARNING]
|
||||
> Deprecated, use source.ad.adTagUrl instead
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
Sets the VAST uri to play AVOD ads.
|
||||
@ -128,15 +131,14 @@ When playing an HLS live stream with a `EXT-X-PROGRAM-DATE-TIME` tag configured,
|
||||
|
||||
### `controls`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Determines whether to show player controls.
|
||||
|
||||
- **false (default)** - Don't show player controls
|
||||
- **true** - Show player controls
|
||||
|
||||
Note on iOS, controls are always shown when in fullscreen mode.
|
||||
Note on Android, native controls are available by default.
|
||||
Controls are always shown in fullscreen mode, even when `controls={false}`.
|
||||
If needed, you can also add your controls or use a package like [react-native-video-controls](https://github.com/itsnubix/react-native-video-controls) or [react-native-media-console](https://github.com/criszz77/react-native-media-console), see [Useful Side Project](/projects).
|
||||
|
||||
### `controlsStyles`
|
||||
@ -145,25 +147,53 @@ If needed, you can also add your controls or use a package like [react-native-vi
|
||||
|
||||
Adjust the control styles. This prop is need only if `controls={true}` and is an object. See the list of prop supported below.
|
||||
|
||||
| Property | Type | Description |
|
||||
|-----------------|---------|-----------------------------------------------------------------------------------------|
|
||||
| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. |
|
||||
| seekIncrementMS | number | The default value is `10000`. You can change the value to increment forward and rewind. |
|
||||
| Property | Type | Description |
|
||||
|-------------------------------------|---------|---------------------------------------------------------------------------------------------|
|
||||
| hidePosition | boolean | Hides the position indicator. Default is `false`. |
|
||||
| hidePlayPause | boolean | Hides the play/pause button. Default is `false`. |
|
||||
| hideForward | boolean | Hides the forward button. Default is `false`. |
|
||||
| hideRewind | boolean | Hides the rewind button. Default is `false`. |
|
||||
| hideNext | boolean | Hides the next button. Default is `false`. |
|
||||
| hidePrevious | boolean | Hides the previous button. Default is `false`. |
|
||||
| hideFullscreen | boolean | Hides the fullscreen button. Default is `false`. |
|
||||
| hideSeekBar | boolean | The default value is `false`, allowing you to hide the seek bar for live broadcasts. |
|
||||
| hideDuration | boolean | The default value is `false`, allowing you to hide the duration. |
|
||||
| hideNavigationBarOnFullScreenMode | boolean | The default value is `true`, allowing you to hide the navigation bar on full-screen mode. |
|
||||
| hideNotificationBarOnFullScreenMode | boolean | The default value is `true`, allowing you to hide the notification bar on full-screen mode. |
|
||||
| hideSettingButton | boolean | The default value is `true`, allowing you to hide the setting button. |
|
||||
| seekIncrementMS | number | The default value is `10000`. You can change the value to increment forward and rewind. |
|
||||
| liveLabel | string | Allowing you to set a label for live video. |
|
||||
|
||||
Example with default values:
|
||||
|
||||
```javascript
|
||||
controlsStyles={{
|
||||
hidePosition: false,
|
||||
hidePlayPause: false,
|
||||
hideForward: false,
|
||||
hideRewind: false,
|
||||
hideNext: false,
|
||||
hidePrevious: false,
|
||||
hideFullscreen: false,
|
||||
hideSeekBar: false,
|
||||
hideDuration: false,
|
||||
hideNavigationBarOnFullScreenMode: true,
|
||||
hideNotificationBarOnFullScreenMode: true,
|
||||
hideSettingButton: true,
|
||||
seekIncrementMS: 10000,
|
||||
liveLabel: "LIVE"
|
||||
}}
|
||||
```
|
||||
|
||||
### `contentStartTime`
|
||||
|
||||
> [!WARNING]
|
||||
> Deprecated, use source.contentStartTime instead
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
|
||||
The start time in ms for SSAI content. This determines at what time to load the video info like resolutions. Use this only when you have SSAI stream where ads resolution is not the same as content resolution.
|
||||
Note: This feature only works on DASH streams
|
||||
|
||||
### `debug`
|
||||
|
||||
@ -270,7 +300,7 @@ Whether this video view should be focusable with a non-touch input device, eg. r
|
||||
|
||||
### `fullscreen`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Controls whether the player enters fullscreen on play.
|
||||
See [presentFullscreenPlayer](#presentfullscreenplayer) for details.
|
||||
@ -286,7 +316,7 @@ If a preferred [fullscreenOrientation](#fullscreenorientation) is set, causes th
|
||||
|
||||
### `fullscreenOrientation`
|
||||
|
||||
<PlatformsList types={['iOS', 'visionOS']} />
|
||||
<PlatformsList types={['iOS', 'visionOS', 'web']} />
|
||||
|
||||
- **all (default)** -
|
||||
- **landscape**
|
||||
@ -330,19 +360,6 @@ Controls the iOS silent switch behavior
|
||||
- **"ignore"** - Play audio even if the silent switch is set
|
||||
- **"obey"** - Don't play audio if the silent switch is set
|
||||
|
||||
### `localSourceEncryptionKeyScheme`
|
||||
|
||||
<PlatformsList types={['iOS']} />
|
||||
|
||||
Set the url scheme for stream encryption key for local assets
|
||||
|
||||
Type: String
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
localSourceEncryptionKeyScheme="my-offline-key"
|
||||
```
|
||||
|
||||
### `maxBitRate`
|
||||
|
||||
@ -352,6 +369,9 @@ Sets the desired limit, in bits per second, of network bandwidth consumption whe
|
||||
|
||||
Default: 0. Don't limit the maxBitRate.
|
||||
|
||||
Note: This property can interact with selectedVideoTrack.
|
||||
To use `maxBitrate`, selectedVideoTrack shall be undefined or `{type: SelectedVideoTrackType.AUTO}`.
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
@ -506,14 +526,29 @@ Speed at which the media should play.
|
||||
|
||||
<PlatformsList types={['All']} />
|
||||
|
||||
Allows you to create custom components to display while the video is loading. If `renderLoader` is provided, `poster` and `posterResizeMode` will be ignored.
|
||||
Allows you to create custom components to display while the video is loading.
|
||||
If `renderLoader` is provided, `poster` and `posterResizeMode` will be ignored.
|
||||
renderLoader is either a component or a function returning a component.
|
||||
It is recommended to use the function for optimization matter.
|
||||
|
||||
`renderLoader` function be called with parameters of type `ReactVideoRenderLoaderProps` to be able to adapt loader
|
||||
|
||||
```typescript
|
||||
interface ReactVideoRenderLoaderProps {
|
||||
source?: ReactVideoSource; /// source of the video
|
||||
style?: StyleProp<ImageStyle>; /// style to apply
|
||||
resizeMode?: EnumValues<VideoResizeMode>; /// resizeMode provided to the video component
|
||||
}
|
||||
````
|
||||
|
||||
Sample:
|
||||
|
||||
```javascript
|
||||
<Video>
|
||||
renderLoader={
|
||||
renderLoader={() => (
|
||||
<View>
|
||||
<Text>Custom Loader</Text>
|
||||
</View>
|
||||
</View>)
|
||||
}
|
||||
</Video>
|
||||
````
|
||||
@ -672,6 +707,8 @@ The docs for this prop are incomplete and will be updated as each option is inve
|
||||
|
||||
> ⚠️ on iOS, you file name must not contain spaces eg. `my video.mp4` will not work, use `my-video.mp4` instead
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
|
||||
|
||||
Example:
|
||||
|
||||
Pass directly the asset to play (deprecated)
|
||||
@ -762,7 +799,7 @@ The following other types are supported on some platforms, but aren't fully docu
|
||||
|
||||
#### Using DRM content
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'tvOS']} />
|
||||
|
||||
To setup DRM please follow [this guide](/component/drm)
|
||||
|
||||
@ -780,12 +817,10 @@ Example:
|
||||
},
|
||||
```
|
||||
|
||||
> ⚠️ DRM is not supported on visionOS yet
|
||||
|
||||
|
||||
#### Start playback at a specific point in time
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
Provide an optional `startPosition` for video. Value is in milliseconds. If the `cropStart` prop is applied, it will be applied from that point forward.
|
||||
(If it is negative or undefined or null, it is ignored)
|
||||
@ -828,6 +863,33 @@ source={{
|
||||
}}
|
||||
```
|
||||
|
||||
### `ad`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
Sets the ad configuration.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
ad: {
|
||||
adTagUrl="https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator="
|
||||
adLanguage="fr"
|
||||
}
|
||||
```
|
||||
|
||||
See: [./ads.md] for more informations
|
||||
|
||||
Note: You need enable IMA SDK in gradle or pod file - [enable client side ads insertion](/installation)
|
||||
|
||||
|
||||
#### `contentStartTime`
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
|
||||
The start time in ms for SSAI content. This determines at what time to load the video info like resolutions. Use this only when you have SSAI stream where ads resolution is not the same as content resolution.
|
||||
Note: This feature only works on DASH streams
|
||||
|
||||
#### `textTracksAllowChunklessPreparation`
|
||||
<PlatformsList types={['Android']} />
|
||||
|
||||
@ -843,37 +905,19 @@ source={{
|
||||
}}
|
||||
```
|
||||
|
||||
### `subtitleStyle`
|
||||
|
||||
| Property | Description | Platforms |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| fontSize | Adjust the font size of the subtitles. Default: font size of the device | Android |
|
||||
| paddingTop | Adjust the top padding of the subtitles. Default: 0 | Android |
|
||||
| paddingBottom | Adjust the bottom padding of the subtitles. Default: 0 | Android |
|
||||
| paddingLeft | Adjust the left padding of the subtitles. Default: 0 | Android |
|
||||
| paddingRight | Adjust the right padding of the subtitles. Default: 0 | Android |
|
||||
| opacity | Adjust the visibility of subtitles with 0 hiding and 1 fully showing them. Android supports float values between 0 and 1 for varying opacity levels, whereas iOS supports only 0 or 1. Default: 1. | Android, iOS |
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
subtitleStyle={{ paddingBottom: 50, fontSize: 20, opacity: 0 }}
|
||||
```
|
||||
|
||||
### `textTracks`
|
||||
|
||||
#### `textTracks`
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
|
||||
Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format:
|
||||
|
||||
> ⚠️ This feature does not work with HLS playlists (e.g m3u8) on iOS
|
||||
|
||||
| Property | Description |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| title | Descriptive name for the track |
|
||||
| language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
|
||||
| type | Mime type of the track _ TextTrackType.SRT - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 |
|
||||
| uri | URL for the text track. Currently, only tracks hosted on a webserver are supported |
|
||||
| Property | Description |
|
||||
|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| title | Descriptive name for the track |
|
||||
| language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
|
||||
| type | Mime type of the track _ TextTrackType.SUBRIP - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 |
|
||||
| uri | URL for the text track. Currently, only tracks hosted on a webserver are supported |
|
||||
|
||||
On iOS, sidecar text tracks are only supported for individual files, not HLS playlists. For HLS, you should include the text tracks as part of the playlist.
|
||||
|
||||
@ -894,7 +938,92 @@ textTracks={[
|
||||
{
|
||||
title: "Spanish Subtitles",
|
||||
language: "es",
|
||||
type: TextTrackType.SRT, // "application/x-subrip"
|
||||
type: TextTrackType.SUBRIP, // "application/x-subrip"
|
||||
uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt"
|
||||
}
|
||||
]}
|
||||
```
|
||||
|
||||
### `subtitleStyle`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
|
||||
| Property | Platform | Description | Platforms |
|
||||
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| fontSize | Android | Adjust the font size of the subtitles. Default: font size of the device | Android |
|
||||
| paddingTop | Android | Adjust the top padding of the subtitles. Default: 0 | Android |
|
||||
| paddingBottom | Android | Adjust the bottom padding of the subtitles. Default: 0 | Android |
|
||||
| paddingLeft | Android | Adjust the left padding of the subtitles. Default: 0 | Android |
|
||||
| paddingRight | Android | Adjust the right padding of the subtitles. Default: 0 | Android |
|
||||
| opacity | Android, iOS | Adjust the visibility of subtitles with 0 hiding and 1 fully showing them. Android supports float values between 0 and 1 for varying opacity levels, whereas iOS supports only 0 or 1. Default: 1. | Android, iOS |
|
||||
| subtitlesFollowVideo | Android | Boolean to adjust position of subtitles. Default: true |
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
subtitleStyle={{ paddingBottom: 50, fontSize: 20, opacity: 0 }}
|
||||
```
|
||||
|
||||
Note for `subtitlesFollowVideo`
|
||||
|
||||
`subtitlesFollowVideo` helps to determine how the subtitles are positionned.
|
||||
To understand this prop you need to understand how views management works.
|
||||
The main View style passed to react native video is the position reserved to display the video component.
|
||||
It may not match exactly the real video size.
|
||||
For exemple, you can pass a 4:3 video view and render a 16:9 video inside.
|
||||
So there is a second view, the video view.
|
||||
|
||||
Subtitles are managed in a third view.
|
||||
|
||||
First react-native-video resize the video to keep aspect ratio (depending on `resizeMode` property) and put it in main view.
|
||||
|
||||
* When putting subtitlesFollowVideo to true, the subtitle view will be adapt to the video view.
|
||||
It means that if the video is displayed out of screen, the subtitles may also be displayed out of screen.
|
||||
|
||||
* When putting subtitlesFollowVideo to false, the subtitle view will keep adapting to the main view.
|
||||
It means that if the video is displayed out of screen, the subtitles may also be displayed out of screen.
|
||||
|
||||
This prop can be changed on runtime.
|
||||
|
||||
### `textTracks`
|
||||
|
||||
> [!WARNING]
|
||||
> deprecated, use source.textTracks instead. changing text tracks will restart playback
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
|
||||
Load one or more "sidecar" text tracks. This takes an array of objects representing each track. Each object should have the format:
|
||||
|
||||
> ⚠️ This feature does not work with HLS playlists (e.g m3u8) on iOS
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| title | Descriptive name for the track |
|
||||
| language | 2 letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) representing the language |
|
||||
| type | Mime type of the track _ TextTrackType.SUBRIP - SubRip (.srt) _ TextTrackType.TTML - TTML (.ttml) \* TextTrackType.VTT - WebVTT (.vtt)iOS only supports VTT, Android supports all 3 |
|
||||
| uri | URL for the text track. Currently, only tracks hosted on a webserver are supported |
|
||||
|
||||
On iOS, sidecar text tracks are only supported for individual files, not HLS playlists. For HLS, you should include the text tracks as part of the playlist.
|
||||
|
||||
Note: Due to iOS limitations, sidecar text tracks are not compatible with Airplay. If textTracks are specified, AirPlay support will be automatically disabled.
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
import { TextTrackType }, Video from 'react-native-video';
|
||||
|
||||
textTracks={[
|
||||
{
|
||||
title: "English CC",
|
||||
language: "en",
|
||||
type: TextTrackType.VTT, // "text/vtt"
|
||||
uri: "https://bitdash-a.akamaihd.net/content/sintel/subtitles/subtitles_en.vtt"
|
||||
},
|
||||
{
|
||||
title: "Spanish Subtitles",
|
||||
language: "es",
|
||||
type: TextTrackType.SUBRIP, // "application/x-subrip"
|
||||
uri: "https://durian.blender.org/wp-content/content/subtitles/sintel_es.srt"
|
||||
}
|
||||
]}
|
||||
@ -902,7 +1031,7 @@ textTracks={[
|
||||
|
||||
### `showNotificationControls`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
Controls whether to show media controls in the notification area.
|
||||
For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
|
||||
@ -999,3 +1128,68 @@ Adjust the volume.
|
||||
- **1.0 (default)** - Play at full volume
|
||||
- **0.0** - Mute the audio
|
||||
- **Other values** - Reduce volume
|
||||
|
||||
### `cmcd`
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
|
||||
Configure CMCD (Common Media Client Data) parameters. CMCD is a standard for conveying client-side metrics and capabilities to servers, which can help improve streaming quality and performance.
|
||||
|
||||
For detailed information about CMCD, please refer to the [CTA-5004 Final Specification](https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf).
|
||||
|
||||
- **false (default)** - Don't use CMCD
|
||||
- **true** - Use default CMCD configuration
|
||||
- **object** - Use custom CMCD configuration
|
||||
|
||||
When providing an object, you can configure the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|-------------------------|----------------------------------------------------|
|
||||
| `mode` | `CmcdMode` | The mode for sending CMCD data |
|
||||
| `request` | `CmcdData` | Custom key-value pairs for the request object |
|
||||
| `session` | `CmcdData` | Custom key-value pairs for the session object |
|
||||
| `object` | `CmcdData` | Custom key-value pairs for the object metadata |
|
||||
| `status` | `CmcdData` | Custom key-value pairs for the status information |
|
||||
|
||||
Note: The `mode` property defaults to `CmcdMode.MODE_QUERY_PARAMETER` if not specified.
|
||||
|
||||
#### `CmcdMode`
|
||||
CmcdMode is an enum that defines how CMCD data should be sent:
|
||||
- `CmcdMode.MODE_REQUEST_HEADER` (0) - Send CMCD data in the HTTP request headers.
|
||||
- `CmcdMode.MODE_QUERY_PARAMETER` (1) - Send CMCD data as query parameters in the URL.
|
||||
|
||||
#### `CmcdData`
|
||||
CmcdData is a type representing custom key-value pairs for CMCD data. It's defined as:
|
||||
|
||||
```typescript
|
||||
type CmcdData = Record<`${string}-${string}`, string | number>;
|
||||
```
|
||||
|
||||
Custom key names MUST include a hyphenated prefix to prevent namespace collisions. It's recommended to use a reverse-DNS syntax for custom prefixes.
|
||||
|
||||
Example:
|
||||
|
||||
```javascript
|
||||
<Video
|
||||
source={{
|
||||
uri: 'https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
|
||||
cmcd: {
|
||||
mode: CmcdMode.MODE_QUERY_PARAMETER,
|
||||
request: {
|
||||
'com-custom-key': 'custom-value'
|
||||
},
|
||||
session: {
|
||||
sid: 'session-id'
|
||||
},
|
||||
object: {
|
||||
br: '3000',
|
||||
d: '4000'
|
||||
},
|
||||
status: {
|
||||
rtp: '1200'
|
||||
}
|
||||
}
|
||||
}}
|
||||
// or other video props
|
||||
/>
|
||||
```
|
||||
|
@ -8,6 +8,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap
|
||||
- Exoplayer for android
|
||||
- AVplayer for iOS, tvOS and visionOS
|
||||
- Windows UWP for windows
|
||||
- HTML5 for web
|
||||
- Trick mode support
|
||||
- Subtitles (embeded or side loaded)
|
||||
- DRM support
|
||||
|
@ -181,3 +181,12 @@ Select RCTVideo-tvOS
|
||||
Run `pod install` in the `visionos` directory of your project
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>web</summary>
|
||||
|
||||
Nothing to do, everything should work out of the box.
|
||||
|
||||
Note that only basic video support is present, no hls/dash or ads/drm for now.
|
||||
|
||||
</details>
|
||||
|
@ -86,11 +86,6 @@ buildscript {
|
||||
}
|
||||
```
|
||||
|
||||
### Desugaring
|
||||
to be able to link you may also need to enable coreLibraryDesugaringEnabled in your app.
|
||||
|
||||
See: https://developer.android.com/studio/write/java8-support?hl=fr#library-desugaring for more informations.
|
||||
|
||||
## It's still not working
|
||||
|
||||
You can try to open a ticket now !
|
||||
You can try to open a ticket or contact us for [premium support](https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=docs#Contact)!
|
||||
|
@ -22,7 +22,7 @@ In your project Podfile add support for static dependency linking. This is requi
|
||||
|
||||
Add `use_frameworks! :linkage => :static` just under `platform :ios` in your ios project Podfile.
|
||||
|
||||
[See the example ios project for reference](examples/basic/ios/Podfile#L5)
|
||||
[See the example ios project for reference](https://github.com/TheWidlarzGroup/react-native-video/blob/master/examples/basic/ios/Podfile#L5)
|
||||
|
||||
##### podspec
|
||||
|
||||
@ -34,7 +34,7 @@ You can remove following lines from your podfile as they are not necessary anymo
|
||||
- `pod 'react-native-video/VideoCaching', :path => '../node_modules/react-native-video/react-native-video.podspec'`
|
||||
```
|
||||
|
||||
If you were previously using VideoCaching, you should $RNVideoUseVideoCaching flag in your podspec, see: [installation section](https://react-native-video.github.io/react-native-video/installation#video-caching)
|
||||
If you were previously using VideoCaching, you should $RNVideoUseVideoCaching flag in your podspec, see: [installation section](https://thewidlarzgroup.github.io/react-native-video/installation#video-caching)
|
||||
|
||||
#### Android
|
||||
|
||||
|
@ -52,6 +52,62 @@ export default {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
toc: {
|
||||
extraContent: (
|
||||
<>
|
||||
<style>{`
|
||||
:is(html[class~=dark]) .extra-container {
|
||||
background-color: #87ccef;
|
||||
}
|
||||
:is(html[class~=dark]) .extra-text {
|
||||
color: #171717;
|
||||
}
|
||||
:is(html[class~=dark]) .extra-button {
|
||||
background-color: #171717;
|
||||
}
|
||||
.extra-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
background-color: #171717;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.extra-text {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
.extra-button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 500;
|
||||
background-color: #f9d85b;
|
||||
transition: transform 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
.extra-button:hover {
|
||||
transform: scale(1.05);
|
||||
background-color: #fff;
|
||||
}
|
||||
`}</style>
|
||||
<div className="extra-container">
|
||||
<span className="extra-text">We are TheWidlarzGroup</span>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://www.thewidlarzgroup.com/?utm_source=rnv&utm_medium=docs#Contact"
|
||||
className="extra-button"
|
||||
rel="noreferrer">
|
||||
Premium support →
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
useNextSeoProps() {
|
||||
return {
|
||||
titleTemplate: '%s – Video',
|
||||
|
@ -83,10 +83,6 @@ android {
|
||||
namespace "com.videoplayer"
|
||||
|
||||
compileOptions {
|
||||
// These options are necessary to be able to build from source
|
||||
// coreLibraryDesugaringEnabled is mandatory to be able to build exoplayer from source
|
||||
// uncomment this line if you want to build from source
|
||||
// coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
@ -157,8 +153,6 @@ dependencies {
|
||||
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
|
||||
}
|
||||
}
|
||||
// coreLibraryDesugaring is mandatory to be able to build exoplayer from source
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
}
|
||||
|
||||
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"web": "expo start --web",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"windows": "react-native run-windows",
|
||||
@ -13,13 +14,17 @@
|
||||
"pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~3.2.1",
|
||||
"@react-native-picker/picker": "2.7.5",
|
||||
"expo": "^51.0.17",
|
||||
"expo-asset": "^10.0.9",
|
||||
"expo-image": "^1.12.12",
|
||||
"expo": "^51.0.32",
|
||||
"expo-asset": "~10.0.10",
|
||||
"expo-image": "^1.12.15",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.3",
|
||||
"react-native-windows": "0.74.1"
|
||||
"react-native": "0.74.5",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native-web": "~0.19.10",
|
||||
"react-native-windows": "0.74.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
|
@ -1,8 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
import React, {type FC, useCallback, useRef, useState} from 'react';
|
||||
import React, {type FC, useCallback, useRef, useState, useEffect} from 'react';
|
||||
|
||||
import {Platform, TouchableOpacity, View} from 'react-native';
|
||||
import {Platform, TouchableOpacity, View, StatusBar} from 'react-native';
|
||||
|
||||
import Video, {
|
||||
VideoRef,
|
||||
@ -30,11 +30,20 @@ import Video, {
|
||||
type SelectedTrack,
|
||||
type SelectedVideoTrack,
|
||||
type EnumValues,
|
||||
OnBandwidthUpdateData,
|
||||
ControlsStyles,
|
||||
} from 'react-native-video';
|
||||
import styles from './styles';
|
||||
import {type AdditionalSourceInfo} from './types';
|
||||
import {bufferConfig, srcList, textTracksSelectionBy} from './constants';
|
||||
import {
|
||||
bufferConfig,
|
||||
isAndroid,
|
||||
srcList,
|
||||
textTracksSelectionBy,
|
||||
audioTracksSelectionBy,
|
||||
} from './constants';
|
||||
import {Overlay, toast, VideoLoader} from './components';
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
|
||||
type Props = NonNullable<unknown>;
|
||||
|
||||
@ -103,19 +112,30 @@ const VideoPlayer: FC<Props> = ({}) => {
|
||||
goToChannel((srcListId + srcList.length - 1) % srcList.length);
|
||||
}, [goToChannel, srcListId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAndroid) {
|
||||
NavigationBar.setVisibilityAsync('visible');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onAudioTracks = (data: OnAudioTracksData) => {
|
||||
console.log('onAudioTracks', data);
|
||||
const selectedTrack = data.audioTracks?.find((x: AudioTrack) => {
|
||||
return x.selected;
|
||||
});
|
||||
if (selectedTrack?.index) {
|
||||
setAudioTracks(data.audioTracks);
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: selectedTrack.index,
|
||||
});
|
||||
} else {
|
||||
setAudioTracks(data.audioTracks);
|
||||
let value;
|
||||
if (audioTracksSelectionBy === SelectedTrackType.INDEX) {
|
||||
value = selectedTrack?.index;
|
||||
} else if (audioTracksSelectionBy === SelectedTrackType.LANGUAGE) {
|
||||
value = selectedTrack?.language;
|
||||
} else if (audioTracksSelectionBy === SelectedTrackType.TITLE) {
|
||||
value = selectedTrack?.title;
|
||||
}
|
||||
setAudioTracks(data.audioTracks);
|
||||
setSelectedAudioTrack({
|
||||
type: audioTracksSelectionBy,
|
||||
value: value,
|
||||
});
|
||||
};
|
||||
|
||||
const onVideoTracks = (data: OnVideoTracksData) => {
|
||||
@ -128,22 +148,19 @@ const VideoPlayer: FC<Props> = ({}) => {
|
||||
return x?.selected;
|
||||
});
|
||||
|
||||
if (selectedTrack?.language) {
|
||||
setTextTracks(data.textTracks);
|
||||
if (textTracksSelectionBy === 'index') {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: selectedTrack?.index,
|
||||
});
|
||||
} else {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.LANGUAGE,
|
||||
value: selectedTrack?.language,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTextTracks(data.textTracks);
|
||||
setTextTracks(data.textTracks);
|
||||
let value;
|
||||
if (textTracksSelectionBy === SelectedTrackType.INDEX) {
|
||||
value = selectedTrack?.index;
|
||||
} else if (textTracksSelectionBy === SelectedTrackType.LANGUAGE) {
|
||||
value = selectedTrack?.language;
|
||||
} else if (textTracksSelectionBy === SelectedTrackType.TITLE) {
|
||||
value = selectedTrack?.title;
|
||||
}
|
||||
setSelectedTextTrack({
|
||||
type: textTracksSelectionBy,
|
||||
value: value,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoad = (data: OnLoadData) => {
|
||||
@ -213,21 +230,42 @@ const VideoPlayer: FC<Props> = ({}) => {
|
||||
console.log('onPlaybackStateChanged', data);
|
||||
};
|
||||
|
||||
const onVideoBandwidthUpdate = (data: OnBandwidthUpdateData) => {
|
||||
console.log('onVideoBandwidthUpdate', data);
|
||||
};
|
||||
|
||||
const onFullScreenExit = () => {
|
||||
// iOS pauses video on exit from full screen
|
||||
Platform.OS === 'ios' && setPaused(true);
|
||||
};
|
||||
|
||||
const _renderLoader = showPoster ? () => <VideoLoader /> : undefined;
|
||||
|
||||
const _subtitleStyle = {subtitlesFollowVideo: true};
|
||||
const _controlsStyles : ControlsStyles = {
|
||||
hideNavigationBarOnFullScreenMode: true,
|
||||
hideNotificationBarOnFullScreenMode: true,
|
||||
liveLabel: "LIVE"
|
||||
};
|
||||
const _bufferConfig = {
|
||||
...bufferConfig,
|
||||
cacheSizeMB: useCache ? 200 : 0,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
videoRef.current?.setSource(currentSrc)
|
||||
}, [currentSrc])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar animated={true} backgroundColor="black" hidden={false} />
|
||||
|
||||
{(srcList[srcListId] as AdditionalSourceInfo)?.noView ? null : (
|
||||
<TouchableOpacity style={viewStyle}>
|
||||
<Video
|
||||
showNotificationControls={showNotificationControls}
|
||||
ref={videoRef}
|
||||
source={currentSrc as ReactVideoSource}
|
||||
textTracks={additional?.textTracks}
|
||||
adTagUrl={additional?.adTagUrl}
|
||||
// source={currentSrc as ReactVideoSource}
|
||||
drm={additional?.drm}
|
||||
style={viewStyle}
|
||||
rate={rate}
|
||||
@ -252,22 +290,22 @@ const VideoPlayer: FC<Props> = ({}) => {
|
||||
onAspectRatio={onAspectRatio}
|
||||
onReadyForDisplay={onReadyForDisplay}
|
||||
onBuffer={onVideoBuffer}
|
||||
onBandwidthUpdate={onVideoBandwidthUpdate}
|
||||
onSeek={onSeek}
|
||||
repeat={repeat}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
selectedVideoTrack={selectedVideoTrack}
|
||||
playInBackground={false}
|
||||
bufferConfig={{
|
||||
...bufferConfig,
|
||||
cacheSizeMB: useCache ? 200 : 0,
|
||||
}}
|
||||
bufferConfig={_bufferConfig}
|
||||
preventsDisplaySleepDuringVideoPlayback={true}
|
||||
renderLoader={showPoster ? <VideoLoader /> : undefined}
|
||||
renderLoader={_renderLoader}
|
||||
onPlaybackRateChange={onPlaybackRateChange}
|
||||
onPlaybackStateChanged={onPlaybackStateChanged}
|
||||
bufferingStrategy={BufferingStrategyType.DEFAULT}
|
||||
debug={{enable: true, thread: true}}
|
||||
subtitleStyle={_subtitleStyle}
|
||||
controlsStyles={_controlsStyles}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
@ -1,19 +1,25 @@
|
||||
import {Picker} from '@react-native-picker/picker';
|
||||
import {Text} from 'react-native';
|
||||
import type {AudioTrack, SelectedTrack} from 'react-native-video';
|
||||
import {
|
||||
SelectedTrackType,
|
||||
type AudioTrack,
|
||||
type SelectedTrack,
|
||||
} from 'react-native-video';
|
||||
import styles from '../styles';
|
||||
import React from 'react';
|
||||
|
||||
export interface AudioTrackSelectorType {
|
||||
audioTracks: Array<AudioTrack>;
|
||||
selectedAudioTrack: SelectedTrack | undefined;
|
||||
onValueChange: (arg0: string) => void;
|
||||
onValueChange: (arg0: string | number) => void;
|
||||
audioTracksSelectionBy: SelectedTrackType;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector = ({
|
||||
audioTracks,
|
||||
selectedAudioTrack,
|
||||
onValueChange,
|
||||
audioTracksSelectionBy,
|
||||
}: AudioTrackSelectorType) => {
|
||||
return (
|
||||
<>
|
||||
@ -25,7 +31,7 @@ export const AudioTrackSelector = ({
|
||||
onValueChange={itemValue => {
|
||||
if (itemValue !== 'empty') {
|
||||
console.log('on audio value change ' + itemValue);
|
||||
onValueChange(`${itemValue}`);
|
||||
onValueChange(itemValue);
|
||||
}
|
||||
}}>
|
||||
{audioTracks?.length <= 0 ? (
|
||||
@ -37,11 +43,19 @@ export const AudioTrackSelector = ({
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
let value;
|
||||
if (audioTracksSelectionBy === SelectedTrackType.INDEX) {
|
||||
value = track.index;
|
||||
} else if (audioTracksSelectionBy === SelectedTrackType.LANGUAGE) {
|
||||
value = track.language;
|
||||
} else if (audioTracksSelectionBy === SelectedTrackType.TITLE) {
|
||||
value = track.title;
|
||||
}
|
||||
return (
|
||||
<Picker.Item
|
||||
label={`${track.language} - ${track.title} - ${track.selected}`}
|
||||
value={`${track.index}`}
|
||||
key={`${track.index}`}
|
||||
label={`${value} - ${track.selected}`}
|
||||
value={`${value}`}
|
||||
key={`${value}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -21,7 +21,7 @@ interface MultiValueControlType<T> {
|
||||
onPress: (arg: T) => void;
|
||||
}
|
||||
|
||||
const MultiValueControl = <T extends number | string | ResizeMode>({
|
||||
export const MultiValueControl = <T extends number | string | ResizeMode>({
|
||||
values,
|
||||
selected,
|
||||
onPress,
|
@ -7,9 +7,12 @@ import React, {
|
||||
} from 'react';
|
||||
import {View} from 'react-native';
|
||||
import styles from '../styles.tsx';
|
||||
import ToggleControl from '../ToggleControl.tsx';
|
||||
import {isAndroid, isIos, textTracksSelectionBy} from '../constants';
|
||||
import MultiValueControl from '../MultiValueControl.tsx';
|
||||
import {
|
||||
isAndroid,
|
||||
isIos,
|
||||
textTracksSelectionBy,
|
||||
audioTracksSelectionBy,
|
||||
} from '../constants';
|
||||
import {
|
||||
ResizeMode,
|
||||
VideoRef,
|
||||
@ -23,14 +26,15 @@ import {
|
||||
type VideoTrack,
|
||||
type AudioTrack,
|
||||
} from 'react-native-video';
|
||||
import {
|
||||
toast,
|
||||
Seeker,
|
||||
AudioTrackSelector,
|
||||
TextTrackSelector,
|
||||
VideoTrackSelector,
|
||||
TopControl,
|
||||
} from '../components';
|
||||
|
||||
import {toast} from './Toast';
|
||||
import {Seeker} from './Seeker';
|
||||
import {AudioTrackSelector} from './AudioTracksSelector';
|
||||
import {VideoTrackSelector} from './VideoTracksSelector';
|
||||
import {TextTrackSelector} from './TextTracksSelector';
|
||||
import {TopControl} from './TopControl';
|
||||
import {ToggleControl} from './ToggleControl';
|
||||
import {MultiValueControl} from './MultiValueControl';
|
||||
|
||||
type Props = {
|
||||
channelDown: () => void;
|
||||
@ -149,27 +153,20 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
|
||||
setShowNotificationControls(prev => !prev);
|
||||
};
|
||||
|
||||
const onSelectedAudioTrackChange = (itemValue: string) => {
|
||||
const onSelectedAudioTrackChange = (itemValue: string | number) => {
|
||||
console.log('on audio value change ' + itemValue);
|
||||
if (itemValue === 'none') {
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.DISABLED,
|
||||
});
|
||||
} else {
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: itemValue,
|
||||
});
|
||||
setSelectedAudioTrack({type: audioTracksSelectionBy, value: itemValue});
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectedTextTrackChange = (itemValue: string) => {
|
||||
console.log('on value change ' + itemValue);
|
||||
const type =
|
||||
textTracksSelectionBy === 'index'
|
||||
? SelectedTrackType.INDEX
|
||||
: SelectedTrackType.LANGUAGE;
|
||||
setSelectedTextTrack({type, value: itemValue});
|
||||
setSelectedTextTrack({type: textTracksSelectionBy, value: itemValue});
|
||||
};
|
||||
|
||||
const onSelectedVideoTrackChange = (itemValue: string) => {
|
||||
@ -329,6 +326,7 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
|
||||
audioTracks={audioTracks}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
onValueChange={onSelectedAudioTrackChange}
|
||||
audioTracksSelectionBy={audioTracksSelectionBy}
|
||||
/>
|
||||
<TextTrackSelector
|
||||
textTracks={textTracks}
|
||||
|
@ -1,6 +1,10 @@
|
||||
import {Picker} from '@react-native-picker/picker';
|
||||
import {Text} from 'react-native';
|
||||
import type {TextTrack, SelectedTrack} from 'react-native-video';
|
||||
import {
|
||||
type TextTrack,
|
||||
type SelectedTrack,
|
||||
SelectedTrackType,
|
||||
} from 'react-native-video';
|
||||
import styles from '../styles';
|
||||
import React from 'react';
|
||||
|
||||
@ -38,23 +42,15 @@ export const TextTrackSelector = ({
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
if (textTracksSelectionBy === 'index') {
|
||||
return (
|
||||
<Picker.Item
|
||||
label={`${track.index}`}
|
||||
value={track.index}
|
||||
key={track.index}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Picker.Item
|
||||
label={track.language}
|
||||
value={track.language}
|
||||
key={track.language}
|
||||
/>
|
||||
);
|
||||
let value;
|
||||
if (textTracksSelectionBy === SelectedTrackType.INDEX) {
|
||||
value = track.index;
|
||||
} else if (textTracksSelectionBy === SelectedTrackType.LANGUAGE) {
|
||||
value = track.language;
|
||||
} else if (textTracksSelectionBy === SelectedTrackType.TITLE) {
|
||||
value = track.title;
|
||||
}
|
||||
return <Picker.Item label={`${value}`} value={value} key={value} />;
|
||||
})}
|
||||
</Picker>
|
||||
</>
|
||||
|
@ -25,7 +25,7 @@ interface ToggleControlType {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const ToggleControl = ({
|
||||
export const ToggleControl = ({
|
||||
isSelected,
|
||||
selectedText,
|
||||
unselectedText,
|
@ -7,3 +7,5 @@ export * from './TextTracksSelector';
|
||||
export * from './Overlay';
|
||||
export * from './TopControl';
|
||||
export * from './Toast';
|
||||
export * from './ToggleControl';
|
||||
export * from './MultiValueControl';
|
||||
|
@ -2,13 +2,19 @@ import {
|
||||
BufferConfig,
|
||||
DRMType,
|
||||
ISO639_1,
|
||||
SelectedTrackType,
|
||||
TextTrackType,
|
||||
} from 'react-native-video';
|
||||
import {SampleVideoSource} from '../types';
|
||||
import {localeVideo} from '../assets';
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
export const textTracksSelectionBy = 'index';
|
||||
// This constant allows to change how the sample behaves regarding to audio and texts selection.
|
||||
// You can change it to change how selector will use tracks information.
|
||||
// by default, index will be displayed and index will be applied to selected tracks.
|
||||
// You can also use LANGUAGE or TITLE
|
||||
export const textTracksSelectionBy = SelectedTrackType.INDEX;
|
||||
export const audioTracksSelectionBy = SelectedTrackType.INDEX;
|
||||
|
||||
export const isIos = Platform.OS === 'ios';
|
||||
|
||||
@ -25,6 +31,10 @@ export const srcAllPlatformList = [
|
||||
cropStart: 3000,
|
||||
cropEnd: 10000,
|
||||
},
|
||||
{
|
||||
description: 'video with 90° rotation',
|
||||
uri: 'https://bn-dev.fra1.digitaloceanspaces.com/km-tournament/uploads/rn_image_picker_lib_temp_2ee86a27_9312_4548_84af_7fd75d9ad4dd_ad8b20587a.mp4',
|
||||
},
|
||||
{
|
||||
description: 'local file portrait',
|
||||
uri: localeVideo.portrait,
|
||||
@ -68,6 +78,14 @@ export const srcAllPlatformList = [
|
||||
description: 'another bunny (can be saved)',
|
||||
uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4',
|
||||
headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'},
|
||||
metadata: {
|
||||
title: 'Custom Title',
|
||||
subtitle: 'Custom Subtitle',
|
||||
artist: 'Custom Artist',
|
||||
description: 'Custom Description',
|
||||
imageUri:
|
||||
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'sintel with subtitles',
|
||||
@ -78,6 +96,11 @@ export const srcAllPlatformList = [
|
||||
uri: 'https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
|
||||
startPosition: 50000,
|
||||
},
|
||||
{
|
||||
description: 'mp3 with texttrack',
|
||||
uri: 'https://traffic.libsyn.com/democracynow/wx2024-0702_SOT_DeadCalm-LucileSmith-FULL-V2.mxf-audio.mp3', // an mp3 file
|
||||
textTracks: [], // empty text track list
|
||||
},
|
||||
{
|
||||
description: 'BigBugBunny sideLoaded subtitles',
|
||||
// sideloaded subtitles wont work for streaming like HLS on ios
|
||||
@ -92,11 +115,19 @@ export const srcAllPlatformList = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '(mp4) big buck bunny With Ads',
|
||||
ad: {
|
||||
adTagUrl:
|
||||
'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=',
|
||||
},
|
||||
uri: 'https://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4',
|
||||
},
|
||||
];
|
||||
|
||||
export const srcIosList = [];
|
||||
export const srcIosList: SampleVideoSource[] = [];
|
||||
|
||||
export const srcAndroidList = [
|
||||
export const srcAndroidList: SampleVideoSource[] = [
|
||||
{
|
||||
description: 'Another live sample',
|
||||
uri: 'https://live.forstreet.cl/live/livestream.m3u8',
|
||||
@ -118,12 +149,6 @@ export const srcAndroidList = [
|
||||
uri: 'http://www.youtube.com/api/manifest/dash/id/bf5bb2419360daf1/source/youtube?as=fmp4_audio_clear,fmp4_sd_hd_clear&sparams=ip,ipbits,expire,source,id,as&ip=0.0.0.0&ipbits=0&expire=19000000000&signature=51AF5F39AB0CEC3E5497CD9C900EBFEAECCCB5C7.8506521BFC350652163895D4C26DEE124209AA9E&key=ik0',
|
||||
type: 'mpd',
|
||||
},
|
||||
{
|
||||
description: '(mp4) big buck bunny With Ads',
|
||||
adTagUrl:
|
||||
'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=',
|
||||
uri: 'http://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4',
|
||||
},
|
||||
{
|
||||
description: 'WV: Secure SD & HD (cbcs,MP4,H264)',
|
||||
uri: 'https://storage.googleapis.com/wvmedia/cbcs/h264/tears/tears_aes_cbcs.mpd',
|
||||
@ -149,9 +174,12 @@ export const srcAndroidList = [
|
||||
},
|
||||
];
|
||||
|
||||
export const srcList: SampleVideoSource[] = srcAllPlatformList.concat(
|
||||
isAndroid ? srcAndroidList : srcIosList,
|
||||
);
|
||||
const platformSrc: SampleVideoSource[] = isAndroid
|
||||
? srcAndroidList
|
||||
: srcIosList;
|
||||
|
||||
export const srcList: SampleVideoSource[] =
|
||||
platformSrc.concat(srcAllPlatformList);
|
||||
|
||||
export const bufferConfig: BufferConfig = {
|
||||
minBufferMs: 15000,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {AppRegistry} from 'react-native';
|
||||
import {registerRootComponent} from 'expo';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import {name as appName} from '../app.json';
|
||||
|
||||
AppRegistry.registerComponent(appName, () => VideoPlayer);
|
||||
registerRootComponent(VideoPlayer);
|
||||
|
@ -1,11 +1,11 @@
|
||||
import {Drm, ReactVideoSource, TextTracks} from 'react-native-video';
|
||||
|
||||
export type AdditionalSourceInfo = {
|
||||
textTracks: TextTracks;
|
||||
adTagUrl: string;
|
||||
description: string;
|
||||
drm: Drm;
|
||||
noView: boolean;
|
||||
textTracks?: TextTracks;
|
||||
adTagUrl?: string;
|
||||
description?: string;
|
||||
drm?: Drm;
|
||||
noView?: boolean;
|
||||
};
|
||||
|
||||
export type SampleVideoSource = ReactVideoSource | AdditionalSourceInfo;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -316,11 +316,11 @@ PODS:
|
||||
- React-jsinspector (0.71.12-0)
|
||||
- React-logger (0.71.12-0):
|
||||
- glog
|
||||
- react-native-video (6.0.0):
|
||||
- react-native-video (6.6.2):
|
||||
- RCT-Folly (= 2021.07.22.00)
|
||||
- React-Core
|
||||
- react-native-video/Video (= 6.0.0)
|
||||
- react-native-video/Video (6.0.0):
|
||||
- react-native-video/Video (= 6.6.2)
|
||||
- react-native-video/Video (6.6.2):
|
||||
- RCT-Folly (= 2021.07.22.00)
|
||||
- React-Core
|
||||
- React-perflogger (0.71.12-0)
|
||||
@ -592,7 +592,7 @@ SPEC CHECKSUMS:
|
||||
React-jsiexecutor: 0c8c5e8b2171be52295f59097923babf84d1cf66
|
||||
React-jsinspector: f8e6919523047a9bd1270ade75b4eca0108963b4
|
||||
React-logger: 16c56636d4209cc204d06c5ba347cee21b960012
|
||||
react-native-video: fc60911540a69935cc7950829163f6f41259cb0d
|
||||
react-native-video: 5d1e10262d6986e1ce911634a3b8d8f32f2dd97e
|
||||
React-perflogger: 355109dc9d6f34e35bc35dabb32310f8ed2d29a2
|
||||
React-RCTActionSheet: 9d1be4d43972f2aae4b31d9e53ffb030115fa445
|
||||
React-RCTAnimation: aab7e1ecd325db67e1f2a947d85a52adf86594b7
|
||||
@ -609,6 +609,6 @@ SPEC CHECKSUMS:
|
||||
Yoga: 8b8c06e142662150974d1c70b4c5ffb08eb468db
|
||||
YogaKit: 1e22bf2228b3a5ac8cc88965153061ae92c494b5
|
||||
|
||||
PODFILE CHECKSUM: 26d254806a611a4bc6b6c39cff790dd08f770ccf
|
||||
PODFILE CHECKSUM: e20830ba1d59fa52a9075c08861e37e5f2ac113c
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
|
@ -48,7 +48,7 @@ class VideoPluginSample: NSObject, RNVPlugin {
|
||||
* custom functions to be able to track AVPlayer state change
|
||||
*/
|
||||
func handlePlaybackRateChange(player: AVPlayer, change: NSKeyValueObservedChange<Float>) {
|
||||
NSLog("plugin: handlePlaybackRateChange \(change.oldValue)")
|
||||
NSLog("plugin: handlePlaybackRateChange \(String(describing: change.oldValue))")
|
||||
}
|
||||
|
||||
func handlePlayerItemStatusChange(playerItem: AVPlayerItem, change _: NSKeyValueObservedChange<AVPlayerItem.Status>) {
|
||||
@ -56,7 +56,7 @@ class VideoPluginSample: NSObject, RNVPlugin {
|
||||
}
|
||||
|
||||
func handleCurrentItemChange(player: AVPlayer, change: NSKeyValueObservedChange<AVPlayerItem?>) {
|
||||
NSLog("plugin: handleCurrentItemChange \(player.currentItem)")
|
||||
NSLog("plugin: handleCurrentItemChange \(String(describing: player.currentItem))")
|
||||
guard let playerItem = player.currentItem else {
|
||||
_playerItemStatusObserver?.invalidate()
|
||||
return
|
||||
|
18
ios/Video/DataStructures/AdParams.swift
Normal file
18
ios/Video/DataStructures/AdParams.swift
Normal file
@ -0,0 +1,18 @@
|
||||
struct AdParams {
|
||||
let adTagUrl: String?
|
||||
let adLanguage: String?
|
||||
|
||||
let json: NSDictionary?
|
||||
|
||||
init(_ json: NSDictionary!) {
|
||||
guard json != nil else {
|
||||
self.json = nil
|
||||
adTagUrl = nil
|
||||
adLanguage = nil
|
||||
return
|
||||
}
|
||||
self.json = json
|
||||
adTagUrl = json["adTagUrl"] as? String
|
||||
adLanguage = json["adLanguage"] as? String
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ struct DRMParams {
|
||||
let contentId: String?
|
||||
let certificateUrl: String?
|
||||
let base64Certificate: Bool?
|
||||
let localSourceEncryptionKeyScheme: String?
|
||||
|
||||
let json: NSDictionary?
|
||||
|
||||
@ -17,6 +18,7 @@ struct DRMParams {
|
||||
self.certificateUrl = nil
|
||||
self.base64Certificate = nil
|
||||
self.headers = nil
|
||||
self.localSourceEncryptionKeyScheme = nil
|
||||
return
|
||||
}
|
||||
self.json = json
|
||||
@ -36,5 +38,6 @@ struct DRMParams {
|
||||
} else {
|
||||
self.headers = nil
|
||||
}
|
||||
localSourceEncryptionKeyScheme = json["localSourceEncryptionKeyScheme"] as? String
|
||||
}
|
||||
}
|
||||
|
@ -15,4 +15,8 @@ struct SelectedTrackCriteria {
|
||||
self.type = json["type"] as? String ?? ""
|
||||
self.value = json["value"] as? String
|
||||
}
|
||||
|
||||
static func none() -> SelectedTrackCriteria {
|
||||
return SelectedTrackCriteria(["type": "none", "value": ""])
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,9 @@ struct VideoSource {
|
||||
let cropEnd: Int64?
|
||||
let customMetadata: CustomMetadata?
|
||||
/* DRM */
|
||||
let drm: DRMParams?
|
||||
let drm: DRMParams
|
||||
var textTracks: [TextTrack] = []
|
||||
let adParams: AdParams
|
||||
|
||||
let json: NSDictionary?
|
||||
|
||||
@ -27,7 +29,8 @@ struct VideoSource {
|
||||
self.cropStart = nil
|
||||
self.cropEnd = nil
|
||||
self.customMetadata = nil
|
||||
self.drm = nil
|
||||
self.drm = DRMParams(nil)
|
||||
adParams = AdParams(nil)
|
||||
return
|
||||
}
|
||||
self.json = json
|
||||
@ -52,5 +55,9 @@ struct VideoSource {
|
||||
self.cropEnd = (json["cropEnd"] as? Float64).flatMap { Int64(round($0)) }
|
||||
self.customMetadata = CustomMetadata(json["metadata"] as? NSDictionary)
|
||||
self.drm = DRMParams(json["drm"] as? NSDictionary)
|
||||
self.textTracks = (json["textTracks"] as? NSArray)?.map { trackDict in
|
||||
return TextTrack(trackDict as? NSDictionary)
|
||||
} ?? []
|
||||
adParams = AdParams(json["ad"] as? NSDictionary)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,41 @@
|
||||
//
|
||||
// DRMManager+AVContentKeySessionDelegate.swift
|
||||
// react-native-video
|
||||
//
|
||||
// Created by Krzysztof Moch on 14/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
extension DRMManager: AVContentKeySessionDelegate {
|
||||
func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {
|
||||
handleContentKeyRequest(keyRequest: keyRequest)
|
||||
}
|
||||
|
||||
func contentKeySession(_: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) {
|
||||
handleContentKeyRequest(keyRequest: keyRequest)
|
||||
}
|
||||
|
||||
func contentKeySession(_: AVContentKeySession, shouldRetry _: AVContentKeyRequest, reason retryReason: AVContentKeyRequest.RetryReason) -> Bool {
|
||||
let retryReasons: [AVContentKeyRequest.RetryReason] = [
|
||||
.timedOut,
|
||||
.receivedResponseWithExpiredLease,
|
||||
.receivedObsoleteContentKey,
|
||||
]
|
||||
return retryReasons.contains(retryReason)
|
||||
}
|
||||
|
||||
func contentKeySession(_: AVContentKeySession, didProvide keyRequest: AVPersistableContentKeyRequest) {
|
||||
Task {
|
||||
do {
|
||||
try await handlePersistableKeyRequest(keyRequest: keyRequest)
|
||||
} catch {
|
||||
handleError(error, for: keyRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contentKeySession(_: AVContentKeySession, contentKeyRequest _: AVContentKeyRequest, didFailWithError error: Error) {
|
||||
DebugLog(String(describing: error))
|
||||
}
|
||||
}
|
68
ios/Video/Features/DRMManager+OnGetLicense.swift
Normal file
68
ios/Video/Features/DRMManager+OnGetLicense.swift
Normal file
@ -0,0 +1,68 @@
|
||||
//
|
||||
// DRMManager+OnGetLicense.swift
|
||||
// react-native-video
|
||||
//
|
||||
// Created by Krzysztof Moch on 14/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
extension DRMManager {
|
||||
func requestLicenseFromJS(spcData: Data, assetId: String, keyRequest: AVContentKeyRequest) async throws {
|
||||
guard let onGetLicense else {
|
||||
throw RCTVideoError.noDataFromLicenseRequest
|
||||
}
|
||||
|
||||
guard let licenseServerUrl = drmParams?.licenseServer, !licenseServerUrl.isEmpty else {
|
||||
throw RCTVideoError.noLicenseServerURL
|
||||
}
|
||||
|
||||
guard let loadedLicenseUrl = keyRequest.identifier as? String else {
|
||||
throw RCTVideoError.invalidContentId
|
||||
}
|
||||
|
||||
pendingLicenses[loadedLicenseUrl] = keyRequest
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
onGetLicense([
|
||||
"licenseUrl": licenseServerUrl,
|
||||
"loadedLicenseUrl": loadedLicenseUrl,
|
||||
"contentId": assetId,
|
||||
"spcBase64": spcData.base64EncodedString(),
|
||||
"target": self?.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func setJSLicenseResult(license: String, licenseUrl: String) {
|
||||
guard let keyContentRequest = pendingLicenses[licenseUrl] else {
|
||||
setJSLicenseError(error: "Loading request for licenseUrl \(licenseUrl) not found", licenseUrl: licenseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
guard let responseData = Data(base64Encoded: license) else {
|
||||
setJSLicenseError(error: "Invalid license data", licenseUrl: licenseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try finishProcessingContentKeyRequest(keyRequest: keyContentRequest, license: responseData)
|
||||
pendingLicenses.removeValue(forKey: licenseUrl)
|
||||
} catch {
|
||||
handleError(error, for: keyContentRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func setJSLicenseError(error: String, licenseUrl: String) {
|
||||
let rctError = RCTVideoError.fromJSPart(error)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onVideoError?([
|
||||
"error": RCTVideoErrorHandler.createError(from: rctError),
|
||||
"target": self?.reactTag as Any,
|
||||
])
|
||||
}
|
||||
|
||||
pendingLicenses.removeValue(forKey: licenseUrl)
|
||||
}
|
||||
}
|
34
ios/Video/Features/DRMManager+Persitable.swift
Normal file
34
ios/Video/Features/DRMManager+Persitable.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// DRMManager+Persitable.swift
|
||||
// react-native-video
|
||||
//
|
||||
// Created by Krzysztof Moch on 19/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
extension DRMManager {
|
||||
func handlePersistableKeyRequest(keyRequest: AVPersistableContentKeyRequest) async throws {
|
||||
if let localSourceEncryptionKeyScheme = drmParams?.localSourceEncryptionKeyScheme {
|
||||
try handleEmbeddedKey(keyRequest: keyRequest, scheme: localSourceEncryptionKeyScheme)
|
||||
} else {
|
||||
// Offline DRM is not supported yet - if you need it please check out the following issue:
|
||||
// https://github.com/TheWidlarzGroup/react-native-video/issues/3539
|
||||
throw RCTVideoError.offlineDRMNotSupported
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEmbeddedKey(keyRequest: AVPersistableContentKeyRequest, scheme: String) throws {
|
||||
guard let uri = keyRequest.identifier as? String,
|
||||
let url = URL(string: uri) else {
|
||||
throw RCTVideoError.invalidContentId
|
||||
}
|
||||
|
||||
guard let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: scheme) else {
|
||||
throw RCTVideoError.embeddedKeyExtractionFailed
|
||||
}
|
||||
|
||||
let persistentKey = try keyRequest.persistableContentKey(fromKeyVendorResponse: persistentKeyData)
|
||||
try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: persistentKey)
|
||||
}
|
||||
}
|
213
ios/Video/Features/DRMManager.swift
Normal file
213
ios/Video/Features/DRMManager.swift
Normal file
@ -0,0 +1,213 @@
|
||||
//
|
||||
// DRMManager.swift
|
||||
// react-native-video
|
||||
//
|
||||
// Created by Krzysztof Moch on 13/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
class DRMManager: NSObject {
|
||||
static let queue = DispatchQueue(label: "RNVideoContentKeyDelegateQueue")
|
||||
let contentKeySession: AVContentKeySession?
|
||||
|
||||
var drmParams: DRMParams?
|
||||
var reactTag: NSNumber?
|
||||
var onVideoError: RCTDirectEventBlock?
|
||||
var onGetLicense: RCTDirectEventBlock?
|
||||
|
||||
// Licenses handled by onGetLicense (from JS side)
|
||||
var pendingLicenses: [String: AVContentKeyRequest] = [:]
|
||||
|
||||
override init() {
|
||||
#if targetEnvironment(simulator)
|
||||
contentKeySession = nil
|
||||
super.init()
|
||||
#else
|
||||
contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming)
|
||||
super.init()
|
||||
|
||||
contentKeySession?.setDelegate(self, queue: DRMManager.queue)
|
||||
#endif
|
||||
}
|
||||
|
||||
func createContentKeyRequest(
|
||||
asset: AVContentKeyRecipient,
|
||||
drmParams: DRMParams?,
|
||||
reactTag: NSNumber?,
|
||||
onVideoError: RCTDirectEventBlock?,
|
||||
onGetLicense: RCTDirectEventBlock?
|
||||
) {
|
||||
self.reactTag = reactTag
|
||||
self.onVideoError = onVideoError
|
||||
self.onGetLicense = onGetLicense
|
||||
self.drmParams = drmParams
|
||||
|
||||
if drmParams?.type != "fairplay" {
|
||||
self.onVideoError?([
|
||||
"error": RCTVideoErrorHandler.createError(from: RCTVideoError.unsupportedDRMType),
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
DebugLog("Simulator is not supported for FairPlay DRM.")
|
||||
self.onVideoError?([
|
||||
"error": RCTVideoErrorHandler.createError(from: RCTVideoError.simulatorDRMNotSupported),
|
||||
"target": self.reactTag as Any,
|
||||
])
|
||||
#endif
|
||||
|
||||
contentKeySession?.addContentKeyRecipient(asset)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
func handleContentKeyRequest(keyRequest: AVContentKeyRequest) {
|
||||
Task {
|
||||
do {
|
||||
if drmParams?.localSourceEncryptionKeyScheme != nil {
|
||||
#if os(iOS)
|
||||
try keyRequest.respondByRequestingPersistableContentKeyRequestAndReturnError()
|
||||
return
|
||||
#else
|
||||
throw RCTVideoError.offlineDRMNotSupported
|
||||
#endif
|
||||
}
|
||||
|
||||
try await processContentKeyRequest(keyRequest: keyRequest)
|
||||
} catch {
|
||||
handleError(error, for: keyRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishProcessingContentKeyRequest(keyRequest: AVContentKeyRequest, license: Data) throws {
|
||||
let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: license)
|
||||
keyRequest.processContentKeyResponse(keyResponse)
|
||||
}
|
||||
|
||||
func handleError(_ error: Error, for keyRequest: AVContentKeyRequest) {
|
||||
let rctError: RCTVideoError
|
||||
if let videoError = error as? RCTVideoError {
|
||||
// handle RCTVideoError errors
|
||||
rctError = videoError
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onVideoError?([
|
||||
"error": RCTVideoErrorHandler.createError(from: rctError),
|
||||
"target": self?.reactTag as Any,
|
||||
])
|
||||
}
|
||||
} else {
|
||||
let err = error as NSError
|
||||
|
||||
// handle Other errors
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onVideoError?([
|
||||
"error": [
|
||||
"code": err.code,
|
||||
"localizedDescription": err.localizedDescription,
|
||||
"localizedFailureReason": err.localizedFailureReason ?? "",
|
||||
"localizedRecoverySuggestion": err.localizedRecoverySuggestion ?? "",
|
||||
"domain": err.domain,
|
||||
],
|
||||
"target": self?.reactTag as Any,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
keyRequest.processContentKeyResponseError(error)
|
||||
contentKeySession?.expire()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func processContentKeyRequest(keyRequest: AVContentKeyRequest) async throws {
|
||||
guard let assetId = getAssetId(keyRequest: keyRequest),
|
||||
let assetIdData = assetId.data(using: .utf8) else {
|
||||
throw RCTVideoError.invalidContentId
|
||||
}
|
||||
|
||||
let appCertificate = try await requestApplicationCertificate()
|
||||
let spcData = try await keyRequest.makeStreamingContentKeyRequestData(forApp: appCertificate, contentIdentifier: assetIdData)
|
||||
|
||||
if onGetLicense != nil {
|
||||
try await requestLicenseFromJS(spcData: spcData, assetId: assetId, keyRequest: keyRequest)
|
||||
} else {
|
||||
let license = try await requestLicense(spcData: spcData)
|
||||
try finishProcessingContentKeyRequest(keyRequest: keyRequest, license: license)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestApplicationCertificate() async throws -> Data {
|
||||
guard let urlString = drmParams?.certificateUrl,
|
||||
let url = URL(string: urlString) else {
|
||||
throw RCTVideoError.noCertificateURL
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw RCTVideoError.noCertificateData
|
||||
}
|
||||
|
||||
if drmParams?.base64Certificate == true {
|
||||
guard let certData = Data(base64Encoded: data) else {
|
||||
throw RCTVideoError.noCertificateData
|
||||
}
|
||||
return certData
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private func requestLicense(spcData: Data) async throws -> Data {
|
||||
guard let licenseServerUrlString = drmParams?.licenseServer,
|
||||
let licenseServerUrl = URL(string: licenseServerUrlString) else {
|
||||
throw RCTVideoError.noLicenseServerURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: licenseServerUrl)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = spcData
|
||||
|
||||
if let headers = drmParams?.headers {
|
||||
for (key, value) in headers {
|
||||
if let stringValue = value as? String {
|
||||
request.setValue(stringValue, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RCTVideoError.licenseRequestFailed(0)
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
throw RCTVideoError.licenseRequestFailed(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
guard !data.isEmpty else {
|
||||
throw RCTVideoError.noDataFromLicenseRequest
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private func getAssetId(keyRequest: AVContentKeyRequest) -> String? {
|
||||
if let assetId = drmParams?.contentId {
|
||||
return assetId
|
||||
}
|
||||
|
||||
if let url = keyRequest.identifier as? String {
|
||||
return url.replacingOccurrences(of: "skd://", with: "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
@ -19,7 +19,12 @@
|
||||
}
|
||||
|
||||
func setUpAdsLoader() {
|
||||
adsLoader = IMAAdsLoader(settings: nil)
|
||||
guard let _video else { return }
|
||||
let settings = IMASettings()
|
||||
if let adLanguage = _video.getAdLanguage() {
|
||||
settings.language = adLanguage
|
||||
}
|
||||
adsLoader = IMAAdsLoader(settings: settings)
|
||||
adsLoader.delegate = self
|
||||
}
|
||||
|
||||
|
@ -234,10 +234,9 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla
|
||||
|
||||
/* Cancels the previously registered time observer. */
|
||||
func removePlayerTimeObserver() {
|
||||
if _timeObserver != nil {
|
||||
player?.removeTimeObserver(_timeObserver)
|
||||
_timeObserver = nil
|
||||
}
|
||||
guard let timeObserver = _timeObserver else { return }
|
||||
player?.removeTimeObserver(timeObserver)
|
||||
_timeObserver = nil
|
||||
}
|
||||
|
||||
func addTimeObserverIfNotSet() {
|
||||
@ -284,11 +283,11 @@ class RCTPlayerObserver: NSObject, AVPlayerItemMetadataOutputPushDelegate, AVPla
|
||||
name: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime,
|
||||
object: nil)
|
||||
|
||||
NotificationCenter.default.removeObserver(_handlers, name: NSNotification.Name.AVPlayerItemNewAccessLogEntry, object: player?.currentItem)
|
||||
NotificationCenter.default.removeObserver(_handlers, name: AVPlayerItem.newAccessLogEntryNotification, object: player?.currentItem)
|
||||
|
||||
NotificationCenter.default.addObserver(_handlers,
|
||||
selector: #selector(RCTPlayerObserverHandlerObjc.handleAVPlayerAccess(notification:)),
|
||||
name: NSNotification.Name.AVPlayerItemNewAccessLogEntry,
|
||||
name: AVPlayerItem.newAccessLogEntryNotification,
|
||||
object: player?.currentItem)
|
||||
}
|
||||
|
||||
|
@ -15,11 +15,15 @@ enum RCTPlayerOperations {
|
||||
let trackCount: Int! = player?.currentItem?.tracks.count ?? 0
|
||||
|
||||
// The first few tracks will be audio & video track
|
||||
var firstTextIndex = 0
|
||||
var firstTextIndex = -1
|
||||
for i in 0 ..< trackCount where player?.currentItem?.tracks[i].assetTrack?.hasMediaCharacteristic(.legible) ?? false {
|
||||
firstTextIndex = i
|
||||
break
|
||||
}
|
||||
if firstTextIndex == -1 {
|
||||
// no sideLoaded text track available (can happen with invalid vtt url)
|
||||
return
|
||||
}
|
||||
|
||||
var selectedTrackIndex: Int = RCTVideoUnset
|
||||
|
||||
|
@ -1,186 +0,0 @@
|
||||
import AVFoundation
|
||||
|
||||
class RCTResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate {
|
||||
private var _loadingRequests: [String: AVAssetResourceLoadingRequest?] = [:]
|
||||
private var _requestingCertificate = false
|
||||
private var _requestingCertificateErrored = false
|
||||
private var _drm: DRMParams?
|
||||
private var _localSourceEncryptionKeyScheme: String?
|
||||
private var _reactTag: NSNumber?
|
||||
private var _onVideoError: RCTDirectEventBlock?
|
||||
private var _onGetLicense: RCTDirectEventBlock?
|
||||
|
||||
init(
|
||||
asset: AVURLAsset,
|
||||
drm: DRMParams?,
|
||||
localSourceEncryptionKeyScheme: String?,
|
||||
onVideoError: RCTDirectEventBlock?,
|
||||
onGetLicense: RCTDirectEventBlock?,
|
||||
reactTag: NSNumber
|
||||
) {
|
||||
super.init()
|
||||
let queue = DispatchQueue(label: "assetQueue")
|
||||
asset.resourceLoader.setDelegate(self, queue: queue)
|
||||
_reactTag = reactTag
|
||||
_onVideoError = onVideoError
|
||||
_onGetLicense = onGetLicense
|
||||
_drm = drm
|
||||
_localSourceEncryptionKeyScheme = localSourceEncryptionKeyScheme
|
||||
}
|
||||
|
||||
deinit {
|
||||
for request in _loadingRequests.values {
|
||||
request?.finishLoading()
|
||||
}
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest) -> Bool {
|
||||
return loadingRequestHandling(renewalRequest)
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
|
||||
return loadingRequestHandling(loadingRequest)
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, didCancel _: AVAssetResourceLoadingRequest) {
|
||||
RCTLog("didCancelLoadingRequest")
|
||||
}
|
||||
|
||||
func setLicenseResult(_ license: String!, _ licenseUrl: String!) {
|
||||
// Check if the loading request exists in _loadingRequests based on licenseUrl
|
||||
guard let loadingRequest = _loadingRequests[licenseUrl] else {
|
||||
setLicenseResultError("Loading request for licenseUrl \(licenseUrl) not found", licenseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the license data is valid
|
||||
guard let respondData = RCTVideoUtils.base64DataFromBase64String(base64String: license) else {
|
||||
setLicenseResultError("No data from JS license response", licenseUrl)
|
||||
return
|
||||
}
|
||||
|
||||
let dataRequest: AVAssetResourceLoadingDataRequest! = loadingRequest?.dataRequest
|
||||
dataRequest.respond(with: respondData)
|
||||
loadingRequest!.finishLoading()
|
||||
_loadingRequests.removeValue(forKey: licenseUrl)
|
||||
}
|
||||
|
||||
func setLicenseResultError(_ error: String!, _ licenseUrl: String!) {
|
||||
// Check if the loading request exists in _loadingRequests based on licenseUrl
|
||||
guard let loadingRequest = _loadingRequests[licenseUrl] else {
|
||||
print("Loading request for licenseUrl \(licenseUrl) not found. Error: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
self.finishLoadingWithError(error: RCTVideoErrorHandler.fromJSPart(error), licenseUrl: licenseUrl)
|
||||
}
|
||||
|
||||
func finishLoadingWithError(error: Error!, licenseUrl: String!) -> Bool {
|
||||
// Check if the loading request exists in _loadingRequests based on licenseUrl
|
||||
guard let loadingRequest = _loadingRequests[licenseUrl], let error = error as NSError? else {
|
||||
// Handle the case where the loading request is not found or error is nil
|
||||
return false
|
||||
}
|
||||
|
||||
loadingRequest!.finishLoading(with: error)
|
||||
_loadingRequests.removeValue(forKey: licenseUrl)
|
||||
_onVideoError?([
|
||||
"error": [
|
||||
"code": NSNumber(value: error.code),
|
||||
"localizedDescription": error.localizedDescription ?? "",
|
||||
"localizedFailureReason": error.localizedFailureReason ?? "",
|
||||
"localizedRecoverySuggestion": error.localizedRecoverySuggestion ?? "",
|
||||
"domain": error.domain,
|
||||
],
|
||||
"target": _reactTag,
|
||||
])
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func loadingRequestHandling(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
|
||||
if handleEmbeddedKey(loadingRequest) {
|
||||
return true
|
||||
}
|
||||
|
||||
if _drm != nil {
|
||||
return handleDrm(loadingRequest)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func handleEmbeddedKey(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
|
||||
guard let url = loadingRequest.request.url,
|
||||
let _localSourceEncryptionKeyScheme,
|
||||
let persistentKeyData = RCTVideoUtils.extractDataFromCustomSchemeUrl(from: url, scheme: _localSourceEncryptionKeyScheme)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
loadingRequest.contentInformationRequest?.contentType = AVStreamingKeyDeliveryPersistentContentKeyType
|
||||
loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true
|
||||
loadingRequest.contentInformationRequest?.contentLength = Int64(persistentKeyData.count)
|
||||
loadingRequest.dataRequest?.respond(with: persistentKeyData)
|
||||
loadingRequest.finishLoading()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func handleDrm(_ loadingRequest: AVAssetResourceLoadingRequest!) -> Bool {
|
||||
if _requestingCertificate {
|
||||
return true
|
||||
} else if _requestingCertificateErrored {
|
||||
return false
|
||||
}
|
||||
|
||||
let requestKey: String = loadingRequest.request.url?.absoluteString ?? ""
|
||||
|
||||
_loadingRequests[requestKey] = loadingRequest
|
||||
|
||||
guard let _drm, let drmType = _drm.type, drmType == "fairplay" else {
|
||||
return finishLoadingWithError(error: RCTVideoErrorHandler.noDRMData, licenseUrl: requestKey)
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
if _onGetLicense != nil {
|
||||
let contentId = _drm.contentId ?? loadingRequest.request.url?.host
|
||||
let spcData = try await RCTVideoDRM.handleWithOnGetLicense(
|
||||
loadingRequest: loadingRequest,
|
||||
contentId: contentId,
|
||||
certificateUrl: _drm.certificateUrl,
|
||||
base64Certificate: _drm.base64Certificate
|
||||
)
|
||||
|
||||
self._requestingCertificate = true
|
||||
self._onGetLicense?(["licenseUrl": self._drm?.licenseServer ?? "",
|
||||
"loadedLicenseUrl": loadingRequest.request.url?.absoluteString ?? "",
|
||||
"contentId": contentId ?? "",
|
||||
"spcBase64": spcData.base64EncodedString(options: []),
|
||||
"target": self._reactTag])
|
||||
} else {
|
||||
let data = try await RCTVideoDRM.handleInternalGetLicense(
|
||||
loadingRequest: loadingRequest,
|
||||
contentId: _drm.contentId,
|
||||
licenseServer: _drm.licenseServer,
|
||||
certificateUrl: _drm.certificateUrl,
|
||||
base64Certificate: _drm.base64Certificate,
|
||||
headers: _drm.headers
|
||||
)
|
||||
|
||||
guard let dataRequest = loadingRequest.dataRequest else {
|
||||
throw RCTVideoErrorHandler.noCertificateData
|
||||
}
|
||||
dataRequest.respond(with: data)
|
||||
loadingRequest.finishLoading()
|
||||
}
|
||||
} catch {
|
||||
self.finishLoadingWithError(error: error, licenseUrl: requestKey)
|
||||
self._requestingCertificateErrored = true
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
@ -1,161 +0,0 @@
|
||||
import AVFoundation
|
||||
|
||||
enum RCTVideoDRM {
|
||||
static func fetchLicense(
|
||||
licenseServer: String,
|
||||
spcData: Data?,
|
||||
contentId: String,
|
||||
headers: [String: Any]?
|
||||
) async throws -> Data {
|
||||
let request = createLicenseRequest(licenseServer: licenseServer, spcData: spcData, contentId: contentId, headers: headers)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(from: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RCTVideoErrorHandler.noDataFromLicenseRequest
|
||||
}
|
||||
|
||||
if httpResponse.statusCode != 200 {
|
||||
print("Error getting license from \(licenseServer), HTTP status code \(httpResponse.statusCode)")
|
||||
throw RCTVideoErrorHandler.licenseRequestNotOk(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
guard let decodedData = Data(base64Encoded: data, options: []) else {
|
||||
throw RCTVideoErrorHandler.noDataFromLicenseRequest
|
||||
}
|
||||
|
||||
return decodedData
|
||||
}
|
||||
|
||||
static func createLicenseRequest(
|
||||
licenseServer: String,
|
||||
spcData: Data?,
|
||||
contentId: String,
|
||||
headers: [String: Any]?
|
||||
) -> URLRequest {
|
||||
var request = URLRequest(url: URL(string: licenseServer)!)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
if let headers {
|
||||
for item in headers {
|
||||
guard let key = item.key as? String, let value = item.value as? String else {
|
||||
continue
|
||||
}
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
}
|
||||
|
||||
let spcEncoded = spcData?.base64EncodedString(options: [])
|
||||
let spcUrlEncoded = CFURLCreateStringByAddingPercentEscapes(
|
||||
kCFAllocatorDefault,
|
||||
spcEncoded as? CFString? as! CFString,
|
||||
nil,
|
||||
"?=&+" as CFString,
|
||||
CFStringBuiltInEncodings.UTF8.rawValue
|
||||
) as? String
|
||||
let post = String(format: "spc=%@&%@", spcUrlEncoded as! CVarArg, contentId)
|
||||
let postData = post.data(using: String.Encoding.utf8, allowLossyConversion: true)
|
||||
request.httpBody = postData
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
static func fetchSpcData(
|
||||
loadingRequest: AVAssetResourceLoadingRequest,
|
||||
certificateData: Data,
|
||||
contentIdData: Data
|
||||
) throws -> Data {
|
||||
#if os(visionOS)
|
||||
// TODO: DRM is not supported yet on visionOS. See #3467
|
||||
throw NSError(domain: "DRM is not supported yet on visionOS", code: 0, userInfo: nil)
|
||||
#else
|
||||
guard let spcData = try? loadingRequest.streamingContentKeyRequestData(
|
||||
forApp: certificateData,
|
||||
contentIdentifier: contentIdData as Data,
|
||||
options: nil
|
||||
) else {
|
||||
throw RCTVideoErrorHandler.noSPC
|
||||
}
|
||||
|
||||
return spcData
|
||||
#endif
|
||||
}
|
||||
|
||||
static func createCertificateData(certificateStringUrl: String?, base64Certificate: Bool?) throws -> Data {
|
||||
guard let certificateStringUrl,
|
||||
let certificateURL = URL(string: certificateStringUrl.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) ?? "") else {
|
||||
throw RCTVideoErrorHandler.noCertificateURL
|
||||
}
|
||||
|
||||
var certificateData: Data?
|
||||
do {
|
||||
certificateData = try Data(contentsOf: certificateURL)
|
||||
if base64Certificate != nil {
|
||||
certificateData = Data(base64Encoded: certificateData! as Data, options: .ignoreUnknownCharacters)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
guard let certificateData else {
|
||||
throw RCTVideoErrorHandler.noCertificateData
|
||||
}
|
||||
|
||||
return certificateData
|
||||
}
|
||||
|
||||
static func handleWithOnGetLicense(loadingRequest: AVAssetResourceLoadingRequest, contentId: String?, certificateUrl: String?,
|
||||
base64Certificate: Bool?) throws -> Data {
|
||||
let contentIdData = contentId?.data(using: .utf8)
|
||||
|
||||
let certificateData = try? RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate)
|
||||
|
||||
guard let contentIdData else {
|
||||
throw RCTVideoError.invalidContentId as! Error
|
||||
}
|
||||
|
||||
guard let certificateData else {
|
||||
throw RCTVideoError.noCertificateData as! Error
|
||||
}
|
||||
|
||||
return try RCTVideoDRM.fetchSpcData(
|
||||
loadingRequest: loadingRequest,
|
||||
certificateData: certificateData,
|
||||
contentIdData: contentIdData
|
||||
)
|
||||
}
|
||||
|
||||
static func handleInternalGetLicense(
|
||||
loadingRequest: AVAssetResourceLoadingRequest,
|
||||
contentId: String?,
|
||||
licenseServer: String?,
|
||||
certificateUrl: String?,
|
||||
base64Certificate: Bool?,
|
||||
headers: [String: Any]?
|
||||
) async throws -> Data {
|
||||
let url = loadingRequest.request.url
|
||||
|
||||
let parsedContentId = contentId != nil && !contentId!.isEmpty ? contentId : nil
|
||||
|
||||
guard let contentId = parsedContentId ?? url?.absoluteString.replacingOccurrences(of: "skd://", with: "") else {
|
||||
throw RCTVideoError.invalidContentId as! Error
|
||||
}
|
||||
|
||||
let contentIdData = NSData(bytes: contentId.cString(using: String.Encoding.utf8), length: contentId.lengthOfBytes(using: String.Encoding.utf8)) as Data
|
||||
let certificateData = try RCTVideoDRM.createCertificateData(certificateStringUrl: certificateUrl, base64Certificate: base64Certificate)
|
||||
let spcData = try RCTVideoDRM.fetchSpcData(
|
||||
loadingRequest: loadingRequest,
|
||||
certificateData: certificateData,
|
||||
contentIdData: contentIdData
|
||||
)
|
||||
|
||||
guard let licenseServer else {
|
||||
throw RCTVideoError.noLicenseServerURL as! Error
|
||||
}
|
||||
|
||||
return try await RCTVideoDRM.fetchLicense(
|
||||
licenseServer: licenseServer,
|
||||
spcData: spcData,
|
||||
contentId: contentId,
|
||||
headers: headers
|
||||
)
|
||||
}
|
||||
}
|
@ -1,114 +1,188 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - RCTVideoError
|
||||
|
||||
enum RCTVideoError: Int {
|
||||
case fromJSPart
|
||||
enum RCTVideoError: Error, Hashable {
|
||||
case fromJSPart(String)
|
||||
case noLicenseServerURL
|
||||
case licenseRequestNotOk
|
||||
case licenseRequestFailed(Int)
|
||||
case noDataFromLicenseRequest
|
||||
case noSPC
|
||||
case noDataRequest
|
||||
case noCertificateData
|
||||
case noCertificateURL
|
||||
case noFairplayDRM
|
||||
case noDRMData
|
||||
case invalidContentId
|
||||
case invalidAppCert
|
||||
case keyRequestCreationFailed
|
||||
case persistableKeyRequestFailed
|
||||
case embeddedKeyExtractionFailed
|
||||
case offlineDRMNotSupported
|
||||
case unsupportedDRMType
|
||||
case simulatorDRMNotSupported
|
||||
|
||||
var errorCode: Int {
|
||||
switch self {
|
||||
case .fromJSPart:
|
||||
return 1000
|
||||
case .noLicenseServerURL:
|
||||
return 1001
|
||||
case .licenseRequestFailed:
|
||||
return 1002
|
||||
case .noDataFromLicenseRequest:
|
||||
return 1003
|
||||
case .noSPC:
|
||||
return 1004
|
||||
case .noCertificateData:
|
||||
return 1005
|
||||
case .noCertificateURL:
|
||||
return 1006
|
||||
case .noDRMData:
|
||||
return 1007
|
||||
case .invalidContentId:
|
||||
return 1008
|
||||
case .invalidAppCert:
|
||||
return 1009
|
||||
case .keyRequestCreationFailed:
|
||||
return 1010
|
||||
case .persistableKeyRequestFailed:
|
||||
return 1011
|
||||
case .embeddedKeyExtractionFailed:
|
||||
return 1012
|
||||
case .offlineDRMNotSupported:
|
||||
return 1013
|
||||
case .unsupportedDRMType:
|
||||
return 1014
|
||||
case .simulatorDRMNotSupported:
|
||||
return 1015
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LocalizedError
|
||||
|
||||
extension RCTVideoError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .fromJSPart(error):
|
||||
return NSLocalizedString("Error from JavaScript: \(error)", comment: "")
|
||||
case .noLicenseServerURL:
|
||||
return NSLocalizedString("No license server URL provided", comment: "")
|
||||
case let .licenseRequestFailed(statusCode):
|
||||
return NSLocalizedString("License request failed with status code: \(statusCode)", comment: "")
|
||||
case .noDataFromLicenseRequest:
|
||||
return NSLocalizedString("No data received from license server", comment: "")
|
||||
case .noSPC:
|
||||
return NSLocalizedString("Failed to create Server Playback Context (SPC)", comment: "")
|
||||
case .noCertificateData:
|
||||
return NSLocalizedString("No certificate data obtained", comment: "")
|
||||
case .noCertificateURL:
|
||||
return NSLocalizedString("No certificate URL provided", comment: "")
|
||||
case .noDRMData:
|
||||
return NSLocalizedString("No DRM data available", comment: "")
|
||||
case .invalidContentId:
|
||||
return NSLocalizedString("Invalid content ID", comment: "")
|
||||
case .invalidAppCert:
|
||||
return NSLocalizedString("Invalid application certificate", comment: "")
|
||||
case .keyRequestCreationFailed:
|
||||
return NSLocalizedString("Failed to create content key request", comment: "")
|
||||
case .persistableKeyRequestFailed:
|
||||
return NSLocalizedString("Failed to create persistable content key request", comment: "")
|
||||
case .embeddedKeyExtractionFailed:
|
||||
return NSLocalizedString("Failed to extract embedded key", comment: "")
|
||||
case .offlineDRMNotSupported:
|
||||
return NSLocalizedString("Offline DRM is not supported, see https://github.com/TheWidlarzGroup/react-native-video/issues/3539", comment: "")
|
||||
case .unsupportedDRMType:
|
||||
return NSLocalizedString("Unsupported DRM type", comment: "")
|
||||
case .simulatorDRMNotSupported:
|
||||
return NSLocalizedString("DRM on simulators is not supported", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var failureReason: String? {
|
||||
switch self {
|
||||
case .fromJSPart:
|
||||
return NSLocalizedString("An error occurred in the JavaScript part of the application.", comment: "")
|
||||
case .noLicenseServerURL:
|
||||
return NSLocalizedString("The license server URL is missing in the DRM configuration.", comment: "")
|
||||
case .licenseRequestFailed:
|
||||
return NSLocalizedString("The license server responded with an error status code.", comment: "")
|
||||
case .noDataFromLicenseRequest:
|
||||
return NSLocalizedString("The license server did not return any data.", comment: "")
|
||||
case .noSPC:
|
||||
return NSLocalizedString("Failed to generate the Server Playback Context (SPC) for the content.", comment: "")
|
||||
case .noCertificateData:
|
||||
return NSLocalizedString("Unable to retrieve certificate data from the specified URL.", comment: "")
|
||||
case .noCertificateURL:
|
||||
return NSLocalizedString("The certificate URL is missing in the DRM configuration.", comment: "")
|
||||
case .noDRMData:
|
||||
return NSLocalizedString("The required DRM data is not available or is invalid.", comment: "")
|
||||
case .invalidContentId:
|
||||
return NSLocalizedString("The content ID provided is not valid or recognized.", comment: "")
|
||||
case .invalidAppCert:
|
||||
return NSLocalizedString("The application certificate is invalid or not recognized.", comment: "")
|
||||
case .keyRequestCreationFailed:
|
||||
return NSLocalizedString("Unable to create a content key request for DRM.", comment: "")
|
||||
case .persistableKeyRequestFailed:
|
||||
return NSLocalizedString("Failed to create a persistable content key request for offline playback.", comment: "")
|
||||
case .embeddedKeyExtractionFailed:
|
||||
return NSLocalizedString("Unable to extract the embedded key from the custom scheme URL.", comment: "")
|
||||
case .offlineDRMNotSupported:
|
||||
return NSLocalizedString("You tried to use Offline DRM but it is not supported yet", comment: "")
|
||||
case .unsupportedDRMType:
|
||||
return NSLocalizedString("You tried to use unsupported DRM type", comment: "")
|
||||
case .simulatorDRMNotSupported:
|
||||
return NSLocalizedString("You tried to DRM on a simulator", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .fromJSPart:
|
||||
return NSLocalizedString("Check the JavaScript logs for more details and fix any issues in the JS code.", comment: "")
|
||||
case .noLicenseServerURL:
|
||||
return NSLocalizedString("Ensure that you have specified the 'licenseServer' property in the DRM configuration.", comment: "")
|
||||
case .licenseRequestFailed:
|
||||
return NSLocalizedString("Verify that the license server is functioning correctly and that you're sending the correct data.", comment: "")
|
||||
case .noDataFromLicenseRequest:
|
||||
return NSLocalizedString("Check if the license server is operational and responding with the expected data.", comment: "")
|
||||
case .noSPC:
|
||||
return NSLocalizedString("Verify that the content key request is properly configured and that the DRM setup is correct.", comment: "")
|
||||
case .noCertificateData:
|
||||
return NSLocalizedString("Check if the certificate URL is correct and accessible, and that it returns valid certificate data.", comment: "")
|
||||
case .noCertificateURL:
|
||||
return NSLocalizedString("Make sure you have specified the 'certificateUrl' property in the DRM configuration.", comment: "")
|
||||
case .noDRMData:
|
||||
return NSLocalizedString("Ensure that you have provided all necessary DRM-related data in the configuration.", comment: "")
|
||||
case .invalidContentId:
|
||||
return NSLocalizedString("Verify that the content ID is correct and matches the expected format for your DRM system.", comment: "")
|
||||
case .invalidAppCert:
|
||||
return NSLocalizedString("Check if the application certificate is valid and properly formatted for your DRM system.", comment: "")
|
||||
case .keyRequestCreationFailed:
|
||||
return NSLocalizedString("Review your DRM configuration and ensure all required parameters are correctly set.", comment: "")
|
||||
case .persistableKeyRequestFailed:
|
||||
return NSLocalizedString("Verify that offline playback is supported and properly configured for your content.", comment: "")
|
||||
case .embeddedKeyExtractionFailed:
|
||||
return NSLocalizedString("Check if the embedded key is present in the URL and the custom scheme is correctly implemented.", comment: "")
|
||||
case .offlineDRMNotSupported:
|
||||
return NSLocalizedString("Check if localSourceEncryptionKeyScheme is set", comment: "")
|
||||
case .unsupportedDRMType:
|
||||
return NSLocalizedString("Verify that you are using fairplay (on Apple devices)", comment: "")
|
||||
case .simulatorDRMNotSupported:
|
||||
return NSLocalizedString("You need to test DRM content on real device", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RCTVideoErrorHandler
|
||||
|
||||
enum RCTVideoErrorHandler {
|
||||
static let noDRMData = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noDRMData.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
|
||||
NSLocalizedFailureReasonErrorKey: "No drm object found.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Have you specified the 'drm' prop?",
|
||||
static func createError(from error: RCTVideoError) -> [String: Any] {
|
||||
return [
|
||||
"code": error.errorCode,
|
||||
"localizedDescription": error.localizedDescription,
|
||||
"localizedFailureReason": error.failureReason ?? "",
|
||||
"localizedRecoverySuggestion": error.recoverySuggestion ?? "",
|
||||
"domain": "RCTVideo",
|
||||
]
|
||||
)
|
||||
|
||||
static let noCertificateURL = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noCertificateURL.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM License.",
|
||||
NSLocalizedFailureReasonErrorKey: "No certificate URL has been found.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop certificateUrl?",
|
||||
]
|
||||
)
|
||||
|
||||
static let noCertificateData = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noCertificateData.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
|
||||
NSLocalizedFailureReasonErrorKey: "No certificate data obtained from the specificied url.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Have you specified a valid 'certificateUrl'?",
|
||||
]
|
||||
)
|
||||
|
||||
static let noSPC = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noSPC.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining license.",
|
||||
NSLocalizedFailureReasonErrorKey: "No spc received.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Check your DRM config.",
|
||||
]
|
||||
)
|
||||
|
||||
static let noLicenseServerURL = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noLicenseServerURL.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM License.",
|
||||
NSLocalizedFailureReasonErrorKey: "No license server URL has been found.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Did you specified the prop licenseServer?",
|
||||
]
|
||||
)
|
||||
|
||||
static let noDataFromLicenseRequest = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.noDataFromLicenseRequest.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
|
||||
NSLocalizedFailureReasonErrorKey: "No data received from the license server.",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Is the licenseServer ok?",
|
||||
]
|
||||
)
|
||||
|
||||
static func licenseRequestNotOk(_ statusCode: Int) -> NSError {
|
||||
return NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.licenseRequestNotOk.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining license.",
|
||||
NSLocalizedFailureReasonErrorKey: String(
|
||||
format: "License server responded with status code %li",
|
||||
statusCode
|
||||
),
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Did you send the correct data to the license Server? Is the server ok?",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func fromJSPart(_ error: String) -> NSError {
|
||||
return NSError(domain: "RCTVideo",
|
||||
code: RCTVideoError.fromJSPart.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: error,
|
||||
NSLocalizedFailureReasonErrorKey: error,
|
||||
NSLocalizedRecoverySuggestionErrorKey: error,
|
||||
])
|
||||
}
|
||||
|
||||
static let invalidContentId = NSError(
|
||||
domain: "RCTVideo",
|
||||
code: RCTVideoError.invalidContentId.rawValue,
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Error obtaining DRM license.",
|
||||
NSLocalizedFailureReasonErrorKey: "No valide content Id received",
|
||||
NSLocalizedRecoverySuggestionErrorKey: "Is the contentId and url ok?",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
@ -19,26 +19,32 @@ enum RCTVideoSave {
|
||||
reject("ERROR_COULD_NOT_CREATE_EXPORT_SESSION", "Could not create export session", nil)
|
||||
return
|
||||
}
|
||||
var path: String!
|
||||
path = RCTVideoSave.generatePathInDirectory(
|
||||
directory: URL(fileURLWithPath: RCTVideoSave.cacheDirectoryPath() ?? "").appendingPathComponent("Videos").path,
|
||||
withExtension: ".mp4"
|
||||
)
|
||||
let url: NSURL! = NSURL.fileURL(withPath: path) as NSURL
|
||||
exportSession.outputFileType = AVFileType.mp4
|
||||
exportSession.outputURL = url as URL?
|
||||
exportSession.videoComposition = playerItem?.videoComposition
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
exportSession.exportAsynchronously(completionHandler: {
|
||||
switch exportSession.status {
|
||||
case .failed:
|
||||
reject("ERROR_COULD_NOT_EXPORT_VIDEO", "Could not export video", exportSession.error)
|
||||
case .cancelled:
|
||||
reject("ERROR_EXPORT_SESSION_CANCELLED", "Export session was cancelled", exportSession.error)
|
||||
default:
|
||||
resolve(["uri": url.absoluteString])
|
||||
}
|
||||
})
|
||||
|
||||
#if !os(visionOS)
|
||||
var path: String!
|
||||
path = RCTVideoSave.generatePathInDirectory(
|
||||
directory: URL(fileURLWithPath: RCTVideoSave.cacheDirectoryPath() ?? "").appendingPathComponent("Videos").path,
|
||||
withExtension: ".mp4"
|
||||
)
|
||||
let url: NSURL! = NSURL.fileURL(withPath: path) as NSURL
|
||||
exportSession.outputFileType = .mp4
|
||||
exportSession.outputFileType = AVFileType.mp4
|
||||
exportSession.outputURL = url as URL?
|
||||
exportSession.videoComposition = playerItem?.videoComposition
|
||||
exportSession.shouldOptimizeForNetworkUse = true
|
||||
exportSession.exportAsynchronously(completionHandler: {
|
||||
switch exportSession.status {
|
||||
case .failed:
|
||||
reject("ERROR_COULD_NOT_EXPORT_VIDEO", "Could not export video", exportSession.error)
|
||||
case .cancelled:
|
||||
reject("ERROR_EXPORT_SESSION_CANCELLED", "Export session was cancelled", exportSession.error)
|
||||
default:
|
||||
resolve(["uri": url.absoluteString])
|
||||
}
|
||||
})
|
||||
#else
|
||||
reject("ERROR_EXPORT_SESSION_CANCELLED", "this function is not supported on visionOS", nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func generatePathInDirectory(directory: String?, withExtension extension: String?) -> String? {
|
||||
@ -54,13 +60,11 @@ enum RCTVideoSave {
|
||||
|
||||
static func ensureDirExists(withPath path: String?) -> Bool {
|
||||
var isDir: ObjCBool = false
|
||||
var error: Error?
|
||||
let exists = FileManager.default.fileExists(atPath: path ?? "", isDirectory: &isDir)
|
||||
if !(exists && isDir.boolValue) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: path ?? "", withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {}
|
||||
if error != nil {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,15 @@ enum RCTVideoAssetsUtils {
|
||||
for mediaCharacteristic: AVMediaCharacteristic
|
||||
) async -> AVMediaSelectionGroup? {
|
||||
if #available(iOS 15, tvOS 15, visionOS 1.0, *) {
|
||||
return try? await asset?.loadMediaSelectionGroup(for: mediaCharacteristic)
|
||||
do {
|
||||
guard let asset else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return try await asset.loadMediaSelectionGroup(for: mediaCharacteristic)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
#if !os(visionOS)
|
||||
return asset?.mediaSelectionGroup(forMediaCharacteristic: mediaCharacteristic)
|
||||
@ -73,22 +81,25 @@ enum RCTVideoUtils {
|
||||
return 0
|
||||
}
|
||||
|
||||
static func urlFilePath(filepath: NSString!, searchPath: FileManager.SearchPathDirectory) -> NSURL! {
|
||||
if filepath.contains("file://") {
|
||||
return NSURL(string: filepath as String)
|
||||
static func urlFilePath(filepath: NSString?, searchPath: FileManager.SearchPathDirectory) -> NSURL! {
|
||||
guard let _filepath = filepath else { return nil }
|
||||
|
||||
if _filepath.contains("file://") {
|
||||
return NSURL(string: _filepath as String)
|
||||
}
|
||||
|
||||
// if no file found, check if the file exists in the Document directory
|
||||
let paths: [String]! = NSSearchPathForDirectoriesInDomains(searchPath, .userDomainMask, true)
|
||||
var relativeFilePath: String! = filepath.lastPathComponent
|
||||
let paths: [String] = NSSearchPathForDirectoriesInDomains(searchPath, .userDomainMask, true)
|
||||
var relativeFilePath: String = _filepath.lastPathComponent
|
||||
// the file may be multiple levels below the documents directory
|
||||
let directoryString: String! = searchPath == .cachesDirectory ? "Library/Caches/" : "Documents"
|
||||
let fileComponents: [String]! = filepath.components(separatedBy: directoryString)
|
||||
let directoryString: String = searchPath == .cachesDirectory ? "Library/Caches/" : "Documents"
|
||||
let fileComponents: [String] = _filepath.components(separatedBy: directoryString)
|
||||
if fileComponents.count > 1 {
|
||||
relativeFilePath = fileComponents[1]
|
||||
}
|
||||
|
||||
let path: String! = (paths.first! as NSString).appendingPathComponent(relativeFilePath)
|
||||
guard let _pathFirst = paths.first else { return nil }
|
||||
let path: String = (_pathFirst as NSString).appendingPathComponent(relativeFilePath)
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return NSURL.fileURL(withPath: path) as NSURL
|
||||
}
|
||||
@ -127,7 +138,7 @@ enum RCTVideoUtils {
|
||||
return []
|
||||
}
|
||||
|
||||
let audioTracks: NSMutableArray! = NSMutableArray()
|
||||
let audioTracks = NSMutableArray()
|
||||
|
||||
let group = await RCTVideoAssetsUtils.getMediaSelectionGroup(asset: asset, for: .audible)
|
||||
|
||||
@ -138,14 +149,14 @@ enum RCTVideoUtils {
|
||||
if (values?.count ?? 0) > 0, let value = values?[0] {
|
||||
title = value as! String
|
||||
}
|
||||
let language: String! = currentOption?.extendedLanguageTag ?? ""
|
||||
let language: String = currentOption?.extendedLanguageTag ?? ""
|
||||
|
||||
let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!)
|
||||
|
||||
let audioTrack = [
|
||||
"index": NSNumber(value: i),
|
||||
"title": title,
|
||||
"language": language ?? "",
|
||||
"language": language,
|
||||
"selected": currentOption?.displayName == selectedOption?.displayName,
|
||||
] as [String: Any]
|
||||
audioTracks.add(audioTrack)
|
||||
@ -170,13 +181,12 @@ enum RCTVideoUtils {
|
||||
if (values?.count ?? 0) > 0, let value = values?[0] {
|
||||
title = value as! String
|
||||
}
|
||||
let language: String! = currentOption?.extendedLanguageTag ?? ""
|
||||
let selectedOpt = player.currentItem?.currentMediaSelection
|
||||
let language: String = currentOption?.extendedLanguageTag ?? ""
|
||||
let selectedOption: AVMediaSelectionOption? = player.currentItem?.currentMediaSelection.selectedMediaOption(in: group!)
|
||||
let textTrack = TextTrack([
|
||||
"index": NSNumber(value: i),
|
||||
"title": title,
|
||||
"language": language,
|
||||
"language": language as Any,
|
||||
"selected": currentOption?.displayName == selectedOption?.displayName,
|
||||
])
|
||||
textTracks.append(textTrack)
|
||||
@ -356,10 +366,11 @@ enum RCTVideoUtils {
|
||||
static func prepareAsset(source: VideoSource) -> (asset: AVURLAsset?, assetOptions: NSMutableDictionary?)? {
|
||||
guard let sourceUri = source.uri, sourceUri != "" else { return nil }
|
||||
var asset: AVURLAsset!
|
||||
let bundlePath = Bundle.main.path(forResource: source.uri, ofType: source.type) ?? ""
|
||||
let url = source.isNetwork || source.isAsset
|
||||
? URL(string: source.uri ?? "")
|
||||
: URL(fileURLWithPath: bundlePath)
|
||||
let bundlePath = Bundle.main.path(forResource: sourceUri, ofType: source.type) ?? ""
|
||||
guard let url = source.isNetwork || source.isAsset
|
||||
? URL(string: sourceUri)
|
||||
: URL(fileURLWithPath: bundlePath) else { return nil }
|
||||
|
||||
let assetOptions: NSMutableDictionary! = NSMutableDictionary()
|
||||
|
||||
if source.isNetwork {
|
||||
@ -367,10 +378,10 @@ enum RCTVideoUtils {
|
||||
assetOptions.setObject(headers, forKey: "AVURLAssetHTTPHeaderFieldsKey" as NSCopying)
|
||||
}
|
||||
let cookies: [AnyObject]! = HTTPCookieStorage.shared.cookies
|
||||
assetOptions.setObject(cookies, forKey: AVURLAssetHTTPCookiesKey as NSCopying)
|
||||
asset = AVURLAsset(url: url!, options: assetOptions as! [String: Any])
|
||||
assetOptions.setObject(cookies as Any, forKey: AVURLAssetHTTPCookiesKey as NSCopying)
|
||||
asset = AVURLAsset(url: url, options: assetOptions as? [String: Any])
|
||||
} else {
|
||||
asset = AVURLAsset(url: url!)
|
||||
asset = AVURLAsset(url: url)
|
||||
}
|
||||
return (asset, assetOptions)
|
||||
}
|
||||
@ -423,14 +434,10 @@ enum RCTVideoUtils {
|
||||
return try? await AVVideoComposition.videoComposition(
|
||||
with: asset,
|
||||
applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in
|
||||
if filter == nil {
|
||||
request.finish(with: request.sourceImage, context: nil)
|
||||
} else {
|
||||
let image: CIImage! = request.sourceImage.clampedToExtent()
|
||||
filter.setValue(image, forKey: kCIInputImageKey)
|
||||
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
|
||||
request.finish(with: output, context: nil)
|
||||
}
|
||||
let image: CIImage! = request.sourceImage.clampedToExtent()
|
||||
filter.setValue(image, forKey: kCIInputImageKey)
|
||||
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
|
||||
request.finish(with: output, context: nil)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@ -438,14 +445,10 @@ enum RCTVideoUtils {
|
||||
return AVVideoComposition(
|
||||
asset: asset,
|
||||
applyingCIFiltersWithHandler: { (request: AVAsynchronousCIImageFilteringRequest) in
|
||||
if filter == nil {
|
||||
request.finish(with: request.sourceImage, context: nil)
|
||||
} else {
|
||||
let image: CIImage! = request.sourceImage.clampedToExtent()
|
||||
filter.setValue(image, forKey: kCIInputImageKey)
|
||||
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
|
||||
request.finish(with: output, context: nil)
|
||||
}
|
||||
let image: CIImage! = request.sourceImage.clampedToExtent()
|
||||
filter.setValue(image, forKey: kCIInputImageKey)
|
||||
let output: CIImage! = filter.outputImage?.cropped(to: request.sourceImage.extent)
|
||||
request.finish(with: output, context: nil)
|
||||
}
|
||||
)
|
||||
#endif
|
||||
|
@ -18,6 +18,7 @@ class NowPlayingInfoCenterManager {
|
||||
private var skipBackwardTarget: Any?
|
||||
private var playbackPositionTarget: Any?
|
||||
private var seekTarget: Any?
|
||||
private var togglePlayPauseTarget: Any?
|
||||
|
||||
private let remoteCommandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
@ -167,13 +168,26 @@ class NowPlayingInfoCenterManager {
|
||||
return .commandFailed
|
||||
}
|
||||
if let event = event as? MPChangePlaybackPositionCommandEvent {
|
||||
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in
|
||||
player.play()
|
||||
}
|
||||
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max))
|
||||
return .success
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
// Handler for togglePlayPauseCommand, sent by Apple's Earpods wired headphones
|
||||
togglePlayPauseTarget = remoteCommandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if player.rate == 0 {
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidateCommandTargets() {
|
||||
@ -182,6 +196,7 @@ class NowPlayingInfoCenterManager {
|
||||
remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
|
||||
remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
|
||||
remoteCommandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
|
||||
remoteCommandCenter.togglePlayPauseCommand.removeTarget(togglePlayPauseTarget)
|
||||
}
|
||||
|
||||
public func updateNowPlayingInfo() {
|
||||
|
@ -17,7 +17,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
private var _playerViewController: RCTVideoPlayerViewController?
|
||||
private var _videoURL: NSURL?
|
||||
private var _localSourceEncryptionKeyScheme: String?
|
||||
|
||||
/* Required to publish events */
|
||||
private var _eventDispatcher: RCTEventDispatcher?
|
||||
@ -42,20 +41,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
private var _repeat = false
|
||||
private var _isPlaying = false
|
||||
private var _allowsExternalPlayback = true
|
||||
private var _textTracks: [TextTrack]?
|
||||
private var _selectedTextTrackCriteria: SelectedTrackCriteria?
|
||||
private var _selectedAudioTrackCriteria: SelectedTrackCriteria?
|
||||
private var _selectedTextTrackCriteria: SelectedTrackCriteria = .none()
|
||||
private var _selectedAudioTrackCriteria: SelectedTrackCriteria = .none()
|
||||
private var _playbackStalled = false
|
||||
private var _playInBackground = false
|
||||
private var _preventsDisplaySleepDuringVideoPlayback = true
|
||||
private var _preferredForwardBufferDuration: Float = 0.0
|
||||
private var _playWhenInactive = false
|
||||
private var _ignoreSilentSwitch: String! = "inherit" // inherit, ignore, obey
|
||||
private var _mixWithOthers: String! = "inherit" // inherit, mix, duck
|
||||
private var _resizeMode: String! = "cover"
|
||||
private var _ignoreSilentSwitch: String = "inherit" // inherit, ignore, obey
|
||||
private var _mixWithOthers: String = "inherit" // inherit, mix, duck
|
||||
private var _resizeMode: String = "cover"
|
||||
private var _fullscreen = false
|
||||
private var _fullscreenAutorotate = true
|
||||
private var _fullscreenOrientation: String! = "all"
|
||||
private var _fullscreenOrientation: String = "all"
|
||||
private var _fullscreenPlayerPresented = false
|
||||
private var _fullscreenUncontrolPlayerPresented = false // to call events switching full screen mode from player controls
|
||||
private var _filterName: String!
|
||||
@ -63,6 +61,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
private var _presentingViewController: UIViewController?
|
||||
private var _startPosition: Float64 = -1
|
||||
private var _showNotificationControls = false
|
||||
// Buffer last bitrate value received. Initialized to -2 to ensure -1 (sometimes reported by AVPlayer) is not missed
|
||||
private var _lastBitrate = -2.0
|
||||
private var _pictureInPictureEnabled = false {
|
||||
didSet {
|
||||
#if os(iOS)
|
||||
@ -86,7 +86,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
/* IMA Ads */
|
||||
private var _adTagUrl: String?
|
||||
#if USE_GOOGLE_IMA
|
||||
private var _imaAdsManager: RCTIMAAdsManager!
|
||||
/* Playhead used by the SDK to track content video progress and insert mid-rolls. */
|
||||
@ -95,7 +94,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
private var _didRequestAds = false
|
||||
private var _adPlaying = false
|
||||
|
||||
private var _resouceLoaderDelegate: RCTResourceLoaderDelegate?
|
||||
private lazy var _drmManager: DRMManager? = DRMManager()
|
||||
private var _playerObserver: RCTPlayerObserver = .init()
|
||||
|
||||
#if USE_VIDEO_CACHING
|
||||
@ -285,9 +284,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
// MARK: - App lifecycle handlers
|
||||
|
||||
func getIsExternalPlaybackActive() -> Bool {
|
||||
#if os(visionOS)
|
||||
let isExternalPlaybackActive = false
|
||||
#else
|
||||
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false
|
||||
#endif
|
||||
return isExternalPlaybackActive
|
||||
}
|
||||
|
||||
@objc
|
||||
func applicationWillResignActive(notification _: NSNotification!) {
|
||||
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false
|
||||
let isExternalPlaybackActive = getIsExternalPlaybackActive()
|
||||
if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return }
|
||||
|
||||
_player?.pause()
|
||||
@ -296,7 +304,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
@objc
|
||||
func applicationDidBecomeActive(notification _: NSNotification!) {
|
||||
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false
|
||||
let isExternalPlaybackActive = getIsExternalPlaybackActive()
|
||||
if _playInBackground || _playWhenInactive || !_isPlaying || isExternalPlaybackActive { return }
|
||||
|
||||
// Resume the player or any other tasks that should continue when the app becomes active.
|
||||
@ -306,7 +314,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
@objc
|
||||
func applicationDidEnterBackground(notification _: NSNotification!) {
|
||||
let isExternalPlaybackActive = _player?.isExternalPlaybackActive ?? false
|
||||
let isExternalPlaybackActive = getIsExternalPlaybackActive()
|
||||
if !_playInBackground || isExternalPlaybackActive || isPipActive() { return }
|
||||
// Needed to play sound in background. See https://developer.apple.com/library/ios/qa/qa1668/_index.html
|
||||
_playerLayer?.player = nil
|
||||
@ -342,7 +350,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
#endif
|
||||
|
||||
if let video = _player?.currentItem,
|
||||
video == nil || video.status != AVPlayerItem.Status.readyToPlay {
|
||||
video.status != AVPlayerItem.Status.readyToPlay {
|
||||
return
|
||||
}
|
||||
|
||||
@ -365,7 +373,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
if currentTimeSecs >= 0 {
|
||||
#if USE_GOOGLE_IMA
|
||||
if !_didRequestAds && currentTimeSecs >= 0.0001 && _adTagUrl != nil {
|
||||
if !_didRequestAds && currentTimeSecs >= 0.0001 && _source?.adParams.adTagUrl != nil {
|
||||
_imaAdsManager.requestAds()
|
||||
_didRequestAds = true
|
||||
}
|
||||
@ -375,7 +383,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
"playableDuration": RCTVideoUtils.calculatePlayableDuration(_player, withSource: _source),
|
||||
"atValue": currentTime?.value ?? .zero,
|
||||
"currentPlaybackTime": NSNumber(value: Double(currentPlaybackTime?.timeIntervalSince1970 ?? 0 * 1000)).int64Value,
|
||||
"target": reactTag,
|
||||
"target": reactTag as Any,
|
||||
"seekableDuration": RCTVideoUtils.calculateSeekableDuration(_player),
|
||||
])
|
||||
}
|
||||
@ -407,17 +415,17 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
// Perform on next run loop, otherwise onVideoLoadStart is nil
|
||||
onVideoLoadStart?([
|
||||
"src": [
|
||||
"uri": _source?.uri ?? NSNull(),
|
||||
"uri": _source?.uri ?? NSNull() as Any,
|
||||
"type": _source?.type ?? NSNull(),
|
||||
"isNetwork": NSNumber(value: _source?.isNetwork ?? false),
|
||||
],
|
||||
"drm": source.drm?.json ?? NSNull(),
|
||||
"target": reactTag,
|
||||
"drm": source.drm.json ?? NSNull(),
|
||||
"target": reactTag as Any,
|
||||
])
|
||||
|
||||
if let uri = source.uri, uri.starts(with: "ph://") {
|
||||
let photoAsset = await RCTVideoUtils.preparePHAsset(uri: uri)
|
||||
return await playerItemPrepareText(asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
|
||||
return await playerItemPrepareText(source: source, asset: photoAsset, assetOptions: nil, uri: source.uri ?? "")
|
||||
}
|
||||
|
||||
guard let assetResult = RCTVideoUtils.prepareAsset(source: source),
|
||||
@ -443,23 +451,26 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
#if USE_VIDEO_CACHING
|
||||
if _videoCache.shouldCache(source: source, textTracks: _textTracks) {
|
||||
return try await _videoCache.playerItemForSourceUsingCache(uri: source.uri, assetOptions: assetOptions)
|
||||
if _videoCache.shouldCache(source: source) {
|
||||
return try await _videoCache.playerItemForSourceUsingCache(source: source, assetOptions: assetOptions)
|
||||
}
|
||||
#endif
|
||||
|
||||
if source.drm != nil || _localSourceEncryptionKeyScheme != nil {
|
||||
_resouceLoaderDelegate = RCTResourceLoaderDelegate(
|
||||
if source.drm.json != nil {
|
||||
if _drmManager == nil {
|
||||
_drmManager = DRMManager()
|
||||
}
|
||||
|
||||
_drmManager?.createContentKeyRequest(
|
||||
asset: asset,
|
||||
drm: source.drm,
|
||||
localSourceEncryptionKeyScheme: _localSourceEncryptionKeyScheme,
|
||||
drmParams: source.drm,
|
||||
reactTag: reactTag,
|
||||
onVideoError: onVideoError,
|
||||
onGetLicense: onGetLicense,
|
||||
reactTag: reactTag
|
||||
onGetLicense: onGetLicense
|
||||
)
|
||||
}
|
||||
|
||||
return await playerItemPrepareText(asset: asset, assetOptions: assetOptions, uri: source.uri ?? "")
|
||||
return await playerItemPrepareText(source: source, asset: asset, assetOptions: assetOptions, uri: source.uri ?? "")
|
||||
}
|
||||
|
||||
func setupPlayer(playerItem: AVPlayerItem) async throws {
|
||||
@ -480,7 +491,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
if _player == nil {
|
||||
_player = AVPlayer()
|
||||
ReactNativeVideoManager.shared.onInstanceCreated(id: instanceId, player: _player)
|
||||
ReactNativeVideoManager.shared.onInstanceCreated(id: instanceId, player: _player as Any)
|
||||
|
||||
_player!.replaceCurrentItem(with: playerItem)
|
||||
|
||||
@ -489,8 +500,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
NowPlayingInfoCenterManager.shared.registerPlayer(player: _player!)
|
||||
}
|
||||
} else {
|
||||
#if !os(tvOS) && !os(visionOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
// This feature caused crashes, if the app was put in bg, before the source change
|
||||
// https://github.com/TheWidlarzGroup/react-native-video/issues/3900
|
||||
self._playerViewController?.allowsVideoFrameAnalysis = false
|
||||
}
|
||||
#endif
|
||||
_player?.replaceCurrentItem(with: playerItem)
|
||||
|
||||
#if !os(tvOS) && !os(visionOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
self._playerViewController?.allowsVideoFrameAnalysis = true
|
||||
}
|
||||
#endif
|
||||
// later we can just call "updateNowPlayingInfo:
|
||||
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
|
||||
}
|
||||
@ -504,7 +526,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
if _adTagUrl != nil {
|
||||
if _source?.adParams.adTagUrl != nil {
|
||||
// Set up your content playhead and contentComplete callback.
|
||||
_contentPlayhead = IMAAVPlayerContentPlayhead(avPlayer: _player!)
|
||||
|
||||
@ -541,7 +563,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
self.removePlayerLayer()
|
||||
self._playerObserver.player = nil
|
||||
self._resouceLoaderDelegate = nil
|
||||
self._drmManager = nil
|
||||
self._playerObserver.playerItem = nil
|
||||
|
||||
// perform on next run loop, otherwise other passed react-props may not be set
|
||||
@ -573,13 +595,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
DispatchQueue.global(qos: .default).async(execute: initializeSource)
|
||||
}
|
||||
|
||||
@objc
|
||||
func setLocalSourceEncryptionKeyScheme(_ keyScheme: String) {
|
||||
_localSourceEncryptionKeyScheme = keyScheme
|
||||
}
|
||||
|
||||
func playerItemPrepareText(asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
|
||||
if (self._textTracks == nil) || self._textTracks?.isEmpty == true || (uri.hasSuffix(".m3u8")) {
|
||||
func playerItemPrepareText(source: VideoSource, asset: AVAsset!, assetOptions: NSDictionary?, uri: String) async -> AVPlayerItem {
|
||||
if source.textTracks.isEmpty == true || uri.hasSuffix(".m3u8") {
|
||||
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: asset))
|
||||
}
|
||||
|
||||
@ -590,11 +607,15 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
asset: asset,
|
||||
assetOptions: assetOptions,
|
||||
mixComposition: mixComposition,
|
||||
textTracks: self._textTracks
|
||||
textTracks: source.textTracks
|
||||
)
|
||||
|
||||
if validTextTracks.count != self._textTracks?.count {
|
||||
self.setTextTracks(validTextTracks)
|
||||
if validTextTracks.isEmpty {
|
||||
DebugLog("Strange state, not valid textTrack")
|
||||
}
|
||||
|
||||
if validTextTracks.count != source.textTracks.count {
|
||||
setSelectedTextTrack(_selectedTextTrackCriteria)
|
||||
}
|
||||
|
||||
return await self.playerItemPropegateMetadata(AVPlayerItem(asset: mixComposition))
|
||||
@ -719,14 +740,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
@objc
|
||||
func setIgnoreSilentSwitch(_ ignoreSilentSwitch: String?) {
|
||||
_ignoreSilentSwitch = ignoreSilentSwitch
|
||||
_ignoreSilentSwitch = ignoreSilentSwitch ?? "inherit"
|
||||
RCTPlayerOperations.configureAudio(ignoreSilentSwitch: _ignoreSilentSwitch, mixWithOthers: _mixWithOthers, audioOutput: _audioOutput)
|
||||
applyModifiers()
|
||||
}
|
||||
|
||||
@objc
|
||||
func setMixWithOthers(_ mixWithOthers: String?) {
|
||||
_mixWithOthers = mixWithOthers
|
||||
_mixWithOthers = mixWithOthers ?? "inherit"
|
||||
applyModifiers()
|
||||
}
|
||||
|
||||
@ -796,16 +817,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
self._playerObserver.addTimeObserverIfNotSet()
|
||||
if !wasPaused {
|
||||
self.setPaused(false)
|
||||
}
|
||||
self.setPaused(self._paused)
|
||||
|
||||
let currentTimeAfterSeek = CMTimeGetSeconds(item.currentTime())
|
||||
|
||||
let newCurrentTime = NSNumber(value: Float(currentTimeAfterSeek))
|
||||
let newCurrentTime = NSNumber(value: Float(CMTimeGetSeconds(item.currentTime())))
|
||||
self.onVideoSeekComplete?(["currentTime": newCurrentTime,
|
||||
"seekTime": time,
|
||||
"target": self.reactTag])
|
||||
"seekTime": time,
|
||||
"target": self.reactTag as Any])
|
||||
|
||||
}
|
||||
|
||||
player.seek(to: seekTime, toleranceBefore: toleranceTime, toleranceAfter: toleranceTime, completionHandler: seekCompletionHandler)
|
||||
@ -903,7 +921,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
func applyModifiers() {
|
||||
if let video = _player?.currentItem,
|
||||
video == nil || video.status != AVPlayerItem.Status.readyToPlay {
|
||||
video.status != AVPlayerItem.Status.readyToPlay {
|
||||
return
|
||||
}
|
||||
if _muted {
|
||||
@ -928,9 +946,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
setMaxBitRate(_maxBitRate)
|
||||
}
|
||||
|
||||
setSelectedTextTrack(_selectedTextTrackCriteria)
|
||||
setAudioOutput(_audioOutput)
|
||||
setSelectedAudioTrack(_selectedAudioTrackCriteria)
|
||||
setSelectedTextTrack(_selectedTextTrackCriteria)
|
||||
setResizeMode(_resizeMode)
|
||||
setRepeat(_repeat)
|
||||
setControls(_controls)
|
||||
@ -949,7 +967,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
func setSelectedAudioTrack(_ selectedAudioTrack: SelectedTrackCriteria?) {
|
||||
_selectedAudioTrackCriteria = selectedAudioTrack
|
||||
_selectedAudioTrackCriteria = selectedAudioTrack ?? SelectedTrackCriteria.none()
|
||||
Task {
|
||||
await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.audible,
|
||||
criteria: _selectedAudioTrackCriteria)
|
||||
@ -962,9 +980,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
func setSelectedTextTrack(_ selectedTextTrack: SelectedTrackCriteria?) {
|
||||
_selectedTextTrackCriteria = selectedTextTrack
|
||||
if _textTracks != nil { // sideloaded text tracks
|
||||
RCTPlayerOperations.setSideloadedText(player: _player, textTracks: _textTracks!, criteria: _selectedTextTrackCriteria)
|
||||
_selectedTextTrackCriteria = selectedTextTrack ?? SelectedTrackCriteria.none()
|
||||
guard let source = _source else { return }
|
||||
if !source.textTracks.isEmpty { // sideloaded text tracks
|
||||
RCTPlayerOperations.setSideloadedText(player: _player, textTracks: source.textTracks, criteria: _selectedTextTrackCriteria)
|
||||
} else { // text tracks included in the HLS playlist§
|
||||
Task {
|
||||
await RCTPlayerOperations.setMediaSelectionTrackForCharacteristic(player: _player, characteristic: AVMediaCharacteristic.legible,
|
||||
@ -973,18 +992,6 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func setTextTracks(_ textTracks: [NSDictionary]?) {
|
||||
setTextTracks(textTracks?.map { TextTrack($0) })
|
||||
}
|
||||
|
||||
func setTextTracks(_ textTracks: [TextTrack]?) {
|
||||
_textTracks = textTracks
|
||||
|
||||
// in case textTracks was set after selectedTextTrack
|
||||
if _selectedTextTrackCriteria != nil { setSelectedTextTrack(_selectedTextTrackCriteria) }
|
||||
}
|
||||
|
||||
@objc
|
||||
func setChapters(_ chapters: [NSDictionary]?) {
|
||||
setChapters(chapters?.map { Chapter($0) })
|
||||
@ -996,7 +1003,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
@objc
|
||||
func setFullscreen(_ fullscreen: Bool) {
|
||||
var alreadyFullscreenPresented = _presentingViewController?.presentedViewController != nil
|
||||
let alreadyFullscreenPresented = _presentingViewController?.presentedViewController != nil
|
||||
if fullscreen && !_fullscreenPlayerPresented && _player != nil && !alreadyFullscreenPresented {
|
||||
// Ensure player view controller is not null
|
||||
// Controls will be displayed even if it is disabled in configuration
|
||||
@ -1035,7 +1042,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
self._fullscreenPlayerPresented = fullscreen
|
||||
self._playerViewController?.autorotate = self._fullscreenAutorotate
|
||||
|
||||
self.onVideoFullscreenPlayerDidPresent?(["target": self.reactTag])
|
||||
self.onVideoFullscreenPlayerDidPresent?(["target": self.reactTag as Any])
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1058,9 +1065,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
@objc
|
||||
func setFullscreenOrientation(_ orientation: String?) {
|
||||
_fullscreenOrientation = orientation
|
||||
_fullscreenOrientation = orientation ?? "all"
|
||||
if _fullscreenPlayerPresented {
|
||||
_playerViewController?.preferredOrientation = orientation
|
||||
_playerViewController?.preferredOrientation = _fullscreenOrientation
|
||||
}
|
||||
}
|
||||
|
||||
@ -1223,13 +1230,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
|
||||
// MARK: - RCTIMAAdsManager
|
||||
|
||||
func getAdTagUrl() -> String? {
|
||||
return _adTagUrl
|
||||
func getAdLanguage() -> String? {
|
||||
return _source?.adParams.adLanguage
|
||||
}
|
||||
|
||||
@objc
|
||||
func setAdTagUrl(_ adTagUrl: String!) {
|
||||
_adTagUrl = adTagUrl
|
||||
func getAdTagUrl() -> String? {
|
||||
return _source?.adParams.adTagUrl
|
||||
}
|
||||
|
||||
#if USE_GOOGLE_IMA
|
||||
@ -1290,14 +1296,13 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
_playerItem = nil
|
||||
_source = nil
|
||||
_chapters = nil
|
||||
_textTracks = nil
|
||||
_selectedTextTrackCriteria = nil
|
||||
_selectedAudioTrackCriteria = nil
|
||||
_selectedTextTrackCriteria = SelectedTrackCriteria.none()
|
||||
_selectedAudioTrackCriteria = SelectedTrackCriteria.none()
|
||||
_presentingViewController = nil
|
||||
|
||||
ReactNativeVideoManager.shared.onInstanceRemoved(id: instanceId, player: _player)
|
||||
ReactNativeVideoManager.shared.onInstanceRemoved(id: instanceId, player: _player as Any)
|
||||
_player = nil
|
||||
_resouceLoaderDelegate = nil
|
||||
_drmManager = nil
|
||||
_playerObserver.clearPlayer()
|
||||
|
||||
self.removePlayerLayer()
|
||||
@ -1330,12 +1335,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
)
|
||||
}
|
||||
|
||||
func setLicenseResult(_ license: String!, _ licenseUrl: String!) {
|
||||
_resouceLoaderDelegate?.setLicenseResult(license, licenseUrl)
|
||||
func setLicenseResult(_ license: String, _ licenseUrl: String) {
|
||||
_drmManager?.setJSLicenseResult(license: license, licenseUrl: licenseUrl)
|
||||
}
|
||||
|
||||
func setLicenseResultError(_ error: String!, _ licenseUrl: String!) {
|
||||
_resouceLoaderDelegate?.setLicenseResultError(error, licenseUrl)
|
||||
func setLicenseResultError(_ error: String, _ licenseUrl: String) {
|
||||
_drmManager?.setJSLicenseError(error: error, licenseUrl: licenseUrl)
|
||||
}
|
||||
|
||||
// MARK: - RCTPlayerObserverHandler
|
||||
@ -1349,7 +1354,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
_isBuffering = false
|
||||
}
|
||||
onReadyForDisplay?([
|
||||
"target": reactTag,
|
||||
"target": reactTag as Any,
|
||||
])
|
||||
}
|
||||
|
||||
@ -1368,7 +1373,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
onTimedMetadata?([
|
||||
"target": reactTag,
|
||||
"target": reactTag as Any,
|
||||
"metadata": metadata,
|
||||
])
|
||||
}
|
||||
@ -1386,9 +1391,23 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
}
|
||||
|
||||
func extractJsonWithIndex(from tracks: [TextTrack]) -> [NSDictionary]? {
|
||||
if tracks.isEmpty {
|
||||
// No tracks, need to return nil to handle
|
||||
return nil
|
||||
}
|
||||
// Map each enumerated pair to include the index in the json dictionary
|
||||
let mappedTracks = tracks.enumerated().compactMap { index, track -> NSDictionary? in
|
||||
guard let json = track.json?.mutableCopy() as? NSMutableDictionary else { return nil }
|
||||
json["index"] = index // Insert the index into the json dictionary
|
||||
return json
|
||||
}
|
||||
return mappedTracks
|
||||
}
|
||||
|
||||
func handleReadyToPlay() {
|
||||
guard let _playerItem else { return }
|
||||
|
||||
guard let source = _source else { return }
|
||||
Task {
|
||||
if self._pendingSeek {
|
||||
self.setSeek(NSNumber(value: self._pendingSeekTime), NSNumber(value: 100))
|
||||
@ -1417,7 +1436,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
var orientation = "undefined"
|
||||
|
||||
let tracks = await RCTVideoAssetsUtils.getTracks(asset: _playerItem.asset, withMediaType: .video)
|
||||
var presentationSize = _playerItem.presentationSize
|
||||
let presentationSize = _playerItem.presentationSize
|
||||
if presentationSize.height != 0.0 {
|
||||
width = Float(presentationSize.width)
|
||||
height = Float(presentationSize.height)
|
||||
@ -1444,7 +1463,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
"orientation": orientation,
|
||||
],
|
||||
"audioTracks": audioTracks,
|
||||
"textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.map(\.json),
|
||||
"textTracks": extractJsonWithIndex(from: source.textTracks) ?? textTracks.map(\.json),
|
||||
"target": self.reactTag as Any])
|
||||
}
|
||||
|
||||
@ -1464,14 +1483,14 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
[
|
||||
"error": [
|
||||
"code": NSNumber(value: (_playerItem.error! as NSError).code),
|
||||
"localizedDescription": _playerItem.error?.localizedDescription == nil ? "" : _playerItem.error?.localizedDescription,
|
||||
"localizedDescription": _playerItem.error?.localizedDescription == nil ? "" : _playerItem.error?.localizedDescription as Any,
|
||||
"localizedFailureReason": ((_playerItem.error! as NSError).localizedFailureReason == nil ?
|
||||
"" : (_playerItem.error! as NSError).localizedFailureReason) ?? "",
|
||||
"localizedRecoverySuggestion": ((_playerItem.error! as NSError).localizedRecoverySuggestion == nil ?
|
||||
"" : (_playerItem.error! as NSError).localizedRecoverySuggestion) ?? "",
|
||||
"domain": (_playerItem.error as! NSError).domain,
|
||||
],
|
||||
"target": reactTag,
|
||||
"target": reactTag as Any,
|
||||
]
|
||||
)
|
||||
}
|
||||
@ -1584,12 +1603,12 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
[
|
||||
"error": [
|
||||
"code": NSNumber(value: (error as NSError).code),
|
||||
"localizedDescription": error.localizedDescription ?? "",
|
||||
"localizedDescription": error.localizedDescription,
|
||||
"localizedFailureReason": (error as NSError).localizedFailureReason ?? "",
|
||||
"localizedRecoverySuggestion": (error as NSError).localizedRecoverySuggestion ?? "",
|
||||
"domain": (error as NSError).domain,
|
||||
],
|
||||
"target": reactTag,
|
||||
"target": reactTag as Any,
|
||||
]
|
||||
)
|
||||
}
|
||||
@ -1622,7 +1641,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
)
|
||||
} else {
|
||||
_playerObserver.removePlayerTimeObserver()
|
||||
_player?.pause()
|
||||
_player?.rate = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
@ -1633,16 +1653,19 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
guard let accessLog = (notification.object as? AVPlayerItem)?.accessLog() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let lastEvent = accessLog.events.last else { return }
|
||||
onVideoBandwidthUpdate?(["bitrate": lastEvent.observedBitrate, "target": reactTag])
|
||||
if lastEvent.indicatedBitrate != _lastBitrate {
|
||||
_lastBitrate = lastEvent.indicatedBitrate
|
||||
onVideoBandwidthUpdate?(["bitrate": _lastBitrate, "target": reactTag as Any])
|
||||
}
|
||||
}
|
||||
|
||||
func handleTracksChange(playerItem _: AVPlayerItem, change _: NSKeyValueObservedChange<[AVPlayerItemTrack]>) {
|
||||
guard let source = _source else { return }
|
||||
if onTextTracks != nil {
|
||||
Task {
|
||||
let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
|
||||
self.onTextTracks?(["textTracks": self._textTracks?.compactMap { $0.json } ?? textTracks.compactMap(\.json)])
|
||||
self.onTextTracks?(["textTracks": extractJsonWithIndex(from: source.textTracks) ?? textTracks.compactMap(\.json)])
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,6 @@
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(src, NSDictionary);
|
||||
RCT_EXPORT_VIEW_PROPERTY(drm, NSDictionary);
|
||||
RCT_EXPORT_VIEW_PROPERTY(adTagUrl, NSString);
|
||||
RCT_EXPORT_VIEW_PROPERTY(maxBitRate, float);
|
||||
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString);
|
||||
RCT_EXPORT_VIEW_PROPERTY(repeat, BOOL);
|
||||
@ -75,6 +74,7 @@ RCT_EXTERN_METHOD(setLicenseResultErrorCmd : (nonnull NSNumber*)reactTag error :
|
||||
RCT_EXTERN_METHOD(setPlayerPauseStateCmd : (nonnull NSNumber*)reactTag paused : (nonnull BOOL)paused)
|
||||
RCT_EXTERN_METHOD(setVolumeCmd : (nonnull NSNumber*)reactTag volume : (nonnull float*)volume)
|
||||
RCT_EXTERN_METHOD(setFullScreenCmd : (nonnull NSNumber*)reactTag fullscreen : (nonnull BOOL)fullScreen)
|
||||
RCT_EXTERN_METHOD(setSourceCmd : (nonnull NSNumber*)reactTag source : (NSDictionary*)source)
|
||||
|
||||
RCT_EXTERN_METHOD(save
|
||||
: (nonnull NSNumber*)reactTag options
|
||||
|
@ -72,6 +72,13 @@ class RCTVideoManager: RCTViewManager {
|
||||
})
|
||||
}
|
||||
|
||||
@objc(setSourceCmd:source:)
|
||||
func setSourceCmd(_ reactTag: NSNumber, source: NSDictionary) {
|
||||
performOnVideoView(withReactTag: reactTag, callback: { videoView in
|
||||
videoView?.setSrc(source)
|
||||
})
|
||||
}
|
||||
|
||||
@objc(save:options:resolve:reject:)
|
||||
func save(_ reactTag: NSNumber, options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
performOnVideoView(withReactTag: reactTag, callback: { videoView in
|
||||
|
@ -1,7 +1,7 @@
|
||||
import AVKit
|
||||
import Foundation
|
||||
|
||||
protocol RCTVideoPlayerViewControllerDelegate: class {
|
||||
protocol RCTVideoPlayerViewControllerDelegate: AnyObject {
|
||||
func videoPlayerViewControllerWillDismiss(playerViewController: AVPlayerViewController)
|
||||
func videoPlayerViewControllerDidDismiss(playerViewController: AVPlayerViewController)
|
||||
}
|
||||
|
@ -4,20 +4,20 @@ import Foundation
|
||||
|
||||
class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
|
||||
private var _videoCache: RCTVideoCache! = RCTVideoCache.sharedInstance()
|
||||
var playerItemPrepareText: ((AVAsset?, NSDictionary?, String) async -> AVPlayerItem)?
|
||||
var playerItemPrepareText: ((VideoSource, AVAsset?, NSDictionary?, String) async -> AVPlayerItem)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func shouldCache(source: VideoSource, textTracks: [TextTrack]?) -> Bool {
|
||||
if source.isNetwork && source.shouldCache && ((textTracks == nil) || (textTracks!.isEmpty)) {
|
||||
func shouldCache(source: VideoSource) -> Bool {
|
||||
if source.isNetwork && source.shouldCache && source.textTracks.isEmpty {
|
||||
/* The DVURLAsset created by cache doesn't have a tracksWithMediaType property, so trying
|
||||
* to bring in the text track code will crash. I suspect this is because the asset hasn't fully loaded.
|
||||
* Until this is fixed, we need to bypass caching when text tracks are specified.
|
||||
*/
|
||||
DebugLog("""
|
||||
Caching is not supported for uri '\(source.uri)' because text tracks are not compatible with the cache.
|
||||
Caching is not supported for uri '\(source.uri ?? "NO URI")' because text tracks are not compatible with the cache.
|
||||
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
|
||||
""")
|
||||
return true
|
||||
@ -25,7 +25,8 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
func playerItemForSourceUsingCache(uri: String!, assetOptions options: NSDictionary!) async throws -> AVPlayerItem {
|
||||
func playerItemForSourceUsingCache(source: VideoSource, assetOptions options: NSDictionary) async throws -> AVPlayerItem {
|
||||
let uri = source.uri!
|
||||
let url = URL(string: uri)
|
||||
let (videoCacheStatus, cachedAsset) = await getItemForUri(uri)
|
||||
|
||||
@ -36,33 +37,33 @@ class RCTVideoCachingHandler: NSObject, DVAssetLoaderDelegatesDelegate {
|
||||
switch videoCacheStatus {
|
||||
case .missingFileExtension:
|
||||
DebugLog("""
|
||||
Could not generate cache key for uri '\(uri ?? "NO_URI")'.
|
||||
Could not generate cache key for uri '\(uri)'.
|
||||
It is currently not supported to cache urls that do not include a file extension.
|
||||
The video file will not be cached.
|
||||
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
|
||||
""")
|
||||
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as! [String: Any])
|
||||
return await playerItemPrepareText(asset, options, "")
|
||||
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as? [String: Any])
|
||||
return await playerItemPrepareText(source, asset, options, "")
|
||||
|
||||
case .unsupportedFileExtension:
|
||||
DebugLog("""
|
||||
Could not generate cache key for uri '\(uri ?? "NO_URI")'.
|
||||
Could not generate cache key for uri '\(uri)'.
|
||||
The file extension of that uri is currently not supported.
|
||||
The video file will not be cached.
|
||||
Checkout https://github.com/react-native-community/react-native-video/blob/master/docs/caching.md
|
||||
""")
|
||||
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as! [String: Any])
|
||||
return await playerItemPrepareText(asset, options, "")
|
||||
let asset: AVURLAsset! = AVURLAsset(url: url!, options: options as? [String: Any])
|
||||
return await playerItemPrepareText(source, asset, options, "")
|
||||
|
||||
default:
|
||||
if let cachedAsset {
|
||||
DebugLog("Playing back uri '\(uri ?? "NO_URI")' from cache")
|
||||
DebugLog("Playing back uri '\(uri)' from cache")
|
||||
// See note in playerItemForSource about not being able to support text tracks & caching
|
||||
return AVPlayerItem(asset: cachedAsset)
|
||||
}
|
||||
}
|
||||
|
||||
let asset: DVURLAsset! = DVURLAsset(url: url, options: options as! [String: Any], networkTimeout: 10000)
|
||||
let asset: DVURLAsset! = DVURLAsset(url: url, options: options as? [String: Any], networkTimeout: 10000)
|
||||
asset.loaderDelegate = self
|
||||
|
||||
/* More granular code to have control over the DVURLAsset
|
||||
|
11
package.json
11
package.json
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "react-native-video",
|
||||
"version": "6.4.3",
|
||||
"version": "6.6.4",
|
||||
"description": "A <Video /> element for react-native",
|
||||
"main": "lib/index",
|
||||
"source": "src/index",
|
||||
"source": "src/index.ts",
|
||||
"react-native": "src/index",
|
||||
"license": "MIT",
|
||||
"author": "Community Contributors",
|
||||
@ -32,9 +32,12 @@
|
||||
"react-native": "0.73.2",
|
||||
"react-native-windows": "^0.61.0-0",
|
||||
"release-it": "^16.2.1",
|
||||
"typescript": "5.1.6"
|
||||
"typescript": "5.1.6",
|
||||
"patch-package": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"shaka-player": "^4.11.7"
|
||||
},
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
|
39
patches/shaka-player+4.11.7.patch
Normal file
39
patches/shaka-player+4.11.7.patch
Normal file
@ -0,0 +1,39 @@
|
||||
diff --git a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
|
||||
index 19c0930..cc0a3fd 100644
|
||||
--- a/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
|
||||
+++ b/node_modules/shaka-player/dist/shaka-player.compiled.d.ts
|
||||
@@ -5117,3 +5117,5 @@ declare namespace shaka.extern {
|
||||
declare namespace shaka.extern {
|
||||
type TransmuxerPlugin = ( ) => shaka.extern.Transmuxer ;
|
||||
}
|
||||
+
|
||||
+export default shaka;
|
||||
diff --git a/node_modules/shaka-player/dist/shaka-player.ui.d.ts b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
|
||||
index 1618ca0..a6076c6 100644
|
||||
--- a/node_modules/shaka-player/dist/shaka-player.ui.d.ts
|
||||
+++ b/node_modules/shaka-player/dist/shaka-player.ui.d.ts
|
||||
@@ -5830,3 +5830,5 @@ declare namespace shaka.extern {
|
||||
declare namespace shaka.extern {
|
||||
type UIVolumeBarColors = { base : string , level : string } ;
|
||||
}
|
||||
+
|
||||
+export default shaka;
|
||||
diff --git a/node_modules/shaka-player/index.d.ts b/node_modules/shaka-player/index.d.ts
|
||||
new file mode 100644
|
||||
index 0000000..3ebfd96
|
||||
--- /dev/null
|
||||
+++ b/node_modules/shaka-player/index.d.ts
|
||||
@@ -0,0 +1,2 @@
|
||||
+/// <reference path="./dist/shaka-player.compiled.d.ts" />
|
||||
+/// <reference path="./dist/shaka-player.ui.d.ts" />
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/shaka-player/ui.d.ts b/node_modules/shaka-player/ui.d.ts
|
||||
new file mode 100644
|
||||
index 0000000..84a3be0
|
||||
--- /dev/null
|
||||
+++ b/node_modules/shaka-player/ui.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+import shaka from 'shaka-player/dist/shaka-player.ui'
|
||||
+export * from 'shaka-player/dist/shaka-player.ui'
|
||||
+export default shaka;
|
||||
\ No newline at end of file
|
13
shell.nix
Normal file
13
shell.nix
Normal file
@ -0,0 +1,13 @@
|
||||
{pkgs ? import <nixpkgs> {}}:
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs-18_x
|
||||
nodePackages.yarn
|
||||
bun
|
||||
eslint_d
|
||||
prettierd
|
||||
jdk11
|
||||
(jdt-language-server.override { jdk = jdk11; })
|
||||
];
|
||||
}
|
||||
|
217
src/Video.tsx
217
src/Video.tsx
@ -16,7 +16,9 @@ import type {
|
||||
ImageResizeMode,
|
||||
} from 'react-native';
|
||||
|
||||
import NativeVideoComponent from './specs/VideoNativeComponent';
|
||||
import NativeVideoComponent, {
|
||||
NativeCmcdConfiguration,
|
||||
} from './specs/VideoNativeComponent';
|
||||
import type {
|
||||
OnAudioFocusChangedData,
|
||||
OnAudioTracksData,
|
||||
@ -44,13 +46,14 @@ import {
|
||||
resolveAssetSourceForVideo,
|
||||
} from './utils';
|
||||
import NativeVideoManager from './specs/NativeVideoManager';
|
||||
import type {VideoSaveData} from './specs/NativeVideoManager';
|
||||
import {ViewType} from './types';
|
||||
import {type VideoSaveData, CmcdMode, ViewType} from './types';
|
||||
import type {
|
||||
OnLoadData,
|
||||
OnTextTracksData,
|
||||
OnReceiveAdEventData,
|
||||
ReactVideoProps,
|
||||
CmcdData,
|
||||
ReactVideoSource,
|
||||
} from './types';
|
||||
|
||||
export interface VideoRef {
|
||||
@ -64,6 +67,7 @@ export interface VideoRef {
|
||||
) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setFullScreen: (fullScreen: boolean) => void;
|
||||
setSource: (source?: ReactVideoSource) => void;
|
||||
save: (options: object) => Promise<VideoSaveData> | void;
|
||||
getCurrentPosition: () => Promise<number>;
|
||||
}
|
||||
@ -77,6 +81,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
poster,
|
||||
posterResizeMode,
|
||||
renderLoader,
|
||||
contentStartTime,
|
||||
drm,
|
||||
textTracks,
|
||||
selectedVideoTrack,
|
||||
@ -86,6 +91,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
useSecureView,
|
||||
viewType,
|
||||
shutterColor,
|
||||
adTagUrl,
|
||||
adLanguage,
|
||||
onLoadStart,
|
||||
onLoad,
|
||||
onError,
|
||||
@ -117,6 +124,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
onTextTrackDataChanged,
|
||||
onVideoTracks,
|
||||
onAspectRatio,
|
||||
localSourceEncryptionKeyScheme,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@ -125,8 +133,18 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
|
||||
const isPosterDeprecated = typeof poster === 'string';
|
||||
|
||||
const _renderLoader = useMemo(
|
||||
() =>
|
||||
!renderLoader
|
||||
? undefined
|
||||
: renderLoader instanceof Function
|
||||
? renderLoader
|
||||
: () => renderLoader,
|
||||
[renderLoader],
|
||||
);
|
||||
|
||||
const hasPoster = useMemo(() => {
|
||||
if (renderLoader) {
|
||||
if (_renderLoader) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -135,7 +153,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
}
|
||||
|
||||
return !!poster?.source;
|
||||
}, [isPosterDeprecated, poster, renderLoader]);
|
||||
}, [isPosterDeprecated, poster, _renderLoader]);
|
||||
|
||||
const [showPoster, setShowPoster] = useState(hasPoster);
|
||||
|
||||
@ -144,58 +162,114 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
setRestoreUserInterfaceForPIPStopCompletionHandler,
|
||||
] = useState<boolean | undefined>();
|
||||
|
||||
const sourceToUnternalSource = useCallback(
|
||||
(_source?: ReactVideoSource) => {
|
||||
if (!_source) {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedSource = resolveAssetSourceForVideo(_source);
|
||||
let uri = resolvedSource.uri || '';
|
||||
if (uri && uri.match(/^\//)) {
|
||||
uri = `file://${uri}`;
|
||||
}
|
||||
if (!uri) {
|
||||
console.log('Trying to load empty source');
|
||||
}
|
||||
const isNetwork = !!(uri && uri.match(/^(rtp|rtsp|http|https):/));
|
||||
const isAsset = !!(
|
||||
uri &&
|
||||
uri.match(
|
||||
/^(assets-library|ipod-library|file|content|ms-appx|ms-appdata):/,
|
||||
)
|
||||
);
|
||||
|
||||
const selectedDrm = _source.drm || drm;
|
||||
const _textTracks = _source.textTracks || textTracks;
|
||||
const _drm = !selectedDrm
|
||||
? undefined
|
||||
: {
|
||||
type: selectedDrm.type,
|
||||
licenseServer: selectedDrm.licenseServer,
|
||||
headers: generateHeaderForNative(selectedDrm.headers),
|
||||
contentId: selectedDrm.contentId,
|
||||
certificateUrl: selectedDrm.certificateUrl,
|
||||
base64Certificate: selectedDrm.base64Certificate,
|
||||
useExternalGetLicense: !!selectedDrm.getLicense,
|
||||
multiDrm: selectedDrm.multiDrm,
|
||||
localSourceEncryptionKeyScheme:
|
||||
selectedDrm.localSourceEncryptionKeyScheme ||
|
||||
localSourceEncryptionKeyScheme,
|
||||
};
|
||||
|
||||
let _cmcd: NativeCmcdConfiguration | undefined;
|
||||
if (Platform.OS === 'android' && source?.cmcd) {
|
||||
const cmcd = source.cmcd;
|
||||
|
||||
if (typeof cmcd === 'boolean') {
|
||||
_cmcd = cmcd ? {mode: CmcdMode.MODE_QUERY_PARAMETER} : undefined;
|
||||
} else if (typeof cmcd === 'object' && !Array.isArray(cmcd)) {
|
||||
const createCmcdHeader = (property?: CmcdData) =>
|
||||
property ? generateHeaderForNative(property) : undefined;
|
||||
|
||||
_cmcd = {
|
||||
mode: cmcd.mode ?? CmcdMode.MODE_QUERY_PARAMETER,
|
||||
request: createCmcdHeader(cmcd.request),
|
||||
session: createCmcdHeader(cmcd.session),
|
||||
object: createCmcdHeader(cmcd.object),
|
||||
status: createCmcdHeader(cmcd.status),
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
'Invalid CMCD configuration: Expected a boolean or an object.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedContentStartTime =
|
||||
_source.contentStartTime || contentStartTime;
|
||||
|
||||
const _ad =
|
||||
_source.ad ||
|
||||
(adTagUrl || adLanguage
|
||||
? {adTagUrl: adTagUrl, adLanguage: adLanguage}
|
||||
: undefined);
|
||||
|
||||
return {
|
||||
uri,
|
||||
isNetwork,
|
||||
isAsset,
|
||||
shouldCache: resolvedSource.shouldCache || false,
|
||||
type: resolvedSource.type || '',
|
||||
mainVer: resolvedSource.mainVer || 0,
|
||||
patchVer: resolvedSource.patchVer || 0,
|
||||
requestHeaders: generateHeaderForNative(resolvedSource.headers),
|
||||
startPosition: resolvedSource.startPosition ?? -1,
|
||||
cropStart: resolvedSource.cropStart || 0,
|
||||
cropEnd: resolvedSource.cropEnd,
|
||||
contentStartTime: selectedContentStartTime,
|
||||
metadata: resolvedSource.metadata,
|
||||
drm: _drm,
|
||||
ad: _ad,
|
||||
cmcd: _cmcd,
|
||||
textTracks: _textTracks,
|
||||
textTracksAllowChunklessPreparation:
|
||||
resolvedSource.textTracksAllowChunklessPreparation,
|
||||
};
|
||||
},
|
||||
[
|
||||
adLanguage,
|
||||
adTagUrl,
|
||||
contentStartTime,
|
||||
drm,
|
||||
localSourceEncryptionKeyScheme,
|
||||
source?.cmcd,
|
||||
textTracks,
|
||||
],
|
||||
);
|
||||
|
||||
const src = useMemo<VideoSrc | undefined>(() => {
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
const resolvedSource = resolveAssetSourceForVideo(source);
|
||||
let uri = resolvedSource.uri || '';
|
||||
if (uri && uri.match(/^\//)) {
|
||||
uri = `file://${uri}`;
|
||||
}
|
||||
if (!uri) {
|
||||
console.log('Trying to load empty source');
|
||||
}
|
||||
const isNetwork = !!(uri && uri.match(/^(rtp|rtsp|http|https):/));
|
||||
const isAsset = !!(
|
||||
uri &&
|
||||
uri.match(
|
||||
/^(assets-library|ipod-library|file|content|ms-appx|ms-appdata):/,
|
||||
)
|
||||
);
|
||||
|
||||
const selectedDrm = source.drm || drm;
|
||||
const _drm = !selectedDrm
|
||||
? undefined
|
||||
: {
|
||||
type: selectedDrm.type,
|
||||
licenseServer: selectedDrm.licenseServer,
|
||||
headers: generateHeaderForNative(selectedDrm.headers),
|
||||
contentId: selectedDrm.contentId,
|
||||
certificateUrl: selectedDrm.certificateUrl,
|
||||
base64Certificate: selectedDrm.base64Certificate,
|
||||
useExternalGetLicense: !!selectedDrm.getLicense,
|
||||
multiDrm: selectedDrm.multiDrm,
|
||||
};
|
||||
|
||||
return {
|
||||
uri,
|
||||
isNetwork,
|
||||
isAsset,
|
||||
shouldCache: resolvedSource.shouldCache || false,
|
||||
type: resolvedSource.type || '',
|
||||
mainVer: resolvedSource.mainVer || 0,
|
||||
patchVer: resolvedSource.patchVer || 0,
|
||||
requestHeaders: generateHeaderForNative(resolvedSource.headers),
|
||||
startPosition: resolvedSource.startPosition ?? -1,
|
||||
cropStart: resolvedSource.cropStart || 0,
|
||||
cropEnd: resolvedSource.cropEnd,
|
||||
metadata: resolvedSource.metadata,
|
||||
drm: _drm,
|
||||
textTracksAllowChunklessPreparation:
|
||||
resolvedSource.textTracksAllowChunklessPreparation,
|
||||
};
|
||||
}, [drm, source]);
|
||||
return sourceToUnternalSource(source);
|
||||
}, [sourceToUnternalSource, source]);
|
||||
|
||||
const _selectedTextTrack = useMemo(() => {
|
||||
if (!selectedTextTrack) {
|
||||
@ -317,6 +391,16 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setSource = useCallback(
|
||||
(_source?: ReactVideoSource) => {
|
||||
return NativeVideoManager.setSourceCmd(
|
||||
getReactTag(nativeRef),
|
||||
sourceToUnternalSource(_source),
|
||||
);
|
||||
},
|
||||
[sourceToUnternalSource],
|
||||
);
|
||||
|
||||
const presentFullscreenPlayer = useCallback(
|
||||
() => setFullScreen(true),
|
||||
[setFullScreen],
|
||||
@ -582,6 +666,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
setFullScreen,
|
||||
setSource,
|
||||
}),
|
||||
[
|
||||
seek,
|
||||
@ -594,6 +679,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
setFullScreen,
|
||||
setSource,
|
||||
],
|
||||
);
|
||||
|
||||
@ -662,15 +748,23 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
}
|
||||
|
||||
// render poster
|
||||
if (renderLoader && (poster || posterResizeMode)) {
|
||||
if (_renderLoader && (poster || posterResizeMode)) {
|
||||
console.warn(
|
||||
'You provided both `renderLoader` and `poster` or `posterResizeMode` props. `renderLoader` will be used.',
|
||||
);
|
||||
}
|
||||
|
||||
// render loader
|
||||
if (renderLoader) {
|
||||
return <View style={StyleSheet.absoluteFill}>{renderLoader}</View>;
|
||||
if (_renderLoader) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
{_renderLoader({
|
||||
source: source,
|
||||
style: posterStyle,
|
||||
resizeMode: resizeMode,
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -685,8 +779,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
isPosterDeprecated,
|
||||
poster,
|
||||
posterResizeMode,
|
||||
renderLoader,
|
||||
_renderLoader,
|
||||
showPoster,
|
||||
source,
|
||||
resizeMode,
|
||||
]);
|
||||
|
||||
const _style: StyleProp<ViewStyle> = useMemo(
|
||||
@ -708,7 +804,6 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
restoreUserInterfaceForPIPStopCompletionHandler={
|
||||
_restoreUserInterfaceForPIPStopCompletionHandler
|
||||
}
|
||||
textTracks={textTracks}
|
||||
selectedTextTrack={_selectedTextTrack}
|
||||
selectedAudioTrack={_selectedAudioTrack}
|
||||
selectedVideoTrack={_selectedVideoTrack}
|
||||
|
453
src/Video.web.tsx
Normal file
453
src/Video.web.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
//@ts-ignore
|
||||
import shaka from 'shaka-player';
|
||||
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
|
||||
|
||||
const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
(
|
||||
{
|
||||
source,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
rate,
|
||||
repeat,
|
||||
controls,
|
||||
showNotificationControls = false,
|
||||
poster,
|
||||
fullscreen,
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
onBuffer,
|
||||
onLoad,
|
||||
onProgress,
|
||||
onPlaybackRateChange,
|
||||
onError,
|
||||
onReadyForDisplay,
|
||||
onSeek,
|
||||
onVolumeChange,
|
||||
onEnd,
|
||||
onPlaybackStateChanged,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const nativeRef = useRef<HTMLVideoElement>(null);
|
||||
const shakaPlayerRef = useRef<shaka.Player | null>(null);
|
||||
|
||||
const isSeeking = useRef(false);
|
||||
const seek = useCallback(
|
||||
async (time: number, _tolerance?: number) => {
|
||||
if (isNaN(time)) {
|
||||
throw new Error('Specified time is not a number');
|
||||
}
|
||||
if (!nativeRef.current) {
|
||||
console.warn('Video Component is not mounted');
|
||||
return;
|
||||
}
|
||||
time = Math.max(0, Math.min(time, nativeRef.current.duration));
|
||||
nativeRef.current.currentTime = time;
|
||||
onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
|
||||
},
|
||||
[onSeek],
|
||||
);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.pause();
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.play();
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((vol: number) => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
|
||||
}, []);
|
||||
|
||||
const getCurrentPosition = useCallback(async () => {
|
||||
if (!nativeRef.current) {
|
||||
throw new Error('Video Component is not mounted');
|
||||
}
|
||||
return nativeRef.current.currentTime;
|
||||
}, []);
|
||||
|
||||
const unsupported = useCallback(() => {
|
||||
throw new Error('This is unsupported on the web');
|
||||
}, []);
|
||||
|
||||
// Stock this in a ref to not invalidate memoization when those changes.
|
||||
const fsPrefs = useRef({
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
});
|
||||
fsPrefs.current = {
|
||||
fullscreenOrientation,
|
||||
fullscreenAutorotate,
|
||||
};
|
||||
const setFullScreen = useCallback(
|
||||
(
|
||||
newVal: boolean,
|
||||
orientation?: ReactVideoProps['fullscreenOrientation'],
|
||||
autorotate?: boolean,
|
||||
) => {
|
||||
orientation ??= fsPrefs.current.fullscreenOrientation;
|
||||
autorotate ??= fsPrefs.current.fullscreenAutorotate;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
if (newVal) {
|
||||
await nativeRef.current?.requestFullscreen({
|
||||
navigationUI: 'hide',
|
||||
});
|
||||
if (orientation === 'all' || !orientation || autorotate) {
|
||||
screen.orientation.unlock();
|
||||
} else {
|
||||
await screen.orientation.lock(orientation);
|
||||
}
|
||||
} else {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (e) {
|
||||
// Changing fullscreen status without a button click is not allowed so it throws.
|
||||
// Some browsers also used to throw when locking screen orientation was not supported.
|
||||
console.error('Could not toggle fullscreen/screen lock status', e);
|
||||
}
|
||||
};
|
||||
run();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFullScreen(
|
||||
fullscreen || false,
|
||||
fullscreenOrientation,
|
||||
fullscreenAutorotate,
|
||||
);
|
||||
}, [
|
||||
setFullScreen,
|
||||
fullscreen,
|
||||
fullscreenAutorotate,
|
||||
fullscreenOrientation,
|
||||
]);
|
||||
|
||||
const presentFullscreenPlayer = useCallback(
|
||||
() => setFullScreen(true),
|
||||
[setFullScreen],
|
||||
);
|
||||
const dismissFullscreenPlayer = useCallback(
|
||||
() => setFullScreen(false),
|
||||
[setFullScreen],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
seek,
|
||||
pause,
|
||||
resume,
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
setFullScreen,
|
||||
save: unsupported,
|
||||
restoreUserInterfaceForPictureInPictureStopCompleted: unsupported,
|
||||
nativeHtmlVideoRef: nativeRef,
|
||||
}),
|
||||
[
|
||||
seek,
|
||||
pause,
|
||||
resume,
|
||||
unsupported,
|
||||
setVolume,
|
||||
getCurrentPosition,
|
||||
nativeRef,
|
||||
presentFullscreenPlayer,
|
||||
dismissFullscreenPlayer,
|
||||
setFullScreen,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [paused, pause, resume]);
|
||||
useEffect(() => {
|
||||
if (volume === undefined) {
|
||||
return;
|
||||
}
|
||||
setVolume(volume);
|
||||
}, [volume, setVolume]);
|
||||
|
||||
// we use a ref to prevent triggerring the useEffect when the component rerender with a non-stable `onPlaybackStateChanged`.
|
||||
const playbackStateRef = useRef(onPlaybackStateChanged);
|
||||
playbackStateRef.current = onPlaybackStateChanged;
|
||||
useEffect(() => {
|
||||
// Not sure about how to do this but we want to wait for nativeRef to be initialized
|
||||
setTimeout(() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set play state to the player's value (if autoplay is denied)
|
||||
// This is useful if our UI is in a play state but autoplay got denied so
|
||||
// the video is actaully in a paused state.
|
||||
playbackStateRef.current?.({
|
||||
isPlaying: !nativeRef.current.paused,
|
||||
isSeeking: isSeeking.current,
|
||||
});
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nativeRef.current || rate === undefined) {
|
||||
return;
|
||||
}
|
||||
nativeRef.current.playbackRate = rate;
|
||||
}, [rate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nativeRef.current) {
|
||||
console.log("Not starting shaka yet bc undefined")
|
||||
return;
|
||||
}
|
||||
if (shakaPlayerRef.current) {
|
||||
shakaPlayerRef.current.unload()
|
||||
}
|
||||
shakaPlayerRef.current = new shaka.Player();
|
||||
//@ts-ignore
|
||||
shakaPlayerRef.current.addEventListener("error", (event) => {
|
||||
//@ts-ignore
|
||||
const shakaError = event.detail;
|
||||
console.error('Shaka Player Error', shakaError);
|
||||
onError?.({
|
||||
error: {
|
||||
errorString: shakaError.message,
|
||||
code: shakaError.code,
|
||||
},
|
||||
});
|
||||
});
|
||||
console.log("Initializing and attaching shaka")
|
||||
shakaPlayerRef.current.attach(nativeRef.current, true);
|
||||
|
||||
//@ts-ignore
|
||||
shakaPlayerRef.current.load(source?.uri).then(
|
||||
() => console.log(`${source?.uri} finished loading`)
|
||||
);
|
||||
console.log("Started shaka loading");
|
||||
}, [source, nativeRef.current])
|
||||
|
||||
useMediaSession(source?.metadata, nativeRef, showNotificationControls);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={nativeRef}
|
||||
muted={muted}
|
||||
autoPlay={!paused}
|
||||
controls={controls}
|
||||
loop={repeat}
|
||||
playsInline
|
||||
//@ts-ignore
|
||||
poster={poster}
|
||||
onCanPlay={() => onBuffer?.({isBuffering: false})}
|
||||
onWaiting={() => onBuffer?.({isBuffering: true})}
|
||||
onRateChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onPlaybackRateChange?.({
|
||||
playbackRate: nativeRef.current?.playbackRate,
|
||||
});
|
||||
}}
|
||||
onDurationChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onLoad?.({
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
duration: nativeRef.current.duration,
|
||||
videoTracks: [],
|
||||
textTracks: [],
|
||||
audioTracks: [],
|
||||
naturalSize: {
|
||||
width: nativeRef.current.videoWidth,
|
||||
height: nativeRef.current.videoHeight,
|
||||
orientation: 'landscape',
|
||||
},
|
||||
});
|
||||
}}
|
||||
onTimeUpdate={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onProgress?.({
|
||||
currentTime: nativeRef.current.currentTime,
|
||||
playableDuration: nativeRef.current.buffered.length
|
||||
? nativeRef.current.buffered.end(
|
||||
nativeRef.current.buffered.length - 1,
|
||||
)
|
||||
: 0,
|
||||
seekableDuration: 0,
|
||||
});
|
||||
}}
|
||||
onLoadedData={() => onReadyForDisplay?.()}
|
||||
onError={() => {
|
||||
if (!nativeRef.current?.error) {
|
||||
return;
|
||||
}
|
||||
onError?.({
|
||||
error: {
|
||||
errorString: nativeRef.current.error.message ?? 'Unknown error',
|
||||
code: nativeRef.current.error.code,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
if (source?.startPosition) {
|
||||
seek(source.startPosition / 1000);
|
||||
}
|
||||
}}
|
||||
onPlay={() =>
|
||||
onPlaybackStateChanged?.({
|
||||
isPlaying: true,
|
||||
isSeeking: isSeeking.current,
|
||||
})
|
||||
}
|
||||
onPause={() =>
|
||||
onPlaybackStateChanged?.({
|
||||
isPlaying: false,
|
||||
isSeeking: isSeeking.current,
|
||||
})
|
||||
}
|
||||
onSeeking={() => (isSeeking.current = true)}
|
||||
onSeeked={() => (isSeeking.current = false)}
|
||||
onVolumeChange={() => {
|
||||
if (!nativeRef.current) {
|
||||
return;
|
||||
}
|
||||
onVolumeChange?.({volume: nativeRef.current.volume});
|
||||
}}
|
||||
onEnded={onEnd}
|
||||
style={videoStyle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const videoStyle = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
} satisfies React.CSSProperties;
|
||||
|
||||
const useMediaSession = (
|
||||
metadata: VideoMetadata | undefined,
|
||||
nativeRef: RefObject<HTMLVideoElement>,
|
||||
showNotification: boolean,
|
||||
) => {
|
||||
const isPlaying = !nativeRef.current?.paused ?? false;
|
||||
const progress = nativeRef.current?.currentTime ?? 0;
|
||||
const duration = Number.isFinite(nativeRef.current?.duration)
|
||||
? nativeRef.current?.duration
|
||||
: undefined;
|
||||
const playbackRate = nativeRef.current?.playbackRate ?? 1;
|
||||
|
||||
const enabled = 'mediaSession' in navigator && showNotification;
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: metadata?.title,
|
||||
artist: metadata?.artist,
|
||||
artwork: metadata?.imageUri ? [{src: metadata.imageUri}] : undefined,
|
||||
});
|
||||
}
|
||||
}, [enabled, metadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTo = (time: number) => {
|
||||
if (nativeRef.current) {
|
||||
nativeRef.current.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const seekRelative = (offset: number) => {
|
||||
if (nativeRef.current) {
|
||||
nativeRef.current.currentTime = nativeRef.current.currentTime + offset;
|
||||
}
|
||||
};
|
||||
|
||||
const mediaActions: [
|
||||
MediaSessionAction,
|
||||
MediaSessionActionHandler | null,
|
||||
][] = [
|
||||
['play', () => nativeRef.current?.play()],
|
||||
['pause', () => nativeRef.current?.pause()],
|
||||
[
|
||||
'seekbackward',
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
seekRelative(evt.seekOffset ? -evt.seekOffset : -10),
|
||||
],
|
||||
[
|
||||
'seekforward',
|
||||
(evt: MediaSessionActionDetails) =>
|
||||
seekRelative(evt.seekOffset ? evt.seekOffset : 10),
|
||||
],
|
||||
['seekto', (evt: MediaSessionActionDetails) => seekTo(evt.seekTime!)],
|
||||
];
|
||||
|
||||
for (const [action, handler] of mediaActions) {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handler);
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}, [enabled, nativeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||
}
|
||||
}, [isPlaying, enabled]);
|
||||
useEffect(() => {
|
||||
if (enabled && duration !== undefined) {
|
||||
navigator.mediaSession.setPositionState({
|
||||
position: Math.min(progress, duration),
|
||||
duration,
|
||||
playbackRate: playbackRate,
|
||||
});
|
||||
}
|
||||
}, [progress, duration, playbackRate, enabled]);
|
||||
};
|
||||
|
||||
Video.displayName = 'Video';
|
||||
export default Video;
|
34
src/VideoDecoderProperties.web.ts
Normal file
34
src/VideoDecoderProperties.web.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/// <reference lib="dom" />
|
||||
import type {VideoDecoderInfoModuleType} from './specs/NativeVideoDecoderInfoModule';
|
||||
|
||||
const canPlay = (codec: string): boolean => {
|
||||
// most chrome based browser (and safari I think) supports matroska but reports they do not.
|
||||
// for those browsers, only check the codecs and not the container.
|
||||
if (navigator.userAgent.search('Firefox') === -1) {
|
||||
codec = codec.replace('video/x-matroska', 'video/mp4');
|
||||
}
|
||||
|
||||
return !!MediaSource.isTypeSupported(codec);
|
||||
};
|
||||
|
||||
export const VideoDecoderProperties = {
|
||||
async getWidevineLevel() {
|
||||
return 0;
|
||||
},
|
||||
|
||||
async isCodecSupported(
|
||||
mimeType: string,
|
||||
_width: number,
|
||||
_height: number,
|
||||
): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||
// TODO: Figure out if we can get hardware support information
|
||||
return canPlay(mimeType) ? 'software' : 'unsupported';
|
||||
},
|
||||
|
||||
async isHEVCSupported(): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||
// Just a dummy vidoe mime type codec with HEVC to check.
|
||||
return canPlay('video/x-matroska; codecs="hvc1.1.4.L96.BO"')
|
||||
? 'software'
|
||||
: 'unsupported';
|
||||
},
|
||||
} satisfies VideoDecoderInfoModuleType;
|
@ -13,7 +13,7 @@ export const withBackgroundAudio: ConfigPlugin<boolean> = (
|
||||
|
||||
if (enableBackgroundAudio) {
|
||||
if (!modes.includes('audio')) {
|
||||
modes.push('audio');
|
||||
config.modResults.UIBackgroundModes = [...modes, 'audio'];
|
||||
}
|
||||
} else {
|
||||
config.modResults.UIBackgroundModes = modes.filter(
|
||||
|
@ -24,6 +24,19 @@ export const withNotificationControls: ConfigPlugin<boolean> = (
|
||||
application.service = [];
|
||||
}
|
||||
|
||||
// We check if the VideoPlaybackService is already defined in the AndroidManifest.xml
|
||||
// to prevent adding duplicate service entries. If the service exists, we will remove
|
||||
// it before adding the updated configuration to ensure there are no conflicts or redundant
|
||||
// service declarations in the manifest.
|
||||
const existingServiceIndex = application.service.findIndex(
|
||||
(service) =>
|
||||
service?.$?.['android:name'] ===
|
||||
'com.brentvatne.exoplayer.VideoPlaybackService',
|
||||
);
|
||||
if (existingServiceIndex !== -1) {
|
||||
application.service.splice(existingServiceIndex, 1);
|
||||
}
|
||||
|
||||
application.service.push({
|
||||
$: {
|
||||
'android:name': 'com.brentvatne.exoplayer.VideoPlaybackService',
|
||||
|
@ -1,5 +1,5 @@
|
||||
import Video from './Video';
|
||||
export {VideoDecoderProperties} from './VideoDecoderProperties';
|
||||
export * from './types';
|
||||
export type {VideoRef} from './Video';
|
||||
export {Video};
|
||||
export default Video;
|
||||
|
@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
|
||||
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
interface VideoDecoderInfoModuleType {
|
||||
export interface VideoDecoderInfoModuleType {
|
||||
getWidevineLevel: () => Promise<Int32>;
|
||||
isCodecSupported: (
|
||||
mimeType: string,
|
||||
|
@ -4,10 +4,7 @@ import type {
|
||||
Float,
|
||||
UnsafeObject,
|
||||
} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
export type VideoSaveData = {
|
||||
uri: string;
|
||||
};
|
||||
import type {VideoSaveData} from '../types/video-ref';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
export interface VideoManagerType {
|
||||
@ -24,6 +21,7 @@ export interface VideoManagerType {
|
||||
licenseUrl: string,
|
||||
) => Promise<void>;
|
||||
setFullScreenCmd: (reactTag: Int32, fullScreen: boolean) => Promise<void>;
|
||||
setSourceCmd: (reactTag: Int32, source?: UnsafeObject) => Promise<void>;
|
||||
setVolumeCmd: (reactTag: Int32, volume: number) => Promise<void>;
|
||||
save: (reactTag: Int32, option: UnsafeObject) => Promise<VideoSaveData>;
|
||||
getCurrentPosition: (reactTag: Int32) => Promise<Int32>;
|
||||
|
@ -26,6 +26,11 @@ type VideoMetadata = Readonly<{
|
||||
imageUri?: string;
|
||||
}>;
|
||||
|
||||
export type AdsConfig = Readonly<{
|
||||
adTagUrl?: string;
|
||||
adLanguage?: string;
|
||||
}>;
|
||||
|
||||
export type VideoSrc = Readonly<{
|
||||
uri?: string;
|
||||
isNetwork?: boolean;
|
||||
@ -38,9 +43,13 @@ export type VideoSrc = Readonly<{
|
||||
startPosition?: Float;
|
||||
cropStart?: Float;
|
||||
cropEnd?: Float;
|
||||
contentStartTime?: Int32; // Android
|
||||
metadata?: VideoMetadata;
|
||||
drm?: Drm;
|
||||
cmcd?: NativeCmcdConfiguration; // android
|
||||
textTracksAllowChunklessPreparation?: boolean; // android
|
||||
textTracks?: TextTracks;
|
||||
ad?: AdsConfig;
|
||||
}>;
|
||||
|
||||
type DRMType = WithDefault<string, 'widevine'>;
|
||||
@ -59,6 +68,16 @@ type Drm = Readonly<{
|
||||
base64Certificate?: boolean; // ios default: false
|
||||
useExternalGetLicense?: boolean; // ios
|
||||
multiDrm?: WithDefault<boolean, false>; // android
|
||||
localSourceEncryptionKeyScheme?: string; // ios
|
||||
}>;
|
||||
|
||||
type CmcdMode = WithDefault<Int32, 1>;
|
||||
export type NativeCmcdConfiguration = Readonly<{
|
||||
mode?: CmcdMode; // default: MODE_QUERY_PARAMETER
|
||||
request?: Headers;
|
||||
session?: Headers;
|
||||
object?: Headers;
|
||||
status?: Headers;
|
||||
}>;
|
||||
|
||||
type TextTracks = ReadonlyArray<
|
||||
@ -121,6 +140,7 @@ type SubtitleStyle = Readonly<{
|
||||
paddingLeft?: WithDefault<Float, 0>;
|
||||
paddingRight?: WithDefault<Float, 0>;
|
||||
opacity?: WithDefault<Float, 1>;
|
||||
subtitlesFollowVideo?: WithDefault<boolean, true>;
|
||||
}>;
|
||||
|
||||
type OnLoadData = Readonly<{
|
||||
@ -270,12 +290,12 @@ type OnReceiveAdEventData = Readonly<{
|
||||
|
||||
export type OnVideoErrorData = Readonly<{
|
||||
error: Readonly<{
|
||||
errorString?: string; // android
|
||||
errorString?: string; // android | web
|
||||
errorException?: string; // android
|
||||
errorStackTrace?: string; // android
|
||||
errorCode?: string; // android
|
||||
error?: string; // ios
|
||||
code?: Int32; // ios
|
||||
code?: Int32; // ios | web
|
||||
localizedDescription?: string; // ios
|
||||
localizedFailureReason?: string; // ios
|
||||
localizedRecoverySuggestion?: string; // ios
|
||||
@ -289,8 +309,20 @@ export type OnAudioFocusChangedData = Readonly<{
|
||||
}>;
|
||||
|
||||
type ControlsStyles = Readonly<{
|
||||
hideSeekBar?: boolean;
|
||||
hidePosition?: WithDefault<boolean, false>;
|
||||
hidePlayPause?: WithDefault<boolean, false>;
|
||||
hideForward?: WithDefault<boolean, false>;
|
||||
hideRewind?: WithDefault<boolean, false>;
|
||||
hideNext?: WithDefault<boolean, false>;
|
||||
hidePrevious?: WithDefault<boolean, false>;
|
||||
hideFullscreen?: WithDefault<boolean, false>;
|
||||
hideSeekBar?: WithDefault<boolean, false>;
|
||||
hideDuration?: WithDefault<boolean, false>;
|
||||
hideNavigationBarOnFullScreenMode?: WithDefault<boolean, true>;
|
||||
hideNotificationBarOnFullScreenMode?: WithDefault<boolean, true>;
|
||||
hideSettingButton?: WithDefault<boolean, true>;
|
||||
seekIncrementMS?: Int32;
|
||||
liveLabel?: string;
|
||||
}>;
|
||||
|
||||
export type OnControlsVisibilityChange = Readonly<{
|
||||
@ -299,7 +331,6 @@ export type OnControlsVisibilityChange = Readonly<{
|
||||
|
||||
export interface VideoNativeProps extends ViewProps {
|
||||
src?: VideoSrc;
|
||||
adTagUrl?: string;
|
||||
allowsExternalPlayback?: boolean; // ios, true
|
||||
disableFocus?: boolean; // android
|
||||
maxBitRate?: Float;
|
||||
@ -308,7 +339,6 @@ export interface VideoNativeProps extends ViewProps {
|
||||
automaticallyWaitsToMinimizeStalling?: boolean;
|
||||
shutterColor?: Int32;
|
||||
audioOutput?: WithDefault<string, 'speaker'>;
|
||||
textTracks?: TextTracks;
|
||||
selectedTextTrack?: SelectedTextTrack;
|
||||
selectedAudioTrack?: SelectedAudioTrack;
|
||||
selectedVideoTrack?: SelectedVideoTrack; // android
|
||||
@ -331,11 +361,9 @@ export interface VideoNativeProps extends ViewProps {
|
||||
fullscreenOrientation?: WithDefault<string, 'all'>;
|
||||
progressUpdateInterval?: Float;
|
||||
restoreUserInterfaceForPIPStopCompletionHandler?: boolean;
|
||||
localSourceEncryptionKeyScheme?: string;
|
||||
debug?: DebugConfig;
|
||||
showNotificationControls?: WithDefault<boolean, false>; // Android, iOS
|
||||
bufferConfig?: BufferConfig; // Android
|
||||
contentStartTime?: Int32; // Android
|
||||
currentPlaybackTime?: Double; // Android
|
||||
disableDisconnectError?: boolean; // Android
|
||||
focusable?: boolean; // Android
|
||||
|
@ -21,6 +21,8 @@ import type {
|
||||
OnVolumeChangeData,
|
||||
} from '../specs/VideoNativeComponent';
|
||||
|
||||
export type * from '../specs/VideoNativeComponent';
|
||||
|
||||
export type AudioTrack = OnAudioTracksData['audioTracks'][number];
|
||||
export type TextTrack = OnTextTracksData['textTracks'][number];
|
||||
export type VideoTrack = OnVideoTracksData['videoTracks'][number];
|
||||
|
@ -7,4 +7,4 @@ export {default as ResizeMode} from './ResizeMode';
|
||||
export {default as TextTrackType} from './TextTrackType';
|
||||
export {default as ViewType} from './ViewType';
|
||||
export * from './video';
|
||||
export * from '../specs/VideoNativeComponent';
|
||||
export * from './video-ref';
|
||||
|
21
src/types/video-ref.ts
Normal file
21
src/types/video-ref.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type {RefObject} from 'react';
|
||||
|
||||
export type VideoSaveData = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export interface VideoRef {
|
||||
seek: (time: number, tolerance?: number) => void;
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
presentFullscreenPlayer: () => void;
|
||||
dismissFullscreenPlayer: () => void;
|
||||
restoreUserInterfaceForPictureInPictureStopCompleted: (
|
||||
restore: boolean,
|
||||
) => void;
|
||||
save: (options: object) => Promise<VideoSaveData>;
|
||||
setVolume: (volume: number) => void;
|
||||
getCurrentPosition: () => Promise<number>;
|
||||
setFullScreen: (fullScreen: boolean) => void;
|
||||
nativeHtmlVideoRef?: RefObject<HTMLVideoElement>; // web only
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user