Merge remote-tracking branch 'origin/master' into feat/web

This commit is contained in:
Ivan Malison 2024-10-12 22:33:11 -06:00
commit d79b5c9a83
99 changed files with 5602 additions and 4049 deletions

View File

@ -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"

View File

@ -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
View 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
View File

@ -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
View 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
View 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})

View File

@ -1,5 +1,125 @@
## [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)
### Bug Fixes
* **android:** app crash at boot with old arch ([#4022](https://github.com/TheWidlarzGroup/react-native-video/issues/4022)) ([1ee5811](https://github.com/TheWidlarzGroup/react-native-video/commit/1ee5811c8e0ecfc2486f5120b575b57c6396e0f8)), closes [#3875](https://github.com/TheWidlarzGroup/react-native-video/issues/3875)
* **android:** fix backward compatibility ([#4020](https://github.com/TheWidlarzGroup/react-native-video/issues/4020)) ([ab7e02e](https://github.com/TheWidlarzGroup/react-native-video/commit/ab7e02e4538c97340f13fb052b1cad94408b48fa))
* **android:** resize mode cover calculation ([#4010](https://github.com/TheWidlarzGroup/react-native-video/issues/4010)) ([9f38216](https://github.com/TheWidlarzGroup/react-native-video/commit/9f382163d83c3331518e2784b335da28179ac91d))
* index of the selected track ([#4012](https://github.com/TheWidlarzGroup/react-native-video/issues/4012)) ([fb1d6bd](https://github.com/TheWidlarzGroup/react-native-video/commit/fb1d6bdef7210d43f9e34f673691a9a17b95424e))
* **sample:** boot failure on emulator ([#4016](https://github.com/TheWidlarzGroup/react-native-video/issues/4016)) ([ffbc977](https://github.com/TheWidlarzGroup/react-native-video/commit/ffbc977ff90248aeb270626620f5c3553f955617))
### Features
* add ability to define `poster` props as Image type and render poster as custom component ([#3972](https://github.com/TheWidlarzGroup/react-native-video/issues/3972)) ([adbd06e](https://github.com/TheWidlarzGroup/react-native-video/commit/adbd06e2df557b22b8a3a19073a2de1cbb964833))
* **android:** add error handling for Kotlin version ([#4018](https://github.com/TheWidlarzGroup/react-native-video/issues/4018)) ([6189080](https://github.com/TheWidlarzGroup/react-native-video/commit/6189080c9aac89aa3d2a4e60049999d8880bc971))
* **android:** set originalFitsSystemWindows on fullscreen open ([#4013](https://github.com/TheWidlarzGroup/react-native-video/issues/4013)) ([2f70c02](https://github.com/TheWidlarzGroup/react-native-video/commit/2f70c02cdcf6488338df197feb61eeb10ed3281f))
## [6.4.2](https://github.com/TheWidlarzGroup/react-native-video/compare/v6.4.1...v6.4.2) (2024-07-15)

View File

@ -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" />

View File

@ -5,6 +5,21 @@ apply plugin: 'kotlin-android'
buildscript {
def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNVideo_kotlinVersion']
def requiredKotlinVersion = project.properties['RNVideo_kotlinVersion']
def isVersionAtLeast = { version, requiredVersion ->
def (v1, v2) = [version, requiredVersion].collect { it.tokenize('.')*.toInteger() }
for (int i = 0; i < Math.max(v1.size(), v2.size()); i++) {
int val1 = i < v1.size() ? v1[i] : 0
int val2 = i < v2.size() ? v2[i] : 0
if (val1 < val2) {
return false
} else if (val1 > val2) {
return true
}
}
return true
}
repositories {
mavenCentral()
@ -13,6 +28,14 @@ buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
ext {
if (!isVersionAtLeast(kotlin_version, requiredKotlinVersion)) {
throw new GradleException("Kotlin version mismatch: Project is using Kotlin version $kotlin_version, but it must be at least $requiredKotlinVersion. Please update the Kotlin version.")
} else {
println("Kotlin version is correct: $kotlin_version")
}
}
}
// This looks funny but it's necessary to keep backwards compatibility (:
@ -184,7 +207,6 @@ repositories {
}
def media3_version = safeExtGet('media3Version')
def kotlin_version = safeExtGet('kotlinVersion')
def androidxCore_version = safeExtGet('androidxCoreVersion')
def androidxActivity_version = safeExtGet('androidxActivityVersion')
@ -194,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
@ -280,5 +302,4 @@ dependencies {
// Common functionality used across multiple media libraries
implementation "androidx.media3:media3-common:$media3_version"
}
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}

View File

@ -1,15 +1,15 @@
RNVideo_kotlinVersion=1.7.0
RNVideo_kotlinVersion=1.8.0
RNVideo_minSdkVersion=23
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

View File

@ -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;
}

View 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
}
}
}

View 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
}
}
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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))

View File

@ -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) {

View File

@ -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
}
}

View File

@ -48,7 +48,7 @@ enum class EventTypes(val eventName: String) {
fun toMap() =
mutableMapOf<String, Any>().apply {
EventTypes.values().toList().forEach { eventType ->
put("top${eventType.eventName.removePrefix("on")}", mapOf("registrationName" to eventType.eventName))
put("top${eventType.eventName.removePrefix("on")}", hashMapOf("registrationName" to eventType.eventName))
}
}
}
@ -64,11 +64,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 onVideoEnd: () -> Unit
@ -108,7 +108,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))
@ -153,9 +153,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 ->
@ -209,7 +213,7 @@ class VideoEventEmitter {
putArray(
"metadata",
Arguments.createArray().apply {
metadataArrayList.forEachIndexed { i, metadata ->
metadataArrayList.forEachIndexed { _, metadata ->
pushMap(
Arguments.createMap().apply {
putString("identifier", metadata.identifier)
@ -303,7 +307,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)
@ -336,15 +340,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)
}
}

View File

@ -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.
*

View File

@ -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
@ -75,9 +76,9 @@ class AspectRatioFrameLayout(context: Context) : FrameLayout(context) {
// Scale video if it doesn't fill the measuredWidth
if (width < measuredWidth) {
val scaleFactor: Int = measuredWidth / width
width *= scaleFactor
height = measuredHeight * scaleFactor
val scaleFactor: Float = measuredWidth.toFloat() / width
width = (scaleFactor * width).toInt()
height = (scaleFactor * height).toInt()
}
}
@ -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
}
}
}

View 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}")
}
}

View File

@ -1,293 +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, 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);
}
}
}

View 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"
}
}

View File

@ -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,15 +109,28 @@ class FullScreenPlayerView(
}
parent?.requestLayout()
parent = null
onBackPressedCallback.handleOnBackPressed()
restoreSystemUI()
}
private fun getFullscreenIconResource(isFullscreen: Boolean): Int {
return if (isFullscreen) {
// restore system UI state
private fun restoreSystemUI() {
window?.let {
updateNavigationBarVisibility(
it,
initialNavigationBarIsVisible,
initialNotificationBarIsVisible,
initialSystemBarsBehavior
)
}
}
private fun getFullscreenIconResource(isFullscreen: Boolean): Int =
if (isFullscreen) {
androidx.media3.ui.R.drawable.exo_icon_fullscreen_exit
} else {
androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter
}
}
private fun updateFullscreenButton(playerControlView: LegacyPlayerControlView, isFullscreen: Boolean) {
val imageButton = playerControlView.findViewById<ImageButton?>(com.brentvatne.react.R.id.exo_fullscreen)
@ -128,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
}
}
}
}

View File

@ -7,10 +7,10 @@ import static androidx.media3.common.C.CONTENT_TYPE_RTSP;
import static androidx.media3.common.C.CONTENT_TYPE_SS;
import static androidx.media3.common.C.TIME_END_OF_SOURCE;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
@ -24,7 +24,6 @@ import android.os.Looper;
import android.os.Message;
import android.text.TextUtils;
import android.view.View;
import android.view.Window;
import android.view.accessibility.CaptioningManager;
import android.widget.FrameLayout;
import android.widget.ImageButton;
@ -33,10 +32,8 @@ import android.widget.TextView;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.Format;
@ -96,6 +93,7 @@ import androidx.media3.exoplayer.trackselection.MappingTrackSelector;
import androidx.media3.exoplayer.trackselection.TrackSelection;
import androidx.media3.exoplayer.trackselection.TrackSelectionArray;
import androidx.media3.exoplayer.upstream.BandwidthMeter;
import androidx.media3.exoplayer.upstream.CmcdConfiguration;
import androidx.media3.exoplayer.upstream.DefaultAllocator;
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter;
import androidx.media3.exoplayer.util.EventLogger;
@ -103,16 +101,15 @@ import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.session.MediaSessionService;
import androidx.media3.ui.DefaultTimeBar;
import androidx.media3.ui.LegacyPlayerControlView;
import com.brentvatne.common.api.AdsProps;
import com.brentvatne.common.api.BufferConfig;
import com.brentvatne.common.api.BufferingStrategy;
import com.brentvatne.common.api.ControlsConfig;
import com.brentvatne.common.api.DRMProps;
import com.brentvatne.common.api.ResizeMode;
import com.brentvatne.common.api.SideLoadedTextTrack;
import com.brentvatne.common.api.SideLoadedTextTrackList;
import com.brentvatne.common.api.Source;
import com.brentvatne.common.api.SubtitleStyle;
import com.brentvatne.common.api.TimedMetadata;
@ -120,6 +117,7 @@ import com.brentvatne.common.api.Track;
import com.brentvatne.common.api.VideoTrack;
import com.brentvatne.common.react.VideoEventEmitter;
import com.brentvatne.common.toolbox.DebugLog;
import com.brentvatne.common.toolbox.ReactBridgeUtils;
import com.brentvatne.react.BuildConfig;
import com.brentvatne.react.R;
import com.brentvatne.react.ReactNativeVideoManager;
@ -130,16 +128,17 @@ import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.UiThreadUtil;
import com.facebook.react.uimanager.ThemedReactContext;
import com.google.ads.interactivemedia.v3.api.AdError;
import com.google.ads.interactivemedia.v3.api.AdEvent;
import com.google.ads.interactivemedia.v3.api.AdErrorEvent;
import com.google.ads.interactivemedia.v3.api.AdEvent;
import com.google.ads.interactivemedia.v3.api.ImaSdkFactory;
import com.google.ads.interactivemedia.v3.api.ImaSdkSettings;
import com.google.common.collect.ImmutableList;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.lang.Math;
import java.util.List;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
@ -188,8 +187,6 @@ public class ReactExoplayerView extends FrameLayout implements
private ExoPlayer player;
private DefaultTrackSelector trackSelector;
private boolean playerNeedsSource;
private MediaMetadata customMetadata;
private ServiceConnection playbackServiceConnection;
private PlaybackServiceBinder playbackServiceBinder;
@ -235,20 +232,17 @@ public class ReactExoplayerView extends FrameLayout implements
private String audioTrackValue;
private String videoTrackType;
private String videoTrackValue;
private String textTrackType;
private String textTrackType = "disabled";
private String textTrackValue;
private SideLoadedTextTrackList textTracks;
private boolean disableFocus;
private boolean focusable = true;
private BufferingStrategy.BufferingStrategyEnum bufferingStrategy;
private long contentStartTime = -1L;
private boolean disableDisconnectError;
private boolean preventsDisplaySleepDuringVideoPlayback = true;
private float mProgressUpdateInterval = 250.0f;
private boolean playInBackground = false;
private boolean mReportBandwidth = false;
private boolean controls;
private Uri adTagUrl;
private boolean showNotificationControls = false;
// \ End props
@ -265,8 +259,15 @@ public class ReactExoplayerView extends FrameLayout implements
private long lastDuration = -1;
private boolean viewHasDropped = false;
private int selectedSpeedIndex = 1; // Default is 1.0x
private String instanceId = String.valueOf(UUID.randomUUID());
private final String instanceId = String.valueOf(UUID.randomUUID());
private CmcdConfiguration.Factory cmcdConfigurationFactory;
public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) {
this.cmcdConfigurationFactory = factory;
}
private void updateProgress() {
if (player != null) {
@ -385,12 +386,13 @@ public class ReactExoplayerView extends FrameLayout implements
public void onBandwidthSample(int elapsedMs, long bytes, long bitrate) {
if (mReportBandwidth) {
if (player == null) {
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, "-1");
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, 0, 0, null);
} else {
Format videoFormat = player.getVideoFormat();
int width = videoFormat != null ? videoFormat.width : 0;
int height = videoFormat != null ? videoFormat.height : 0;
String trackId = videoFormat != null ? videoFormat.id : "-1";
boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
String trackId = videoFormat != null ? videoFormat.id : null;
eventEmitter.onVideoBandwidthUpdate.invoke(bitrate, height, width, trackId);
}
}
@ -425,15 +427,6 @@ public class ReactExoplayerView extends FrameLayout implements
});
}
if (fullScreenPlayerView == null) {
fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, playerControlView, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
setFullscreen(false);
}
});
}
// Setting the player for the playerControlView
playerControlView.setPlayer(player);
playPauseControlContainer = playerControlView.findViewById(R.id.exo_play_pause_container);
@ -447,6 +440,7 @@ public class ReactExoplayerView extends FrameLayout implements
//Handling the playButton click event
ImageButton playButton = playerControlView.findViewById(R.id.exo_play);
playButton.setOnClickListener((View v) -> {
if (player != null && player.getPlaybackState() == Player.STATE_ENDED) {
player.seekTo(0);
@ -471,11 +465,15 @@ public class ReactExoplayerView extends FrameLayout implements
setPausedModifier(true)
);
//Handling the settingButton click event
final ImageButton settingButton = playerControlView.findViewById(R.id.exo_settings);
settingButton.setOnClickListener(v -> openSettings());
//Handling the fullScreenButton click event
final ImageButton fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen);
fullScreenButton.setOnClickListener(v -> setFullscreen(!isFullscreen));
updateFullScreenButtonVisibility();
refreshProgressBarVisibility();
refreshControlsStyles();
// Invoking onPlaybackStateChanged and onPlayWhenReadyChanged events for Player
eventListener = new Player.Listener() {
@ -489,6 +487,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (pauseButton != null && pauseButton.getVisibility() == GONE) {
pauseButton.setVisibility(INVISIBLE);
}
reLayout(playPauseControlContainer);
//Remove this eventListener once its executed. since UI will work fine once after the reLayout is done
player.removeListener(eventListener);
@ -503,6 +502,35 @@ public class ReactExoplayerView extends FrameLayout implements
};
player.addListener(eventListener);
}
private void openSettings() {
AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext);
builder.setTitle(R.string.settings);
String[] settingsOptions = {themedReactContext.getString(R.string.playback_speed)};
builder.setItems(settingsOptions, (dialog, which) -> {
if (which == 0) {
showPlaybackSpeedOptions();
}
});
builder.show();
}
private void showPlaybackSpeedOptions() {
String[] speedOptions = {"0.5x", "1.0x", "1.5x", "2.0x"};
AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext);
builder.setTitle(R.string.select_playback_speed);
builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> {
selectedSpeedIndex = which;
float speed = switch (which) {
case 0 -> 0.5f;
case 2 -> 1.5f;
case 3 -> 2.0f;
default -> 1.0f;
};
setRateModifier(speed);
});
builder.show();
}
/**
* Adding Player control to the frame layout
@ -534,40 +562,89 @@ public class ReactExoplayerView extends FrameLayout implements
view.layout(view.getLeft(), view.getTop(), view.getMeasuredWidth(), view.getMeasuredHeight());
}
private void refreshProgressBarVisibility (){
if(playerControlView == null) return;
DefaultTimeBar exoProgress;
TextView exoDuration;
TextView exoPosition;
exoProgress = playerControlView.findViewById(R.id.exo_progress);
exoDuration = playerControlView.findViewById(R.id.exo_duration);
exoPosition = playerControlView.findViewById(R.id.exo_position);
if(controlsConfig.getHideSeekBar()){
LinearLayout.LayoutParams param = new LinearLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT,
1.0f
);
exoProgress.setVisibility(GONE);
exoDuration.setVisibility(GONE);
exoPosition.setLayoutParams(param);
}else{
exoProgress.setVisibility(VISIBLE);
exoDuration.setVisibility(VISIBLE);
// Reset the layout parameters of exoPosition to their default state
LinearLayout.LayoutParams defaultParam = new LinearLayout.LayoutParams(
LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT
);
exoPosition.setLayoutParams(defaultParam);
private void refreshControlsStyles() {
if (playerControlView == null || player == null || !controls) return;
updateLiveContent();
updatePlayPauseButtons();
updateButtonVisibility(controlsConfig.getHideForward(), R.id.exo_ffwd);
updateButtonVisibility(controlsConfig.getHideRewind(), R.id.exo_rew);
updateButtonVisibility(controlsConfig.getHideNext(), R.id.exo_next);
updateButtonVisibility(controlsConfig.getHidePrevious(), R.id.exo_prev);
updateViewVisibility(playerControlView.findViewById(R.id.exo_fullscreen), controlsConfig.getHideFullscreen(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_position), controlsConfig.getHidePosition(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_progress), controlsConfig.getHideSeekBar(), INVISIBLE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_duration), controlsConfig.getHideDuration(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_settings), controlsConfig.getHideSettingButton(), GONE );
}
private void updateLiveContent() {
LinearLayout exoLiveContainer = playerControlView.findViewById(R.id.exo_live_container);
TextView exoLiveLabel = playerControlView.findViewById(R.id.exo_live_label);
boolean isLive = false;
Timeline timeline = player.getCurrentTimeline();
// Determine if the content is live
if (!timeline.isEmpty()) {
Timeline.Window window = new Timeline.Window();
timeline.getWindow(player.getCurrentMediaItemIndex(), window);
isLive = window.isLive();
}
if (isLive && controlsConfig.getLiveLabel() != null) {
exoLiveLabel.setText(controlsConfig.getLiveLabel());
exoLiveContainer.setVisibility(VISIBLE);
} else {
exoLiveContainer.setVisibility(GONE);
}
}
private void updatePlayPauseButtons() {
final ImageButton playButton = playerControlView.findViewById(R.id.exo_play);
final ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause);
if (controlsConfig.getHidePlayPause()) {
playPauseControlContainer.setAlpha(0);
playButton.setClickable(false);
pauseButton.setClickable(false);
} else {
playPauseControlContainer.setAlpha(1.0f);
playButton.setClickable(true);
pauseButton.setClickable(true);
}
}
private void updateButtonVisibility(boolean hide, int buttonID) {
ImageButton button = playerControlView.findViewById(buttonID);
if (hide) {
button.setImageAlpha(0);
button.setClickable(false);
} else {
button.setImageAlpha(255);
button.setClickable(true);
}
}
private void updateViewVisibility(View view, boolean hide, int hideVisibility) {
if (hide) {
view.setVisibility(hideVisibility);
} else if (view.getVisibility() == hideVisibility) {
view.setVisibility(VISIBLE);
}
}
private void reLayoutControls() {
reLayout(exoPlayerView);
reLayout(playerControlView);
}
/// returns true is adaptive bitrate shall be used
public boolean isUsingVideoABR() {
return videoTrackType == null || "auto".equals(videoTrackType);
}
public void setDebug(boolean enableDebug) {
this.enableDebug = enableDebug;
refreshDebugState();
@ -659,22 +736,28 @@ public class ReactExoplayerView extends FrameLayout implements
ReactExoplayerView self = this;
Activity activity = themedReactContext.getCurrentActivity();
// This ensures all props have been settled, to avoid async racing conditions.
Source runningSource = source;
mainRunnable = () -> {
if (viewHasDropped) {
if (viewHasDropped && runningSource == source) {
return;
}
try {
if (runningSource.getUri() == null) {
return;
}
if (player == null) {
// Initialize core configuration and listeners
initializePlayerCore(self);
}
if (playerNeedsSource && source.getUri() != null) {
if (playerNeedsSource) {
// Will force display of shutter view if needed
exoPlayerView.updateShutterViewVisibility();
exoPlayerView.invalidateAspectRatio();
// DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> {
// DRM initialization must run on a different thread
if (viewHasDropped) {
if (viewHasDropped && runningSource == source) {
return;
}
if (activity == null) {
@ -685,12 +768,12 @@ public class ReactExoplayerView extends FrameLayout implements
// Initialize handler to run on the main thread
activity.runOnUiThread(() -> {
if (viewHasDropped) {
if (viewHasDropped && runningSource == source) {
return;
}
try {
// Source initialization must run on the main thread
initializePlayerSource();
initializePlayerSource(runningSource);
} catch (Exception ex) {
self.playerNeedsSource = true;
DebugLog.e(TAG, "Failed to initialize Player! 1");
@ -700,8 +783,8 @@ public class ReactExoplayerView extends FrameLayout implements
}
});
});
} else if (source.getUri() != null) {
initializePlayerSource();
} else if (runningSource == source) {
initializePlayerSource(runningSource);
}
} catch (Exception ex) {
self.playerNeedsSource = true;
@ -716,7 +799,7 @@ public class ReactExoplayerView extends FrameLayout implements
public void getCurrentPosition(Promise promise) {
if (player != null) {
double currentPosition = player.getCurrentPosition() / 1000;
float currentPosition = player.getCurrentPosition() / 1000.0f;
promise.resolve(currentPosition);
} else {
promise.reject("PLAYER_NOT_AVAILABLE", "Player is not initialized.");
@ -740,21 +823,30 @@ public class ReactExoplayerView extends FrameLayout implements
.setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing();
// Create an AdsLoader.
adsLoader = new ImaAdsLoader
.Builder(themedReactContext)
.setAdEventListener(this)
.setAdErrorListener(this)
.build();
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory);
if (useCache) {
mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
}
if (adsLoader != null) {
mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView);
if (BuildConfig.USE_EXOPLAYER_IMA) {
AdsProps adProps = source.getAdsProps();
// Create an AdsLoader.
ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader
.Builder(themedReactContext)
.setAdEventListener(this)
.setAdErrorListener(this);
if (adProps != null && adProps.getAdLanguage() != null) {
ImaSdkSettings imaSdkSettings = ImaSdkFactory.getInstance().createImaSdkSettings();
imaSdkSettings.setLanguage(adProps.getAdLanguage());
imaLoaderBuilder.setImaSdkSettings(imaSdkSettings);
}
adsLoader = imaLoaderBuilder.build();
}
mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView);
player = new ExoPlayer.Builder(getContext(), renderersFactory)
.setTrackSelector(self.trackSelector)
.setBandwidthMeter(bandwidthMeter)
@ -766,6 +858,7 @@ public class ReactExoplayerView extends FrameLayout implements
player.addListener(self);
player.setVolume(muted ? 0.f : audioVolume * 1);
exoPlayerView.setPlayer(player);
if (adsLoader != null) {
adsLoader.setPlayer(player);
}
@ -804,49 +897,45 @@ public class ReactExoplayerView extends FrameLayout implements
return drmSessionManager;
}
private void initializePlayerSource() {
if (source.getUri() == null) {
private void initializePlayerSource(Source runningSource) {
if (runningSource.getUri() == null) {
return;
}
/// init DRM
DrmSessionManager drmSessionManager = initializePlayerDrm();
if (drmSessionManager == null && source.getDrmProps() != null && source.getDrmProps().getDrmType() != null) {
if (drmSessionManager == null && runningSource.getDrmProps() != null && runningSource.getDrmProps().getDrmType() != null) {
// Failed to initialize DRM session manager - cannot continue
DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!");
return;
}
// init source to manage ads and external text tracks
ArrayList<MediaSource> mediaSourceList = buildTextSources();
MediaSource videoSource = buildMediaSource(source.getUri(), source.getExtension(), drmSessionManager, source.getCropStartMs(), source.getCropEndMs());
MediaSource subtitlesSource = buildTextSource();
MediaSource videoSource = buildMediaSource(runningSource.getUri(), runningSource.getExtension(), drmSessionManager, runningSource.getCropStartMs(), runningSource.getCropEndMs());
MediaSource mediaSourceWithAds = null;
Uri adTagUrl = null;
if (source.getAdsProps() != null) {
adTagUrl = source.getAdsProps().getAdTagUrl();
}
if (adTagUrl != null && adsLoader != null) {
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory)
.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView);
DataSpec adTagDataSpec = new DataSpec(adTagUrl);
mediaSourceWithAds = new AdsMediaSource(videoSource, adTagDataSpec, ImmutableList.of(source.getUri(), adTagUrl), mediaSourceFactory, adsLoader, exoPlayerView);
} else {
if (adTagUrl == null && adsLoader != null) {
adsLoader.release();
adsLoader = null;
}
DebugLog.w(TAG, "ads " + adTagUrl);
mediaSourceWithAds = new AdsMediaSource(videoSource, adTagDataSpec, ImmutableList.of(runningSource.getUri(), adTagUrl), mediaSourceFactory, adsLoader, exoPlayerView);
exoPlayerView.showAds();
}
MediaSource mediaSource;
if (mediaSourceList.isEmpty()) {
if (mediaSourceWithAds != null) {
mediaSource = mediaSourceWithAds;
} else {
mediaSource = videoSource;
}
if (subtitlesSource == null) {
mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
} else {
if (mediaSourceWithAds != null) {
mediaSourceList.add(0, mediaSourceWithAds);
} else {
mediaSourceList.add(0, videoSource);
}
MediaSource[] textSourceArray = mediaSourceList.toArray(
ArrayList<MediaSource> mediaSourceList = new ArrayList<>();
mediaSourceList.add(subtitlesSource);
mediaSourceList.add(0, Objects.requireNonNullElse(mediaSourceWithAds, videoSource));
MediaSource[] mediaSourceArray = mediaSourceList.toArray(
new MediaSource[mediaSourceList.size()]
);
mediaSource = new MergingMediaSource(textSourceArray);
mediaSource = new MergingMediaSource(mediaSourceArray);
}
// wait for player to be set
@ -863,8 +952,8 @@ public class ReactExoplayerView extends FrameLayout implements
if (haveResumePosition) {
player.seekTo(resumeWindow, resumePosition);
player.setMediaSource(mediaSource, false);
} else if (source.getStartPositionMs() > 0) {
player.setMediaSource(mediaSource, source.getStartPositionMs());
} else if (runningSource.getStartPositionMs() > 0) {
player.setMediaSource(mediaSource, runningSource.getStartPositionMs());
} else {
player.setMediaSource(mediaSource, true);
}
@ -897,17 +986,25 @@ public class ReactExoplayerView extends FrameLayout implements
playbackServiceBinder = (PlaybackServiceBinder) service;
try {
playbackServiceBinder.getService().registerPlayer(player,
Objects.requireNonNull((Class<Activity>) (themedReactContext.getCurrentActivity()).getClass()));
Activity currentActivity = themedReactContext.getCurrentActivity();
if (currentActivity != null) {
playbackServiceBinder.getService().registerPlayer(player,
(Class<Activity>) currentActivity.getClass());
} else {
// Handle the case where currentActivity is null
DebugLog.w(TAG, "Could not register ExoPlayer: currentActivity is null");
}
} catch (Exception e) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
DebugLog.e(TAG, "Could not register ExoPlayer: " + e.getMessage());
}
}
@Override
public void onServiceDisconnected(ComponentName name) {
try {
playbackServiceBinder.getService().unregisterPlayer(player);
if (playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
}
} catch (Exception ignored) {}
playbackServiceBinder = null;
@ -915,14 +1012,18 @@ public class ReactExoplayerView extends FrameLayout implements
@Override
public void onNullBinding(ComponentName name) {
DebugLog.e(TAG, "Cloud not register ExoPlayer");
DebugLog.e(TAG, "Could not register ExoPlayer");
}
};
Intent intent = new Intent(themedReactContext, VideoPlaybackService.class);
intent.setAction(MediaSessionService.SERVICE_INTERFACE);
themedReactContext.startService(intent);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
themedReactContext.startForegroundService(intent);
} else {
themedReactContext.startService(intent);
}
int flags;
if (Build.VERSION.SDK_INT >= 29) {
@ -1007,15 +1108,17 @@ public class ReactExoplayerView extends FrameLayout implements
.setUri(uri);
// refresh custom Metadata
customMetadata = ConfigurationUtils.buildCustomMetadata(source.getMetadata());
MediaMetadata customMetadata = ConfigurationUtils.buildCustomMetadata(source.getMetadata());
if (customMetadata != null) {
mediaItemBuilder.setMediaMetadata(customMetadata);
}
if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration(
new MediaItem.AdsConfiguration.Builder(adTagUrl).build()
);
if (source.getAdsProps() != null) {
Uri adTagUrl = source.getAdsProps().getAdTagUrl();
if (adTagUrl != null) {
mediaItemBuilder.setAdsConfiguration(
new MediaItem.AdsConfiguration.Builder(adTagUrl).build()
);
}
}
MediaItem.LiveConfiguration.Builder liveConfiguration = ConfigurationUtils.getLiveConfiguration(bufferConfig);
@ -1096,6 +1199,12 @@ public class ReactExoplayerView extends FrameLayout implements
}
}
if (cmcdConfigurationFactory != null) {
mediaSourceFactory = mediaSourceFactory.setCmcdConfigurationFactory(
cmcdConfigurationFactory::createCmcdConfiguration
);
}
MediaItem mediaItem = mediaItemBuilder.setStreamKeys(streamKeys).build();
MediaSource mediaSource = mediaSourceFactory
.setDrmSessionManagerProvider(drmProvider)
@ -1115,40 +1224,34 @@ public class ReactExoplayerView extends FrameLayout implements
return mediaSource;
}
private ArrayList<MediaSource> buildTextSources() {
ArrayList<MediaSource> textSources = new ArrayList<>();
if (textTracks == null) {
return textSources;
@Nullable
private MediaSource buildTextSource() {
if (source.getSideLoadedTextTracks() == null) {
return null;
}
for (SideLoadedTextTrack track : textTracks.getTracks()) {
MediaSource textSource = buildTextSource(track.getTitle(),
track.getUri(),
track.getType(),
track.getLanguage());
textSources.add(textSource);
}
return textSources;
}
List<MediaItem.SubtitleConfiguration> subtitleConfigurations = new ArrayList<>();
private MediaSource buildTextSource(String title, Uri uri, String mimeType, String language) {
MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(uri)
.setMimeType(mimeType)
.setLanguage(language)
for (SideLoadedTextTrack track : source.getSideLoadedTextTracks().getTracks()) {
MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(track.getUri())
.setMimeType(track.getType())
.setLanguage(track.getLanguage())
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.setLabel(title)
.setLabel(track.getTitle())
.build();
return new SingleSampleMediaSource.Factory(mediaDataSourceFactory)
.createMediaSource(subtitleConfiguration, C.TIME_UNSET);
subtitleConfigurations.add(subtitleConfiguration);
}
MediaItem subtitlesMediaItem = new MediaItem.Builder()
.setUri(source.getUri())
.setSubtitleConfigurations(subtitleConfigurations).build();
return new DefaultMediaSourceFactory(mediaDataSourceFactory).createMediaSource(subtitlesMediaItem);
}
private void releasePlayer() {
if (player != null) {
if (adsLoader != null) {
adsLoader.setPlayer(null);
}
if(playbackServiceBinder != null) {
playbackServiceBinder.getService().unregisterPlayer(player);
themedReactContext.unbindService(playbackServiceConnection);
@ -1165,8 +1268,8 @@ public class ReactExoplayerView extends FrameLayout implements
if (adsLoader != null) {
adsLoader.release();
adsLoader = null;
}
adsLoader = null;
progressHandler.removeMessages(SHOW_PROGRESS);
audioBecomingNoisyReceiver.removeListener();
bandwidthMeter.removeEventListener(this);
@ -1252,10 +1355,7 @@ public class ReactExoplayerView extends FrameLayout implements
player.setPlayWhenReady(true);
}
} else {
// ensure playback is not ENDED, else it will trigger another ended event
if (player.getPlaybackState() != Player.STATE_ENDED) {
player.setPlayWhenReady(false);
}
player.setPlayWhenReady(false);
}
}
@ -1415,7 +1515,7 @@ public class ReactExoplayerView extends FrameLayout implements
boolean isRotatedContent = videoFormat != null && (videoFormat.rotationDegrees == 90 || videoFormat.rotationDegrees == 270);
int width = videoFormat != null ? (isRotatedContent ? videoFormat.height : videoFormat.width) : 0;
int height = videoFormat != null ? (isRotatedContent ? videoFormat.width : videoFormat.height) : 0;
String trackId = videoFormat != null ? videoFormat.id : "-1";
String trackId = videoFormat != null ? videoFormat.id : null;
// Properties that must be accessed on the main thread
long duration = player.getDuration();
@ -1423,7 +1523,7 @@ public class ReactExoplayerView extends FrameLayout implements
ArrayList<Track> audioTracks = getAudioTrackInfo();
ArrayList<Track> textTracks = getTextTrackInfo();
if (this.contentStartTime != -1L) {
if (source.getContentStartTime() != -1) {
ExecutorService es = Executors.newSingleThreadExecutor();
es.execute(() -> {
// To prevent ANRs caused by getVideoTrackInfo we run this on a different thread and notify the player only when we're done
@ -1442,6 +1542,7 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
audioTracks, textTracks, videoTracks, trackId);
refreshControlsStyles();
}
}
@ -1526,7 +1627,7 @@ public class ReactExoplayerView extends FrameLayout implements
ExecutorService es = Executors.newSingleThreadExecutor();
final DataSource dataSource = this.mediaDataSourceFactory.createDataSource();
final Uri sourceUri = source.getUri();
final long startTime = this.contentStartTime * 1000 - 100; // s -> ms with 100ms offset
final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset
Future<ArrayList<VideoTrack>> result = es.submit(new Callable() {
final DataSource ds = dataSource;
@ -1590,7 +1691,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
if (format.language != null) track.setLanguage(format.language);
if (format.label != null) track.setTitle(format.label);
track.setSelected(isTrackSelected(selection, group, 0));
track.setSelected(isTrackSelected(selection, group, trackIndex));
return track;
}
@ -1724,7 +1825,10 @@ public class ReactExoplayerView extends FrameLayout implements
playerNeedsSource = true;
if (isBehindLiveWindow(e)) {
clearResumePosition();
initializePlayer();
if (player != null) {
player.seekToDefaultPosition();
player.prepare();
}
} else {
updateResumePosition();
}
@ -1793,22 +1897,32 @@ public class ReactExoplayerView extends FrameLayout implements
DataSourceUtil.getDefaultDataSourceFactory(this.themedReactContext, bandwidthMeter,
source.getHeaders());
if (source.getCmcdProps() != null) {
CMCDConfig cmcdConfig = new CMCDConfig(source.getCmcdProps());
CmcdConfiguration.Factory factory = cmcdConfig.toCmcdConfigurationFactory();
this.setCmcdConfigurationFactory(factory);
} else {
this.setCmcdConfigurationFactory(null);
}
if (!isSourceEqual) {
reloadSource();
}
} else {
clearSrc();
}
}
public void clearSrc() {
if (source.getUri() != null) {
if (player != null) {
player.stop();
player.clearMediaItems();
}
this.source = new Source();
this.mediaDataSourceFactory = null;
clearResumePosition();
}
exoPlayerView.hideAds();
this.source = new Source();
this.mediaDataSourceFactory = null;
clearResumePosition();
}
public void setProgressUpdateInterval(final float progressUpdateInterval) {
@ -1819,15 +1933,6 @@ public class ReactExoplayerView extends FrameLayout implements
mReportBandwidth = reportBandwidth;
}
public void setAdTagUrl(final Uri uri) {
adTagUrl = uri;
}
public void setTextTracks(SideLoadedTextTrackList textTracks) {
this.textTracks = textTracks;
reloadSource(); // FIXME Shall be moved inside source
}
private void reloadSource() {
playerNeedsSource = true;
initializePlayer();
@ -1901,70 +2006,73 @@ public class ReactExoplayerView extends FrameLayout implements
} else if ("title".equals(type)) {
for (int i = 0; i < groups.length; ++i) {
Format format = groups.get(i).getFormat(0);
if (format.id != null && format.id.equals(value)) {
if (format.label != null && format.label.equals(value)) {
groupIndex = i;
break;
}
}
} else if ("index".equals(type)) {
int iValue = Integer.parseInt(value);
if (trackType == C.TRACK_TYPE_VIDEO && groups.length == 1) {
groupIndex = 0;
if (iValue < groups.get(groupIndex).length) {
tracks.set(0, iValue);
int iValue = ReactBridgeUtils.safeParseInt(value, -1);
if (iValue != -1) {
if (trackType == C.TRACK_TYPE_VIDEO && groups.length == 1) {
groupIndex = 0;
if (iValue < groups.get(groupIndex).length) {
tracks.set(0, iValue);
}
} else if (iValue < groups.length) {
groupIndex = iValue;
}
} else if (iValue < groups.length) {
groupIndex = iValue;
}
} else if ("resolution".equals(type)) {
int height = Integer.parseInt(value);
for (int i = 0; i < groups.length; ++i) { // Search for the exact height
TrackGroup group = groups.get(i);
Format closestFormat = null;
int closestTrackIndex = -1;
boolean usingExactMatch = false;
for (int j = 0; j < group.length; j++) {
Format format = group.getFormat(j);
if (format.height == height) {
groupIndex = i;
tracks.set(0, j);
closestFormat = null;
closestTrackIndex = -1;
usingExactMatch = true;
break;
} else if (isUsingContentResolution) {
// When using content resolution rather than ads, we need to try and find the closest match if there is no exact match
if (closestFormat != null) {
if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) {
// Higher quality match
int height = ReactBridgeUtils.safeParseInt(value, -1);
if (height != -1) {
for (int i = 0; i < groups.length; ++i) { // Search for the exact height
TrackGroup group = groups.get(i);
Format closestFormat = null;
int closestTrackIndex = -1;
boolean usingExactMatch = false;
for (int j = 0; j < group.length; j++) {
Format format = group.getFormat(j);
if (format.height == height) {
groupIndex = i;
tracks.set(0, j);
closestFormat = null;
closestTrackIndex = -1;
usingExactMatch = true;
break;
} else if (isUsingContentResolution) {
// When using content resolution rather than ads, we need to try and find the closest match if there is no exact match
if (closestFormat != null) {
if ((format.bitrate > closestFormat.bitrate || format.height > closestFormat.height) && format.height < height) {
// Higher quality match
closestFormat = format;
closestTrackIndex = j;
}
} else if (format.height < height) {
closestFormat = format;
closestTrackIndex = j;
}
} else if(format.height < height) {
closestFormat = format;
closestTrackIndex = j;
}
}
}
// This is a fallback if the new period contains only higher resolutions than the user has selected
if (closestFormat == null && isUsingContentResolution && !usingExactMatch) {
// No close match found - so we pick the lowest quality
int minHeight = Integer.MAX_VALUE;
for (int j = 0; j < group.length; j++) {
Format format = group.getFormat(j);
if (format.height < minHeight) {
minHeight = format.height;
groupIndex = i;
tracks.set(0, j);
// This is a fallback if the new period contains only higher resolutions than the user has selected
if (closestFormat == null && isUsingContentResolution && !usingExactMatch) {
// No close match found - so we pick the lowest quality
int minHeight = Integer.MAX_VALUE;
for (int j = 0; j < group.length; j++) {
Format format = group.getFormat(j);
if (format.height < minHeight) {
minHeight = format.height;
groupIndex = i;
tracks.set(0, j);
}
}
}
}
// Selecting the closest match found
if (closestFormat != null && closestTrackIndex != -1) {
// We found the closest match instead of an exact one
groupIndex = i;
tracks.set(0, closestTrackIndex);
// Selecting the closest match found
if (closestFormat != null && closestTrackIndex != -1) {
// We found the closest match instead of an exact one
groupIndex = i;
tracks.set(0, closestTrackIndex);
}
}
}
} else if (trackType == C.TRACK_TYPE_TEXT && Util.SDK_INT > 18) { // Text default
@ -2016,16 +2124,21 @@ public class ReactExoplayerView extends FrameLayout implements
TrackSelectionOverride selectionOverride = new TrackSelectionOverride(groups.get(groupIndex), tracks);
DefaultTrackSelector.Parameters selectionParameters = trackSelector.getParameters()
DefaultTrackSelector.Parameters.Builder selectionParameters = trackSelector.getParameters()
.buildUpon()
.setExceedAudioConstraintsIfNecessary(true)
.setExceedRendererCapabilitiesIfNecessary(true)
.setExceedVideoConstraintsIfNecessary(true)
.setRendererDisabled(rendererIndex, false)
.clearOverridesOfType(selectionOverride.getType())
.addOverride(selectionOverride)
.build();
trackSelector.setParameters(selectionParameters);
.clearOverridesOfType(selectionOverride.getType());
if (trackType == C.TRACK_TYPE_VIDEO && isUsingVideoABR()) {
selectionParameters.setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate);
} else {
selectionParameters.addOverride(selectionOverride);
}
trackSelector.setParameters(selectionParameters.build());
}
private boolean isFormatSupported(Format format) {
@ -2156,7 +2269,8 @@ public class ReactExoplayerView extends FrameLayout implements
public void setMaxBitRateModifier(int newMaxBitRate) {
maxBitRate = newMaxBitRate;
if (player != null) {
if (player != null && isUsingVideoABR()) {
// do not apply yet if not auto
trackSelector.setParameters(trackSelector.buildUponParameters()
.setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate));
}
@ -2181,10 +2295,6 @@ public class ReactExoplayerView extends FrameLayout implements
exoPlayerView.setFocusable(this.focusable);
}
public void setContentStartTime(int contentStartTime) {
this.contentStartTime = contentStartTime;
}
public void setShowNotificationControls(boolean showNotificationControls) {
this.showNotificationControls = showNotificationControls;
@ -2230,17 +2340,18 @@ public class ReactExoplayerView extends FrameLayout implements
return;
}
Window window = activity.getWindow();
WindowInsetsControllerCompat controller = new WindowInsetsControllerCompat(window, window.getDecorView());
if (isFullscreen) {
fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, playerControlView, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
setFullscreen(false);
}
}, controlsConfig);
eventEmitter.onVideoFullscreenPlayerWillPresent.invoke();
if (fullScreenPlayerView != null) {
fullScreenPlayerView.show();
}
UiThreadUtil.runOnUiThread(() -> {
WindowCompat.setDecorFitsSystemWindows(window, false);
controller.hide(WindowInsetsCompat.Type.systemBars());
controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
eventEmitter.onVideoFullscreenPlayerDidPresent.invoke();
});
} else {
@ -2251,8 +2362,6 @@ public class ReactExoplayerView extends FrameLayout implements
setControls(controls);
}
UiThreadUtil.runOnUiThread(() -> {
WindowCompat.setDecorFitsSystemWindows(window, true);
controller.show(WindowInsetsCompat.Type.systemBars());
eventEmitter.onVideoFullscreenPlayerDidDismiss.invoke();
});
}
@ -2326,6 +2435,7 @@ public class ReactExoplayerView extends FrameLayout implements
removeViewAt(indexOfPC);
}
}
refreshControlsStyles();
}
public void setSubtitleStyle(SubtitleStyle style) {
@ -2358,6 +2468,6 @@ public class ReactExoplayerView extends FrameLayout implements
public void setControlsStyles(ControlsConfig controlsStyles) {
controlsConfig = controlsStyles;
refreshProgressBarVisibility();
refreshControlsStyles();
}
}

View File

@ -1,346 +0,0 @@
package com.brentvatne.exoplayer;
import android.content.Context;
import android.graphics.Color;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import com.brentvatne.common.api.BufferConfig;
import com.brentvatne.common.api.BufferingStrategy;
import com.brentvatne.common.api.ControlsConfig;
import com.brentvatne.common.api.DRMProps;
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;
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;
import com.facebook.react.uimanager.annotations.ReactProp;
import java.util.Map;
import javax.annotation.Nullable;
public class ReactExoplayerViewManager extends ViewGroupManager<ReactExoplayerView> {
private static final String TAG = "ExoViewManager";
private static final String REACT_CLASS = "RCTVideo";
private static final String PROP_SRC = "src";
private static final String PROP_AD_TAG_URL = "adTagUrl";
private static final String PROP_RESIZE_MODE = "resizeMode";
private static final String PROP_REPEAT = "repeat";
private static final String PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack";
private static final String PROP_SELECTED_AUDIO_TRACK_TYPE = "type";
private static final String PROP_SELECTED_AUDIO_TRACK_VALUE = "value";
private static final String PROP_SELECTED_TEXT_TRACK = "selectedTextTrack";
private static final String PROP_SELECTED_TEXT_TRACK_TYPE = "type";
private static final String PROP_SELECTED_TEXT_TRACK_VALUE = "value";
private static final String PROP_TEXT_TRACKS = "textTracks";
private static final String PROP_PAUSED = "paused";
private static final String PROP_MUTED = "muted";
private static final String PROP_AUDIO_OUTPUT = "audioOutput";
private static final String PROP_VOLUME = "volume";
private static final String PROP_BUFFER_CONFIG = "bufferConfig";
private static final String PROP_PREVENTS_DISPLAY_SLEEP_DURING_VIDEO_PLAYBACK = "preventsDisplaySleepDuringVideoPlayback";
private static final String PROP_PROGRESS_UPDATE_INTERVAL = "progressUpdateInterval";
private static final String PROP_REPORT_BANDWIDTH = "reportBandwidth";
private static final String PROP_RATE = "rate";
private static final String PROP_MIN_LOAD_RETRY_COUNT = "minLoadRetryCount";
private static final String PROP_MAXIMUM_BIT_RATE = "maxBitRate";
private static final String PROP_PLAY_IN_BACKGROUND = "playInBackground";
private static final String PROP_CONTENT_START_TIME = "contentStartTime";
private static final String PROP_DISABLE_FOCUS = "disableFocus";
private static final String PROP_BUFFERING_STRATEGY = "bufferingStrategy";
private static final String PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError";
private static final String PROP_FOCUSABLE = "focusable";
private static final String PROP_FULLSCREEN = "fullscreen";
private static final String PROP_VIEW_TYPE = "viewType";
private static final String PROP_SELECTED_VIDEO_TRACK = "selectedVideoTrack";
private static final String PROP_SELECTED_VIDEO_TRACK_TYPE = "type";
private static final String PROP_SELECTED_VIDEO_TRACK_VALUE = "value";
private static final String PROP_HIDE_SHUTTER_VIEW = "hideShutterView";
private static final String PROP_CONTROLS = "controls";
private static final String PROP_SUBTITLE_STYLE = "subtitleStyle";
private static final String PROP_SHUTTER_COLOR = "shutterColor";
private static final String PROP_SHOW_NOTIFICATION_CONTROLS = "showNotificationControls";
private static final String PROP_DEBUG = "debug";
private static final String PROP_CONTROLS_STYLES = "controlsStyles";
private final ReactExoplayerConfig config;
public ReactExoplayerViewManager(ReactExoplayerConfig config) {
this.config = config;
}
@NonNull
@Override
public String getName() {
return REACT_CLASS;
}
@NonNull
@Override
protected ReactExoplayerView createViewInstance(@NonNull ThemedReactContext themedReactContext) {
ReactNativeVideoManager.Companion.getInstance().registerView(this);
return new ReactExoplayerView(themedReactContext, config);
}
@Override
public void onDropViewInstance(ReactExoplayerView view) {
view.cleanUpResources();
ReactNativeVideoManager.Companion.getInstance().unregisterView(this);
}
@Override
public @Nullable Map<String, Object> getExportedCustomDirectEventTypeConstants() {
return EventTypes.Companion.toMap();
}
@Override
public void addEventEmitters(@NonNull ThemedReactContext reactContext, @NonNull ReactExoplayerView view) {
super.addEventEmitters(reactContext, view);
view.eventEmitter.addEventEmitters(reactContext, view);
}
@ReactProp(name = PROP_SRC)
public void setSrc(final ReactExoplayerView videoView, @Nullable ReadableMap src) {
Context context = videoView.getContext().getApplicationContext();
Source source = Source.parse(src, context);
if (source.getUri() == null) {
videoView.clearSrc();
} else {
videoView.setSrc(source);
}
}
@ReactProp(name = PROP_AD_TAG_URL)
public void setAdTagUrl(final ReactExoplayerView videoView, final String uriString) {
if (TextUtils.isEmpty(uriString)) {
videoView.setAdTagUrl(null);
return;
}
Uri adTagUrl = Uri.parse(uriString);
videoView.setAdTagUrl(adTagUrl);
}
@ReactProp(name = PROP_RESIZE_MODE)
public void setResizeMode(final ReactExoplayerView videoView, final String resizeMode) {
switch (resizeMode) {
case "none":
case "contain":
videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FIT);
break;
case "cover":
videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_CENTER_CROP);
break;
case "stretch":
videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FILL);
break;
default:
DebugLog.w(TAG, "Unsupported resize mode: " + resizeMode + " - falling back to fit");
videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FIT);
break;
}
}
@ReactProp(name = PROP_REPEAT, defaultBoolean = false)
public void setRepeat(final ReactExoplayerView videoView, final boolean repeat) {
videoView.setRepeatModifier(repeat);
}
@ReactProp(name = PROP_PREVENTS_DISPLAY_SLEEP_DURING_VIDEO_PLAYBACK, defaultBoolean = false)
public void setPreventsDisplaySleepDuringVideoPlayback(final ReactExoplayerView videoView, final boolean preventsSleep) {
videoView.setPreventsDisplaySleepDuringVideoPlayback(preventsSleep);
}
@ReactProp(name = PROP_SELECTED_VIDEO_TRACK)
public void setSelectedVideoTrack(final ReactExoplayerView videoView,
@Nullable ReadableMap selectedVideoTrack) {
String typeString = null;
String value = null;
if (selectedVideoTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedVideoTrack, PROP_SELECTED_VIDEO_TRACK_TYPE);
value = ReactBridgeUtils.safeGetString(selectedVideoTrack, PROP_SELECTED_VIDEO_TRACK_VALUE);
}
videoView.setSelectedVideoTrack(typeString, value);
}
@ReactProp(name = PROP_SELECTED_AUDIO_TRACK)
public void setSelectedAudioTrack(final ReactExoplayerView videoView,
@Nullable ReadableMap selectedAudioTrack) {
String typeString = null;
String value = null;
if (selectedAudioTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedAudioTrack, PROP_SELECTED_AUDIO_TRACK_TYPE);
value = ReactBridgeUtils.safeGetString(selectedAudioTrack, PROP_SELECTED_AUDIO_TRACK_VALUE);
}
videoView.setSelectedAudioTrack(typeString, value);
}
@ReactProp(name = PROP_SELECTED_TEXT_TRACK)
public void setSelectedTextTrack(final ReactExoplayerView videoView,
@Nullable ReadableMap selectedTextTrack) {
String typeString = null;
String value = null;
if (selectedTextTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedTextTrack, PROP_SELECTED_TEXT_TRACK_TYPE);
value = ReactBridgeUtils.safeGetString(selectedTextTrack, PROP_SELECTED_TEXT_TRACK_VALUE);
}
videoView.setSelectedTextTrack(typeString, value);
}
@ReactProp(name = PROP_TEXT_TRACKS)
public void setTextTracks(final ReactExoplayerView videoView,
@Nullable ReadableArray textTracks) {
SideLoadedTextTrackList sideLoadedTextTracks = SideLoadedTextTrackList.Companion.parse(textTracks);
videoView.setTextTracks(sideLoadedTextTracks);
}
@ReactProp(name = PROP_PAUSED, defaultBoolean = false)
public void setPaused(final ReactExoplayerView videoView, final boolean paused) {
videoView.setPausedModifier(paused);
}
@ReactProp(name = PROP_MUTED, defaultBoolean = false)
public void setMuted(final ReactExoplayerView videoView, final boolean muted) {
videoView.setMutedModifier(muted);
}
@ReactProp(name = PROP_AUDIO_OUTPUT)
public void setAudioOutput(final ReactExoplayerView videoView, final String audioOutput) {
videoView.setAudioOutput(AudioOutput.get(audioOutput));
}
@ReactProp(name = PROP_VOLUME, defaultFloat = 1.0f)
public void setVolume(final ReactExoplayerView videoView, final float volume) {
videoView.setVolumeModifier(volume);
}
@ReactProp(name = PROP_PROGRESS_UPDATE_INTERVAL, defaultFloat = 250.0f)
public void setProgressUpdateInterval(final ReactExoplayerView videoView, final float progressUpdateInterval) {
videoView.setProgressUpdateInterval(progressUpdateInterval);
}
@ReactProp(name = PROP_REPORT_BANDWIDTH, defaultBoolean = false)
public void setReportBandwidth(final ReactExoplayerView videoView, final boolean reportBandwidth) {
videoView.setReportBandwidth(reportBandwidth);
}
@ReactProp(name = PROP_RATE)
public void setRate(final ReactExoplayerView videoView, final float rate) {
videoView.setRateModifier(rate);
}
@ReactProp(name = PROP_MAXIMUM_BIT_RATE)
public void setMaxBitRate(final ReactExoplayerView videoView, final float maxBitRate) {
videoView.setMaxBitRateModifier((int)maxBitRate);
}
@ReactProp(name = PROP_MIN_LOAD_RETRY_COUNT)
public void setMinLoadRetryCount(final ReactExoplayerView videoView, final int minLoadRetryCount) {
videoView.setMinLoadRetryCountModifier(minLoadRetryCount);
}
@ReactProp(name = PROP_PLAY_IN_BACKGROUND, defaultBoolean = false)
public void setPlayInBackground(final ReactExoplayerView videoView, final boolean playInBackground) {
videoView.setPlayInBackground(playInBackground);
}
@ReactProp(name = PROP_DISABLE_FOCUS, defaultBoolean = false)
public void setDisableFocus(final ReactExoplayerView videoView, final boolean disableFocus) {
videoView.setDisableFocus(disableFocus);
}
@ReactProp(name = PROP_FOCUSABLE, defaultBoolean = true)
public void setFocusable(final ReactExoplayerView videoView, final boolean focusable) {
videoView.setFocusable(focusable);
}
@ReactProp(name = PROP_CONTENT_START_TIME, defaultInt = -1)
public void setContentStartTime(final ReactExoplayerView videoView, final int contentStartTime) {
videoView.setContentStartTime(contentStartTime);
}
@ReactProp(name = PROP_BUFFERING_STRATEGY)
public void setBufferingStrategy(final ReactExoplayerView videoView, final String bufferingStrategy) {
BufferingStrategy.BufferingStrategyEnum strategy = BufferingStrategy.Companion.parse(bufferingStrategy);
videoView.setBufferingStrategy(strategy);
}
@ReactProp(name = PROP_DISABLE_DISCONNECT_ERROR, defaultBoolean = false)
public void setDisableDisconnectError(final ReactExoplayerView videoView, final boolean disableDisconnectError) {
videoView.setDisableDisconnectError(disableDisconnectError);
}
@ReactProp(name = PROP_FULLSCREEN, defaultBoolean = false)
public void setFullscreen(final ReactExoplayerView videoView, final boolean fullscreen) {
videoView.setFullscreen(fullscreen);
}
@ReactProp(name = PROP_VIEW_TYPE, defaultInt = ViewType.VIEW_TYPE_SURFACE)
public void setViewType(final ReactExoplayerView videoView, final int viewType) {
videoView.setViewType(viewType);
}
@ReactProp(name = PROP_HIDE_SHUTTER_VIEW, defaultBoolean = false)
public void setHideShutterView(final ReactExoplayerView videoView, final boolean hideShutterView) {
videoView.setHideShutterView(hideShutterView);
}
@ReactProp(name = PROP_CONTROLS, defaultBoolean = false)
public void setControls(final ReactExoplayerView videoView, final boolean controls) {
videoView.setControls(controls);
}
@ReactProp(name = PROP_SUBTITLE_STYLE)
public void setSubtitleStyle(final ReactExoplayerView videoView, @Nullable final ReadableMap src) {
videoView.setSubtitleStyle(SubtitleStyle.parse(src));
}
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = 0)
public void setShutterColor(final ReactExoplayerView videoView, final int color) {
videoView.setShutterColor(color == 0 ? Color.BLACK : color);
}
@ReactProp(name = PROP_BUFFER_CONFIG)
public void setBufferConfig(final ReactExoplayerView videoView, @Nullable ReadableMap bufferConfig) {
BufferConfig config = BufferConfig.parse(bufferConfig);
videoView.setBufferConfig(config);
}
@ReactProp(name = PROP_SHOW_NOTIFICATION_CONTROLS)
public void setShowNotificationControls(final ReactExoplayerView videoView, final boolean showNotificationControls) {
videoView.setShowNotificationControls(showNotificationControls);
}
@ReactProp(name = PROP_DEBUG, defaultBoolean = false)
public void setDebug(final ReactExoplayerView videoView,
@Nullable final ReadableMap debugConfig) {
boolean enableDebug = ReactBridgeUtils.safeGetBool(debugConfig, "enable", false);
boolean enableThreadDebug = ReactBridgeUtils.safeGetBool(debugConfig, "thread", false);
if (enableDebug) {
DebugLog.setConfig(Log.VERBOSE, enableThreadDebug);
} else {
DebugLog.setConfig(Log.WARN, enableThreadDebug);
}
videoView.setDebug(enableDebug);
}
@ReactProp(name = PROP_CONTROLS_STYLES)
public void setControlsStyles(final ReactExoplayerView videoView, @Nullable ReadableMap controlsStyles) {
ControlsConfig controlsConfig = ControlsConfig.parse(controlsStyles);
videoView.setControlsStyles(controlsConfig);
}
}

View File

@ -0,0 +1,279 @@
package com.brentvatne.exoplayer
import android.graphics.Color
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.Source
import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType
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.ReadableMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.annotations.ReactProp
class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : ViewGroupManager<ReactExoplayerView>() {
companion object {
private const val TAG = "ExoViewManager"
private const val REACT_CLASS = "RCTVideo"
private const val PROP_SRC = "src"
private const val PROP_RESIZE_MODE = "resizeMode"
private const val PROP_REPEAT = "repeat"
private const val PROP_SELECTED_AUDIO_TRACK = "selectedAudioTrack"
private const val PROP_SELECTED_AUDIO_TRACK_TYPE = "type"
private const val PROP_SELECTED_AUDIO_TRACK_VALUE = "value"
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_PAUSED = "paused"
private const val PROP_MUTED = "muted"
private const val PROP_AUDIO_OUTPUT = "audioOutput"
private const val PROP_VOLUME = "volume"
private const val PROP_BUFFER_CONFIG = "bufferConfig"
private const val PROP_PREVENTS_DISPLAY_SLEEP_DURING_VIDEO_PLAYBACK =
"preventsDisplaySleepDuringVideoPlayback"
private const val PROP_PROGRESS_UPDATE_INTERVAL = "progressUpdateInterval"
private const val PROP_REPORT_BANDWIDTH = "reportBandwidth"
private const val PROP_RATE = "rate"
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_DISABLE_FOCUS = "disableFocus"
private const val PROP_BUFFERING_STRATEGY = "bufferingStrategy"
private const val PROP_DISABLE_DISCONNECT_ERROR = "disableDisconnectError"
private const val PROP_FOCUSABLE = "focusable"
private const val PROP_FULLSCREEN = "fullscreen"
private const val PROP_VIEW_TYPE = "viewType"
private const val PROP_SELECTED_VIDEO_TRACK = "selectedVideoTrack"
private const val PROP_SELECTED_VIDEO_TRACK_TYPE = "type"
private const val PROP_SELECTED_VIDEO_TRACK_VALUE = "value"
private const val PROP_HIDE_SHUTTER_VIEW = "hideShutterView"
private const val PROP_CONTROLS = "controls"
private const val PROP_SUBTITLE_STYLE = "subtitleStyle"
private const val PROP_SHUTTER_COLOR = "shutterColor"
private const val PROP_SHOW_NOTIFICATION_CONTROLS = "showNotificationControls"
private const val PROP_DEBUG = "debug"
private const val PROP_CONTROLS_STYLES = "controlsStyles"
}
override fun getName(): String = REACT_CLASS
override fun createViewInstance(themedReactContext: ThemedReactContext): ReactExoplayerView {
ReactNativeVideoManager.getInstance().registerView(this)
return ReactExoplayerView(themedReactContext, config)
}
override fun onDropViewInstance(view: ReactExoplayerView) {
view.cleanUpResources()
ReactNativeVideoManager.getInstance().unregisterView(this)
}
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> = EventTypes.toMap()
override fun addEventEmitters(reactContext: ThemedReactContext, view: ReactExoplayerView) {
super.addEventEmitters(reactContext, view)
view.eventEmitter.addEventEmitters(reactContext, view)
}
@ReactProp(name = PROP_SRC)
fun setSrc(videoView: ReactExoplayerView, src: ReadableMap?) {
val context = videoView.context.applicationContext
videoView.setSrc(Source.parse(src, context))
}
@ReactProp(name = PROP_RESIZE_MODE)
fun setResizeMode(videoView: ReactExoplayerView, resizeMode: String) {
when (resizeMode) {
"none", "contain" -> videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FIT)
"cover" -> videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_CENTER_CROP)
"stretch" -> videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FILL)
else -> {
DebugLog.w(TAG, "Unsupported resize mode: $resizeMode - falling back to fit")
videoView.setResizeModeModifier(ResizeMode.RESIZE_MODE_FIT)
}
}
}
@ReactProp(name = PROP_REPEAT, defaultBoolean = false)
fun setRepeat(videoView: ReactExoplayerView, repeat: Boolean) {
videoView.setRepeatModifier(repeat)
}
@ReactProp(name = PROP_PREVENTS_DISPLAY_SLEEP_DURING_VIDEO_PLAYBACK, defaultBoolean = false)
fun setPreventsDisplaySleepDuringVideoPlayback(videoView: ReactExoplayerView, preventsSleep: Boolean) {
videoView.preventsDisplaySleepDuringVideoPlayback = preventsSleep
}
@ReactProp(name = PROP_SELECTED_VIDEO_TRACK)
fun setSelectedVideoTrack(videoView: ReactExoplayerView, selectedVideoTrack: ReadableMap?) {
var typeString: String? = null
var value: String? = null
if (selectedVideoTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedVideoTrack, PROP_SELECTED_VIDEO_TRACK_TYPE)
value = ReactBridgeUtils.safeGetString(selectedVideoTrack, PROP_SELECTED_VIDEO_TRACK_VALUE)
}
videoView.setSelectedVideoTrack(typeString, value)
}
@ReactProp(name = PROP_SELECTED_AUDIO_TRACK)
fun setSelectedAudioTrack(videoView: ReactExoplayerView, selectedAudioTrack: ReadableMap?) {
var typeString: String? = null
var value: String? = null
if (selectedAudioTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedAudioTrack, PROP_SELECTED_AUDIO_TRACK_TYPE)
value = ReactBridgeUtils.safeGetString(selectedAudioTrack, PROP_SELECTED_AUDIO_TRACK_VALUE)
}
videoView.setSelectedAudioTrack(typeString, value)
}
@ReactProp(name = PROP_SELECTED_TEXT_TRACK)
fun setSelectedTextTrack(videoView: ReactExoplayerView, selectedTextTrack: ReadableMap?) {
var typeString: String? = null
var value: String? = null
if (selectedTextTrack != null) {
typeString = ReactBridgeUtils.safeGetString(selectedTextTrack, PROP_SELECTED_TEXT_TRACK_TYPE)
value = ReactBridgeUtils.safeGetString(selectedTextTrack, PROP_SELECTED_TEXT_TRACK_VALUE)
}
videoView.setSelectedTextTrack(typeString, value)
}
@ReactProp(name = PROP_PAUSED, defaultBoolean = false)
fun setPaused(videoView: ReactExoplayerView, paused: Boolean) {
videoView.setPausedModifier(paused)
}
@ReactProp(name = PROP_MUTED, defaultBoolean = false)
fun setMuted(videoView: ReactExoplayerView, muted: Boolean) {
videoView.setMutedModifier(muted)
}
@ReactProp(name = PROP_AUDIO_OUTPUT)
fun setAudioOutput(videoView: ReactExoplayerView, audioOutput: String) {
videoView.setAudioOutput(AudioOutput.get(audioOutput))
}
@ReactProp(name = PROP_VOLUME, defaultFloat = 1.0f)
fun setVolume(videoView: ReactExoplayerView, volume: Float) {
videoView.setVolumeModifier(volume)
}
@ReactProp(name = PROP_PROGRESS_UPDATE_INTERVAL, defaultFloat = 250.0f)
fun setProgressUpdateInterval(videoView: ReactExoplayerView, progressUpdateInterval: Float) {
videoView.setProgressUpdateInterval(progressUpdateInterval)
}
@ReactProp(name = PROP_REPORT_BANDWIDTH, defaultBoolean = false)
fun setReportBandwidth(videoView: ReactExoplayerView, reportBandwidth: Boolean) {
videoView.setReportBandwidth(reportBandwidth)
}
@ReactProp(name = PROP_RATE)
fun setRate(videoView: ReactExoplayerView, rate: Float) {
videoView.setRateModifier(rate)
}
@ReactProp(name = PROP_MAXIMUM_BIT_RATE)
fun setMaxBitRate(videoView: ReactExoplayerView, maxBitRate: Float) {
videoView.setMaxBitRateModifier(maxBitRate.toInt())
}
@ReactProp(name = PROP_MIN_LOAD_RETRY_COUNT)
fun setMinLoadRetryCount(videoView: ReactExoplayerView, minLoadRetryCount: Int) {
videoView.setMinLoadRetryCountModifier(minLoadRetryCount)
}
@ReactProp(name = PROP_PLAY_IN_BACKGROUND, defaultBoolean = false)
fun setPlayInBackground(videoView: ReactExoplayerView, playInBackground: Boolean) {
videoView.setPlayInBackground(playInBackground)
}
@ReactProp(name = PROP_DISABLE_FOCUS, defaultBoolean = false)
fun setDisableFocus(videoView: ReactExoplayerView, disableFocus: Boolean) {
videoView.setDisableFocus(disableFocus)
}
@ReactProp(name = PROP_FOCUSABLE, defaultBoolean = true)
fun setFocusable(videoView: ReactExoplayerView, focusable: Boolean) {
videoView.setFocusable(focusable)
}
@ReactProp(name = PROP_BUFFERING_STRATEGY)
fun setBufferingStrategy(videoView: ReactExoplayerView, bufferingStrategy: String) {
val strategy = BufferingStrategy.parse(bufferingStrategy)
videoView.setBufferingStrategy(strategy)
}
@ReactProp(name = PROP_DISABLE_DISCONNECT_ERROR, defaultBoolean = false)
fun setDisableDisconnectError(videoView: ReactExoplayerView, disableDisconnectError: Boolean) {
videoView.setDisableDisconnectError(disableDisconnectError)
}
@ReactProp(name = PROP_FULLSCREEN, defaultBoolean = false)
fun setFullscreen(videoView: ReactExoplayerView, fullscreen: Boolean) {
videoView.setFullscreen(fullscreen)
}
@ReactProp(name = PROP_VIEW_TYPE, defaultInt = ViewType.VIEW_TYPE_SURFACE)
fun setViewType(videoView: ReactExoplayerView, viewType: Int) {
videoView.setViewType(viewType)
}
@ReactProp(name = PROP_HIDE_SHUTTER_VIEW, defaultBoolean = false)
fun setHideShutterView(videoView: ReactExoplayerView, hideShutterView: Boolean) {
videoView.setHideShutterView(hideShutterView)
}
@ReactProp(name = PROP_CONTROLS, defaultBoolean = false)
fun setControls(videoView: ReactExoplayerView, controls: Boolean) {
videoView.setControls(controls)
}
@ReactProp(name = PROP_SUBTITLE_STYLE)
fun setSubtitleStyle(videoView: ReactExoplayerView, src: ReadableMap?) {
videoView.setSubtitleStyle(SubtitleStyle.parse(src))
}
@ReactProp(name = PROP_SHUTTER_COLOR, defaultInt = Color.BLACK)
fun setShutterColor(videoView: ReactExoplayerView, color: Int) {
videoView.setShutterColor(color)
}
@ReactProp(name = PROP_BUFFER_CONFIG)
fun setBufferConfig(videoView: ReactExoplayerView, bufferConfig: ReadableMap?) {
val config = BufferConfig.parse(bufferConfig)
videoView.setBufferConfig(config)
}
@ReactProp(name = PROP_SHOW_NOTIFICATION_CONTROLS)
fun setShowNotificationControls(videoView: ReactExoplayerView, showNotificationControls: Boolean) {
videoView.setShowNotificationControls(showNotificationControls)
}
@ReactProp(name = PROP_DEBUG, defaultBoolean = false)
fun setDebug(videoView: ReactExoplayerView, debugConfig: ReadableMap?) {
val enableDebug = ReactBridgeUtils.safeGetBool(debugConfig, "enable", false)
val enableThreadDebug = ReactBridgeUtils.safeGetBool(debugConfig, "thread", false)
if (enableDebug) {
DebugLog.setConfig(Log.VERBOSE, enableThreadDebug)
} else {
DebugLog.setConfig(Log.WARN, enableThreadDebug)
}
videoView.setDebug(enableDebug)
}
@ReactProp(name = PROP_CONTROLS_STYLES)
fun setControlsStyles(videoView: ReactExoplayerView, controlsStyles: ReadableMap?) {
val controlsConfig = ControlsConfig.parse(controlsStyles)
videoView.setControlsStyles(controlsConfig)
}
}

View File

@ -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() {

View File

@ -29,21 +29,19 @@ class ReactNativeVideoManager : RNVPlugin {
/**
* register a new ReactExoplayerViewManager in the managed list
*/
fun registerView(newInstance: ReactExoplayerViewManager): () -> Boolean =
{
if (instanceList.size > 2) {
DebugLog.d(TAG, "multiple Video displayed ?")
}
instanceList.add(newInstance)
fun registerView(newInstance: ReactExoplayerViewManager) {
if (instanceList.size > 2) {
DebugLog.d(TAG, "multiple Video displayed ?")
}
instanceList.add(newInstance)
}
/**
* unregister existing ReactExoplayerViewManager in the managed list
*/
fun unregisterView(newInstance: ReactExoplayerViewManager): () -> Boolean =
{
instanceList.remove(newInstance)
}
fun unregisterView(newInstance: ReactExoplayerViewManager) {
instanceList.remove(newInstance)
}
/**
* register a new plugin in the managed list

View File

@ -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) {

View File

@ -1,13 +0,0 @@
package com.brentvatne.receiver;
public interface BecomingNoisyListener {
BecomingNoisyListener NO_OP = new BecomingNoisyListener() {
@Override public void onAudioBecomingNoisy() {
// NO_OP
}
};
void onAudioBecomingNoisy();
}

View File

@ -0,0 +1,12 @@
package com.brentvatne.receiver
interface BecomingNoisyListener {
companion object {
val NO_OP = object : BecomingNoisyListener {
override fun onAudioBecomingNoisy() {
// NO_OP
}
}
}
fun onAudioBecomingNoisy()
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View 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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"
...
```

View File

@ -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

View File

@ -67,7 +67,7 @@ Example:
### `onBandwidthUpdate`
<PlatformsList types={['Android']} />
<PlatformsList types={['Android', 'iOS']} />
Callback function that is called when the available bandwidth changes.
@ -225,13 +225,14 @@ NOTE: tracks (`audioTracks`, `textTracks` & `videoTracks`) are not available on
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:
@ -263,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"
}
```

View File

@ -115,6 +115,16 @@ This function will change the volume exactly like [volume](./props#volume) prope
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.
### `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']} />
@ -148,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();

View File

@ -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.
@ -135,8 +138,7 @@ 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`
@ -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
@ -437,13 +457,26 @@ Determine whether the media should continue playing when notifications or the Co
### `poster`
<PlatformsList types={['All']} />
> [!WARNING]
> Value: string with a URL for the poster is deprecated, use `poster` as object instead
An image to display while the video is loading
Value: string with a URL for the poster, e.g. "https://baconmockup.com/300/200/"
Value: Props for the `Image` component. The poster is visible when the source attribute is provided.
```javascript
<Video>
poster={{
source: { uri: "https://baconmockup.com/300/200/" },
resizeMode: "cover",
// ...
}}
</Video>
````
### `posterResizeMode`
> [!WARNING]
> deprecated, use `poster` with `resizeMode` key instead
<PlatformsList types={['All']} />
Determines how to resize the poster image when the frame doesn't match the raw video dimensions.
@ -489,6 +522,37 @@ Speed at which the media should play.
- **1.0** - Play at normal speed (default)
- **Other values** - Slow down or speed up playback
### `renderLoader`
<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.
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={() => (
<View>
<Text>Custom Loader</Text>
</View>)
}
</Video>
````
### `repeat`
<PlatformsList types={['All']} />
@ -735,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)
@ -753,8 +817,6 @@ Example:
},
```
> ⚠️ DRM is not supported on visionOS yet
#### Start playback at a specific point in time
@ -801,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']} />
@ -816,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.
@ -867,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"
}
]}
@ -972,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
/>
```

View File

@ -57,12 +57,14 @@ $RNVideoUseGoogleIMA=true
## Android
From version >= 6.0.0, your application needs to have kotlin version >= 1.7.0
From version >= 6.0.0, your application needs to have kotlin version >= 1.8.0
```:
buildscript {
...
ext.kotlinVersion = '1.7.0'
ext.kotlinVersion = '1.8.0',
ext.compileSdkVersion = 34
ext.targetSdkVersion = 34
...
}
```

View File

@ -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)!

View File

@ -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

View File

@ -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',

View File

@ -83,8 +83,6 @@ android {
namespace "com.videoplayer"
compileOptions {
// These options are necessary to be able to build fro source
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
@ -155,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

View File

@ -16,14 +16,15 @@
"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": "0.74.5",
"react-dom": "18.2.0",
"react-native-web": "~0.19.10",
"react-native-windows": "0.74.1"
"react-native-windows": "0.74.19"
},
"devDependencies": {
"@babel/core": "^7.24.0",

View File

@ -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,
@ -26,23 +26,24 @@ import Video, {
type OnPlaybackRateChangeData,
type OnVideoTracksData,
type ReactVideoSource,
type TextTracks,
type VideoTrack,
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 {Overlay, toast} from './components';
type AdditionnalSourceInfo = {
textTracks: TextTracks;
adTagUrl: string;
description: string;
noView: boolean;
};
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>;
@ -76,7 +77,7 @@ const VideoPlayer: FC<Props> = ({}) => {
const [repeat, setRepeat] = useState(false);
const [controls, setControls] = useState(false);
const [useCache, setUseCache] = useState(false);
const [poster, setPoster] = useState<string | undefined>(undefined);
const [showPoster, setShowPoster] = useState<boolean>(false);
const [showNotificationControls, setShowNotificationControls] =
useState(false);
const [isSeeking, setIsSeeking] = useState(false);
@ -111,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) => {
@ -136,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) => {
@ -221,28 +230,48 @@ 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}
paused={paused}
volume={volume}
muted={muted}
fullscreen={fullscreen}
controls={controls}
resizeMode={resizeMode}
onFullscreenPlayerWillDismiss={onFullScreenExit}
@ -261,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}
poster={poster}
renderLoader={_renderLoader}
onPlaybackRateChange={onPlaybackRateChange}
onPlaybackStateChanged={onPlaybackStateChanged}
bufferingStrategy={BufferingStrategyType.DEFAULT}
debug={{enable: true, thread: true}}
subtitleStyle={_subtitleStyle}
controlsStyles={_controlsStyles}
/>
</TouchableOpacity>
)}
@ -302,7 +331,7 @@ const VideoPlayer: FC<Props> = ({}) => {
paused={paused}
volume={volume}
setControls={setControls}
poster={poster}
showPoster={showPoster}
rate={rate}
setFullscreen={setFullscreen}
setPaused={setPaused}
@ -311,7 +340,7 @@ const VideoPlayer: FC<Props> = ({}) => {
setIsSeeking={setIsSeeking}
repeat={repeat}
setRepeat={setRepeat}
setPoster={setPoster}
setShowPoster={setShowPoster}
setRate={setRate}
setResizeMode={setResizeMode}
setShowNotificationControls={setShowNotificationControls}

View File

@ -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}`}
/>
);
})}

View File

@ -1,22 +1,8 @@
import React, {FC, memo} from 'react';
import {ActivityIndicator, View} from 'react-native';
import styles from '../styles.tsx';
import React, {memo} from 'react';
import {ActivityIndicator} from 'react-native';
type Props = {
isLoading: boolean;
};
const _Indicator: FC<Props> = ({isLoading}) => {
if (!isLoading) {
return <View />;
}
return (
<ActivityIndicator
color="#3235fd"
size="large"
style={styles.IndicatorStyle}
/>
);
const _Indicator = () => {
return <ActivityIndicator color="#3235fd" size="large" />;
};
export const Indicator = memo(_Indicator);

View File

@ -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,

View File

@ -5,19 +5,14 @@ import React, {
type Dispatch,
type SetStateAction,
} from 'react';
import {Indicator} from './Indicator.tsx';
import {View} from 'react-native';
import styles from '../styles.tsx';
import ToggleControl from '../ToggleControl.tsx';
import {
isAndroid,
isIos,
samplePoster,
textTracksSelectionBy,
audioTracksSelectionBy,
} from '../constants';
import MultiValueControl, {
type MultiValueControlPropType,
} from '../MultiValueControl.tsx';
import {
ResizeMode,
VideoRef,
@ -31,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;
@ -69,8 +65,8 @@ type Props = {
setPaused: Dispatch<SetStateAction<boolean>>;
repeat: boolean;
setRepeat: Dispatch<SetStateAction<boolean>>;
poster: string | undefined;
setPoster: Dispatch<SetStateAction<string | undefined>>;
showPoster: boolean;
setShowPoster: Dispatch<SetStateAction<boolean>>;
muted: boolean;
setMuted: Dispatch<SetStateAction<boolean>>;
currentTime: number;
@ -108,8 +104,8 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
setPaused,
setRepeat,
repeat,
setPoster,
poster,
setShowPoster,
showPoster,
setMuted,
muted,
duration,
@ -157,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) => {
@ -217,14 +206,12 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
const toggleRepeat = () => setRepeat(prev => !prev);
const togglePoster = () =>
setPoster(prev => (prev ? undefined : samplePoster));
const togglePoster = () => setShowPoster(prev => !prev);
const toggleMuted = () => setMuted(prev => !prev);
return (
<>
<Indicator isLoading={isLoading} />
<View style={styles.topControls}>
<View style={styles.resizeModeControl}>
<TopControl
@ -270,7 +257,7 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
<ToggleControl onPress={toggleFullscreen} text="fullscreen" />
<ToggleControl onPress={openDecoration} text="decoration" />
<ToggleControl
isSelected={!!poster}
isSelected={showPoster}
onPress={togglePoster}
selectedText="poster"
unselectedText="no poster"
@ -339,6 +326,7 @@ const _Overlay = forwardRef<VideoRef, Props>((props, ref) => {
audioTracks={audioTracks}
selectedAudioTrack={selectedAudioTrack}
onValueChange={onSelectedAudioTrackChange}
audioTracksSelectionBy={audioTracksSelectionBy}
/>
<TextTrackSelector
textTracks={textTracks}

View File

@ -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>
</>

View File

@ -25,7 +25,7 @@ interface ToggleControlType {
onPress: () => void;
}
const ToggleControl = ({
export const ToggleControl = ({
isSelected,
selectedText,
unselectedText,

View File

@ -0,0 +1,15 @@
import {Text, View} from 'react-native';
import {Indicator} from './Indicator.tsx';
import React, {memo} from 'react';
import styles from '../styles.tsx';
const _VideoLoader = () => {
return (
<View style={styles.indicatorContainer}>
<Text style={styles.indicatorText}>Loading...</Text>
<Indicator />
</View>
);
};
export const VideoLoader = memo(_VideoLoader);

View File

@ -1,3 +1,4 @@
export * from './VideoLoader';
export * from './Indicator';
export * from './Seeker';
export * from './AudioTracksSelector';
@ -6,3 +7,5 @@ export * from './TextTracksSelector';
export * from './Overlay';
export * from './TopControl';
export * from './Toast';
export * from './ToggleControl';
export * from './MultiValueControl';

View File

@ -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,
@ -86,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
@ -100,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',
@ -126,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',
@ -157,13 +174,12 @@ export const srcAndroidList = [
},
];
// poster which can be displayed
export const samplePoster =
'https://upload.wikimedia.org/wikipedia/commons/1/18/React_Native_Logo.png';
const platformSrc: SampleVideoSource[] = isAndroid
? srcAndroidList
: srcIosList;
export const srcList: SampleVideoSource[] = srcAllPlatformList.concat(
isAndroid ? srcAndroidList : srcIosList,
);
export const srcList: SampleVideoSource[] =
platformSrc.concat(srcAllPlatformList);
export const bufferConfig: BufferConfig = {
minBufferMs: 15000,

View File

@ -102,9 +102,15 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: 'red',
},
IndicatorStyle: {
flex: 1,
indicatorContainer: {
justifyContent: 'center',
alignItems: 'center',
gap: 10,
width: '100%',
height: '100%',
},
indicatorText: {
color: 'white',
},
seekbarContainer: {
flex: 1,

View File

@ -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

View File

@ -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

View File

@ -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

View 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
}
}

View File

@ -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
}
}

View File

@ -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": ""])
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
)
}
}

View File

@ -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?",
]
)
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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()
@ -72,7 +73,7 @@ class NowPlayingInfoCenterManager {
if currentPlayer == player {
currentPlayer = nil
updateMetadata()
updateNowPlayingInfo()
}
if players.allObjects.isEmpty {
@ -106,14 +107,12 @@ class NowPlayingInfoCenterManager {
currentPlayer = player
registerCommandTargets()
updateMetadata()
// one second observer
updateNowPlayingInfo()
playbackObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(value: 1, timescale: 4),
queue: .global(),
using: { [weak self] _ in
self?.updatePlaybackInfo()
self?.updateNowPlayingInfo()
}
)
}
@ -169,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() {
@ -184,9 +196,10 @@ class NowPlayingInfoCenterManager {
remoteCommandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
remoteCommandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
remoteCommandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
remoteCommandCenter.togglePlayPauseCommand.removeTarget(togglePlayPauseTarget)
}
public func updateMetadata() {
public func updateNowPlayingInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
invalidateCommandTargets()
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
@ -194,7 +207,12 @@ class NowPlayingInfoCenterManager {
}
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
let metadata = currentItem.asset.commonMetadata + currentItem.externalMetadata
// externalMetadata should override commonMetadata to allow override metadata from source
let metadata = {
let common = Dictionary(uniqueKeysWithValues: currentItem.asset.commonMetadata.map { ($0.identifier, $0) })
let external = Dictionary(uniqueKeysWithValues: currentItem.externalMetadata.map { ($0.identifier, $0) })
return Array((common.merging(external) { _, new in new }).values)
}()
let titleItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierTitle).first?.stringValue ?? ""
@ -206,7 +224,7 @@ class NowPlayingInfoCenterManager {
let image = imgData.flatMap { UIImage(data: $0) } ?? UIImage()
let artworkItem = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
let nowPlayingInfo: [String: Any] = [
let newNowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: titleItem,
MPMediaItemPropertyArtist: artistItem,
MPMediaItemPropertyArtwork: artworkItem,
@ -215,28 +233,9 @@ class NowPlayingInfoCenterManager {
MPNowPlayingInfoPropertyPlaybackRate: player.rate,
MPNowPlayingInfoPropertyIsLiveStream: CMTIME_IS_INDEFINITE(currentItem.asset.duration),
]
let currentNowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updatePlaybackInfo() {
guard let player = currentPlayer, let currentItem = player.currentItem else {
return
}
// We dont want to update playback if we did not set metadata yet
if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
let newNowPlayingInfo: [String: Any] = [
MPMediaItemPropertyPlaybackDuration: currentItem.duration.seconds,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentItem.currentTime().seconds.rounded(),
MPNowPlayingInfoPropertyPlaybackRate: player.rate,
MPNowPlayingInfoPropertyIsLiveStream: CMTIME_IS_INDEFINITE(currentItem.asset.duration),
]
nowPlayingInfo.merge(newNowPlayingInfo) { _, v in v }
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = currentNowPlayingInfo.merging(newNowPlayingInfo) { _, new in new }
}
private func findNewCurrentPlayer() {

View File

@ -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
@ -284,9 +283,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()
@ -295,7 +303,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.
@ -305,7 +313,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
@ -341,7 +349,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
}
@ -364,7 +372,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
}
@ -374,7 +382,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),
])
}
@ -406,17 +414,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),
@ -442,23 +450,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 {
@ -479,7 +490,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)
@ -488,10 +499,21 @@ 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)
// later we can just call "updateMetadata:
NowPlayingInfoCenterManager.shared.updateMetadata()
#if !os(tvOS) && !os(visionOS)
if #available(iOS 16.0, *) {
self._playerViewController?.allowsVideoFrameAnalysis = true
}
#endif
// later we can just call "updateNowPlayingInfo:
NowPlayingInfoCenterManager.shared.updateNowPlayingInfo()
}
_playerObserver.player = _player
@ -503,7 +525,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!)
@ -540,7 +562,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
@ -572,13 +594,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))
}
@ -589,11 +606,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))
@ -718,14 +739,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()
}
@ -785,7 +806,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
self.setPaused(self._paused)
self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))),
"seekTime": time,
"target": self.reactTag])
"target": self.reactTag as Any])
}
_pendingSeek = false
@ -883,7 +904,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 {
@ -908,9 +929,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
setMaxBitRate(_maxBitRate)
}
setSelectedTextTrack(_selectedTextTrackCriteria)
setAudioOutput(_audioOutput)
setSelectedAudioTrack(_selectedAudioTrackCriteria)
setSelectedTextTrack(_selectedTextTrackCriteria)
setResizeMode(_resizeMode)
setRepeat(_repeat)
setControls(_controls)
@ -929,7 +950,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)
@ -942,9 +963,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,
@ -953,18 +975,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) })
@ -976,7 +986,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
@ -1015,7 +1025,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])
})
}
}
@ -1038,9 +1048,9 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
@objc
func setFullscreenOrientation(_ orientation: String?) {
_fullscreenOrientation = orientation
_fullscreenOrientation = orientation ?? "all"
if _fullscreenPlayerPresented {
_playerViewController?.preferredOrientation = orientation
_playerViewController?.preferredOrientation = _fullscreenOrientation
}
}
@ -1203,13 +1213,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
@ -1270,14 +1279,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()
@ -1310,12 +1318,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
@ -1329,7 +1337,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
_isBuffering = false
}
onReadyForDisplay?([
"target": reactTag,
"target": reactTag as Any,
])
}
@ -1348,7 +1356,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
}
onTimedMetadata?([
"target": reactTag,
"target": reactTag as Any,
"metadata": metadata,
])
}
@ -1366,9 +1374,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))
@ -1397,7 +1419,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)
@ -1424,7 +1446,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])
}
@ -1444,14 +1466,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,
]
)
}
@ -1564,12 +1586,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,
]
)
}
@ -1602,7 +1624,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
}
)
} else {
_playerObserver.removePlayerTimeObserver()
_player?.pause()
_player?.rate = 0.0
}
}
@ -1613,16 +1636,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)])
}
}

View File

@ -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);
@ -69,11 +68,12 @@ RCT_EXPORT_VIEW_PROPERTY(onAudioTracks, RCTDirectEventBlock);
RCT_EXPORT_VIEW_PROPERTY(onTextTrackDataChanged, RCTDirectEventBlock);
RCT_EXTERN_METHOD(seekCmd : (nonnull NSNumber*)reactTag time : (nonnull NSNumber*)time tolerance : (nonnull NSNumber*)tolerance)
RCT_EXTERN_METHOD(setLicenseResultCmd : (nonnull NSNumber*)reactTag lisence : (NSString*)license licenseUrl : (NSString*)licenseUrl)
RCT_EXTERN_METHOD(setLicenseResultCmd : (nonnull NSNumber*)reactTag license : (NSString*)license licenseUrl : (NSString*)licenseUrl)
RCT_EXTERN_METHOD(setLicenseResultErrorCmd : (nonnull NSNumber*)reactTag error : (NSString*)error licenseUrl : (NSString*)licenseUrl)
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

View File

@ -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

View File

@ -1,7 +1,7 @@
import AVKit
import Foundation
protocol RCTVideoPlayerViewControllerDelegate: class {
protocol RCTVideoPlayerViewControllerDelegate: AnyObject {
func videoPlayerViewControllerWillDismiss(playerViewController: AVPlayerViewController)
func videoPlayerViewControllerDidDismiss(playerViewController: AVPlayerViewController)
}

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name": "react-native-video",
"version": "6.4.2",
"version": "6.6.4",
"description": "A <Video /> element for react-native",
"main": "lib/index",
"source": "src/index.ts",

View File

@ -8,9 +8,17 @@ import React, {
} from 'react';
import type {ElementRef} from 'react';
import {View, StyleSheet, Image, Platform, processColor} from 'react-native';
import type {StyleProp, ImageStyle, NativeSyntheticEvent} from 'react-native';
import type {
StyleProp,
ImageStyle,
NativeSyntheticEvent,
ViewStyle,
ImageResizeMode,
} from 'react-native';
import NativeVideoComponent from './specs/VideoNativeComponent';
import NativeVideoComponent, {
NativeCmcdConfiguration,
} from './specs/VideoNativeComponent';
import type {
OnAudioFocusChangedData,
OnAudioTracksData,
@ -37,12 +45,14 @@ import {
resolveAssetSourceForVideo,
} from './utils';
import NativeVideoManager from './specs/NativeVideoManager';
import {ViewType, type VideoSaveData} from './types';
import {type VideoSaveData, CmcdMode, ViewType} from './types';
import type {
OnLoadData,
OnTextTracksData,
OnReceiveAdEventData,
ReactVideoProps,
CmcdData,
ReactVideoSource,
} from './types';
export interface VideoRef {
@ -56,6 +66,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>;
}
@ -66,8 +77,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
source,
style,
resizeMode,
posterResizeMode,
poster,
posterResizeMode,
renderLoader,
contentStartTime,
drm,
textTracks,
selectedVideoTrack,
@ -77,6 +90,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
useSecureView,
viewType,
shutterColor,
adTagUrl,
adLanguage,
onLoadStart,
onLoad,
onError,
@ -107,82 +122,152 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
onTextTrackDataChanged,
onVideoTracks,
onAspectRatio,
localSourceEncryptionKeyScheme,
...rest
},
ref,
) => {
const nativeRef = useRef<ElementRef<typeof NativeVideoComponent>>(null);
const [showPoster, setShowPoster] = useState(!!poster);
const isPosterDeprecated = typeof poster === 'string';
const _renderLoader = useMemo(
() =>
!renderLoader
? undefined
: renderLoader instanceof Function
? renderLoader
: () => renderLoader,
[renderLoader],
);
const hasPoster = useMemo(() => {
if (_renderLoader) {
return true;
}
if (isPosterDeprecated) {
return !!poster;
}
return !!poster?.source;
}, [isPosterDeprecated, poster, _renderLoader]);
const [showPoster, setShowPoster] = useState(hasPoster);
const [
_restoreUserInterfaceForPIPStopCompletionHandler,
setRestoreUserInterfaceForPIPStopCompletionHandler,
] = useState<boolean | undefined>();
const hasPoster = !!poster;
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 posterStyle = useMemo<StyleProp<ImageStyle>>(
() => ({
...StyleSheet.absoluteFillObject,
resizeMode:
posterResizeMode && posterResizeMode !== 'none'
? posterResizeMode
: 'contain',
}),
[posterResizeMode],
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) {
@ -304,6 +389,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],
@ -501,7 +596,8 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
[onControlsVisibilityChange],
);
const usingExternalGetLicense = drm?.getLicense instanceof Function;
const selectedDrm = source?.drm || drm;
const usingExternalGetLicense = selectedDrm?.getLicense instanceof Function;
const onGetLicense = useCallback(
async (event: NativeSyntheticEvent<OnGetLicenseData>) => {
@ -509,33 +605,43 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
return;
}
const data = event.nativeEvent;
let result;
if (data?.spcBase64) {
try {
// Handles both scenarios, getLicenseOverride being a promise and not.
const license = await drm.getLicense(
try {
if (!data?.spcBase64) {
throw new Error('No spc received');
}
// Handles both scenarios, getLicenseOverride being a promise and not.
const license = await Promise.resolve(
selectedDrm.getLicense(
data.spcBase64,
data.contentId,
data.licenseUrl,
data.loadedLicenseUrl,
);
result =
typeof license === 'string' ? license : 'Empty license result';
} catch {
result = 'fetch error';
),
).catch(() => {
throw new Error('fetch error');
});
if (typeof license !== 'string') {
throw Error('Empty license result');
}
if (nativeRef.current) {
NativeVideoManager.setLicenseResultCmd(
getReactTag(nativeRef),
license,
data.loadedLicenseUrl,
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'fetch error';
if (nativeRef.current) {
NativeVideoManager.setLicenseResultErrorCmd(
getReactTag(nativeRef),
msg,
data.loadedLicenseUrl,
);
}
} else {
result = 'No spc received';
}
if (nativeRef.current) {
NativeVideoManager.setLicenseResultErrorCmd(
getReactTag(nativeRef),
result,
data.loadedLicenseUrl,
);
}
},
[drm, usingExternalGetLicense],
[selectedDrm, usingExternalGetLicense],
);
useImperativeHandle(
@ -551,6 +657,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume,
getCurrentPosition,
setFullScreen,
setSource,
}),
[
seek,
@ -563,6 +670,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
setVolume,
getCurrentPosition,
setFullScreen,
setSource,
],
);
@ -573,42 +681,120 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const shallForceViewType =
hasValidDrmProp && (viewType === ViewType.TEXTURE || useTextureView);
if (shallForceViewType) {
console.warn(
'cannot use DRM on texture view. please set useTextureView={false}',
);
}
if (useSecureView && useTextureView) {
console.warn(
'cannot use SecureView on texture view. please set useTextureView={false}',
);
}
return shallForceViewType
? useSecureView
? ViewType.SURFACE_SECURE
: ViewType.SURFACE // check if we should force the type to Surface due to DRM
: viewType
? viewType // else use ViewType from source
: useSecureView // else infer view type from useSecureView and useTextureView
? ViewType.SURFACE_SECURE
: useTextureView
? ViewType.TEXTURE
: ViewType.SURFACE;
if (shallForceViewType) {
console.warn(
'cannot use DRM on texture view. please set useTextureView={false}',
);
return useSecureView ? ViewType.SURFACE_SECURE : ViewType.SURFACE;
}
if (viewType !== undefined && viewType !== null) {
return viewType;
}
if (useSecureView) {
return ViewType.SURFACE_SECURE;
}
if (useTextureView) {
return ViewType.TEXTURE;
}
return ViewType.SURFACE;
}, [drm, useSecureView, useTextureView, viewType]);
const _renderPoster = useCallback(() => {
if (!hasPoster || !showPoster) {
return null;
}
// poster resize mode
let _posterResizeMode: ImageResizeMode = 'contain';
if (!isPosterDeprecated && poster?.resizeMode) {
_posterResizeMode = poster.resizeMode;
} else if (posterResizeMode && posterResizeMode !== 'none') {
_posterResizeMode = posterResizeMode;
}
// poster style
const baseStyle: StyleProp<ImageStyle> = {
...StyleSheet.absoluteFillObject,
resizeMode: _posterResizeMode,
};
let posterStyle: StyleProp<ImageStyle> = baseStyle;
if (!isPosterDeprecated && poster?.style) {
const styles = Array.isArray(poster.style)
? poster.style
: [poster.style];
posterStyle = [baseStyle, ...styles];
}
// render poster
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({
source: source,
style: posterStyle,
resizeMode: resizeMode,
})}
</View>
);
}
return (
<Image
{...(isPosterDeprecated ? {} : poster)}
source={isPosterDeprecated ? {uri: poster} : poster?.source}
style={posterStyle}
/>
);
}, [
hasPoster,
isPosterDeprecated,
poster,
posterResizeMode,
_renderLoader,
showPoster,
source,
resizeMode,
]);
const _style: StyleProp<ViewStyle> = useMemo(
() => ({
...StyleSheet.absoluteFillObject,
...(showPoster ? {display: 'none'} : {}),
}),
[showPoster],
);
return (
<View style={style}>
<NativeVideoComponent
ref={nativeRef}
{...rest}
src={src}
style={StyleSheet.absoluteFill}
style={_style}
resizeMode={resizeMode}
restoreUserInterfaceForPIPStopCompletionHandler={
_restoreUserInterfaceForPIPStopCompletionHandler
}
textTracks={textTracks}
selectedTextTrack={_selectedTextTrack}
selectedAudioTrack={_selectedAudioTrack}
selectedVideoTrack={_selectedVideoTrack}
@ -678,9 +864,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
}
viewType={_viewType}
/>
{hasPoster && showPoster ? (
<Image style={posterStyle} source={{uri: poster}} />
) : null}
{_renderPoster()}
</View>
);
},

View File

@ -236,6 +236,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
controls={controls}
loop={repeat}
playsInline
//@ts-ignore
poster={poster}
onCanPlay={() => onBuffer?.({isBuffering: false})}
onWaiting={() => onBuffer?.({isBuffering: true})}

View File

@ -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(

View File

@ -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',

View File

@ -1,4 +1,5 @@
import Video from './Video';
export {VideoDecoderProperties} from './VideoDecoderProperties';
export * from './types';
export {Video};
export default Video;

View File

@ -21,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>;

View File

@ -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<{
@ -283,8 +303,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<{
@ -293,7 +325,6 @@ export type OnControlsVisibilityChange = Readonly<{
export interface VideoNativeProps extends ViewProps {
src?: VideoSrc;
adTagUrl?: string;
allowsExternalPlayback?: boolean; // ios, true
disableFocus?: boolean; // android
maxBitRate?: Float;
@ -302,7 +333,6 @@ export interface VideoNativeProps extends ViewProps {
automaticallyWaitsToMinimizeStalling?: boolean;
shutterColor?: Int32;
audioOutput?: WithDefault<string, 'speaker'>;
textTracks?: TextTracks;
selectedTextTrack?: SelectedTextTrack;
selectedAudioTrack?: SelectedAudioTrack;
selectedVideoTrack?: SelectedVideoTrack; // android
@ -325,11 +355,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

View File

@ -1,6 +1,15 @@
import type {ISO639_1} from './language';
import type {ReactVideoEvents} from './events';
import type {StyleProp, ViewProps, ViewStyle} from 'react-native';
import type {
ImageProps,
StyleProp,
ViewProps,
ViewStyle,
ImageRequireSource,
ImageURISource,
ImageStyle,
} from 'react-native';
import type {ReactNode} from 'react';
import type VideoResizeMode from './ResizeMode';
import type FilterType from './FilterType';
import type ViewType from './ViewType';
@ -23,9 +32,13 @@ export type ReactVideoSourceProperties = {
startPosition?: number;
cropStart?: number;
cropEnd?: number;
contentStartTime?: number; // Android
metadata?: VideoMetadata;
drm?: Drm;
cmcd?: Cmcd; // android
textTracksAllowChunklessPreparation?: boolean;
textTracks?: TextTracks;
ad?: AdConfig;
};
export type ReactVideoSource = Readonly<
@ -34,6 +47,13 @@ export type ReactVideoSource = Readonly<
}
>;
export type ReactVideoPosterSource = ImageURISource | ImageRequireSource;
export type ReactVideoPoster = Omit<ImageProps, 'source'> & {
// prevents giving source in the array
source?: ReactVideoPosterSource;
};
export type VideoMetadata = Readonly<{
title?: string;
subtitle?: string;
@ -54,6 +74,11 @@ export enum DRMType {
FAIRPLAY = 'fairplay',
}
export type AdConfig = Readonly<{
adTagUrl?: string;
adLanguage?: ISO639_1;
}>;
export type Drm = Readonly<{
type?: DRMType;
licenseServer?: string;
@ -62,6 +87,7 @@ export type Drm = Readonly<{
certificateUrl?: string; // ios
base64Certificate?: boolean; // ios default: false
multiDrm?: boolean; // android
localSourceEncryptionKeyScheme?: string; // ios
/* eslint-disable @typescript-eslint/no-unused-vars */
getLicense?: (
spcBase64: string,
@ -72,6 +98,27 @@ export type Drm = Readonly<{
/* eslint-enable @typescript-eslint/no-unused-vars */
}>;
export enum CmcdMode {
MODE_REQUEST_HEADER = 0,
MODE_QUERY_PARAMETER = 1,
}
/**
* Custom key names MUST carry a hyphenated prefix to ensure that there will not be a
* namespace collision with future revisions to this specification. Clients SHOULD
* use a reverse-DNS syntax when defining their own prefix.
*
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf CTA-5004 Specification (Page 6, Section 3.1)
*/
export type CmcdData = Record<`${string}-${string}`, string | number>;
export type CmcdConfiguration = Readonly<{
mode?: CmcdMode; // default: MODE_QUERY_PARAMETER
request?: CmcdData;
session?: CmcdData;
object?: CmcdData;
status?: CmcdData;
}>;
export type Cmcd = boolean | CmcdConfiguration;
export enum BufferingStrategyType {
DEFAULT = 'Default',
DISABLE_BUFFERING = 'DisableBuffering',
@ -131,6 +178,7 @@ export type SubtitleStyle = {
paddingLeft?: number;
paddingRight?: number;
opacity?: number;
subtitlesFollowVideo?: boolean;
};
export enum TextTrackType {
@ -208,20 +256,42 @@ export type AudioOutput = 'speaker' | 'earpiece';
export type ControlsStyles = {
hideSeekBar?: boolean;
hideDuration?: boolean;
hidePosition?: boolean;
hidePlayPause?: boolean;
hideForward?: boolean;
hideRewind?: boolean;
hideNext?: boolean;
hidePrevious?: boolean;
hideFullscreen?: boolean;
hideNavigationBarOnFullScreenMode?: boolean;
hideNotificationBarOnFullScreenMode?: boolean;
hideSettingButton?: boolean;
seekIncrementMS?: number;
liveLabel?: string;
};
export interface ReactVideoRenderLoaderProps {
source?: ReactVideoSource;
style?: StyleProp<ImageStyle>;
resizeMode?: EnumValues<VideoResizeMode>;
}
export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
source?: ReactVideoSource;
/** @deprecated */
/** @deprecated Use source.drm */
drm?: Drm;
style?: StyleProp<ViewStyle>;
/** @deprecated Use source.ad.adTagUrl */
adTagUrl?: string;
/** @deprecated Use source.ad.adLanguage */
adLanguage?: ISO639_1;
audioOutput?: AudioOutput; // Mobile
automaticallyWaitsToMinimizeStalling?: boolean; // iOS
bufferConfig?: BufferConfig; // Android
bufferingStrategy?: BufferingStrategyType;
chapters?: Chapters[]; // iOS
/** @deprecated Use source.contentStartTime */
contentStartTime?: number; // Android
controls?: boolean;
currentPlaybackTime?: number; // Android
@ -243,12 +313,14 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
pictureInPicture?: boolean; // iOS
playInBackground?: boolean;
playWhenInactive?: boolean; // iOS
poster?: string;
poster?: string | ReactVideoPoster; // string is deprecated
/** @deprecated use **resizeMode** key in **poster** props instead */
posterResizeMode?: EnumValues<PosterResizeModeType>;
preferredForwardBufferDuration?: number; // iOS
preventsDisplaySleepDuringVideoPlayback?: boolean;
progressUpdateInterval?: number;
rate?: number;
renderLoader?: ReactNode | ((arg0: ReactVideoRenderLoaderProps) => ReactNode);
repeat?: boolean;
reportBandwidth?: boolean; //Android
resizeMode?: EnumValues<VideoResizeMode>;
@ -258,14 +330,16 @@ export interface ReactVideoProps extends ReactVideoEvents, ViewProps {
selectedVideoTrack?: SelectedVideoTrack; // android
subtitleStyle?: SubtitleStyle; // android
shutterColor?: string; // Android
/** @deprecated Use source.textTracks */
textTracks?: TextTracks;
testID?: string;
viewType?: ViewType;
/** @deprecated */
/** @deprecated Use viewType */
useTextureView?: boolean; // Android
/** @deprecated */
/** @deprecated Use viewType*/
useSecureView?: boolean; // Android
volume?: number;
/** @deprecated use **localSourceEncryptionKeyScheme** key in **drm** props instead */
localSourceEncryptionKeyScheme?: string;
debug?: DebugConfig;
allowsExternalPlayback?: boolean; // iOS