wip: Add expo plugin

This commit is contained in:
2026-04-24 20:47:24 +02:00
parent b160f9cede
commit eaeec5a35a
7 changed files with 306 additions and 835 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('./plugin/build').default;
+71 -833
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -10,8 +10,9 @@
"scripts": {
"dev": "cd example && bun start",
"android": " cd example && bunx react-native run-android --no-packager --port 8082",
"typecheck": "tsc --noEmit",
"build": "bun run typecheck && bob build",
"typecheck": "tsc --noEmit && tsc -p plugin/tsconfig.json --noEmit",
"build:plugin": "tsc -p plugin/tsconfig.json",
"build": "bun run typecheck && bob build && bun run build:plugin",
"codegen": "nitrogen --logLevel=\"debug\" && node post-script.js"
},
"keywords": [
@@ -38,6 +39,7 @@
"ios/**/*.cpp",
"ios/**/*.swift",
"app.plugin.js",
"plugin/build",
"*.podspec",
"README.md"
],
@@ -53,6 +55,9 @@
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"dependencies": {
"@expo/config-plugins": "^10.0.2"
},
"devDependencies": {
"@biomejs/biome": "2.4.10",
"@types/jest": "^29.5.12",
+15
View File
@@ -0,0 +1,15 @@
import { createRunOncePlugin } from '@expo/config-plugins';
import type { ConfigPlugin } from '@expo/config-plugins';
import pkg from '../../package.json';
import { withMediaNotifications } from './withMediaNotifications';
import { withPip } from './withPip';
const withOmni: ConfigPlugin = (config) => {
let nextConfig = withPip(config);
nextConfig = withMediaNotifications(nextConfig);
return nextConfig;
};
export default createRunOncePlugin(withOmni, pkg.name, pkg.version);
export { withPip, withMediaNotifications };
+68
View File
@@ -0,0 +1,68 @@
import { AndroidConfig, withAndroidManifest, type ConfigPlugin } from '@expo/config-plugins';
function ensureUsesPermission(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
permission: string
): void {
const usesPermissions = androidManifest.manifest['uses-permission'] ?? [];
const alreadyDefined = usesPermissions.some((item) => item.$['android:name'] === permission);
if (!alreadyDefined) {
usesPermissions.push({
$: {
'android:name': permission,
},
});
}
androidManifest.manifest['uses-permission'] = usesPermissions;
}
function patchManifestForMediaNotifications(
androidManifest: AndroidConfig.Manifest.AndroidManifest
): AndroidConfig.Manifest.AndroidManifest {
const mainApplication = AndroidConfig.Manifest.getMainApplication(androidManifest);
if (!mainApplication) {
console.warn(
'AndroidManifest.xml is missing a <application android:name=".MainApplication" /> element - skipping Omni media service config.'
);
return androidManifest;
}
const services = mainApplication.service ?? [];
const serviceName = 'dev.zoriya.omni.OmniPlayerService';
const hasService = services.some((service) => service.$['android:name'] === serviceName);
if (!hasService) {
services.push({
$: {
'android:name': serviceName,
'android:enabled': 'true',
'android:exported': 'true',
'android:foregroundServiceType': 'mediaPlayback',
},
'intent-filter': [
{
action: [
{
$: {
'android:name': 'androidx.media3.session.MediaSessionService',
},
},
],
},
],
});
}
mainApplication.service = services;
ensureUsesPermission(androidManifest, 'android.permission.FOREGROUND_SERVICE');
ensureUsesPermission(androidManifest, 'android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK');
return androidManifest;
}
export const withMediaNotifications: ConfigPlugin = (config) => {
return withAndroidManifest(config, (manifestConfig) => {
manifestConfig.modResults = patchManifestForMediaNotifications(manifestConfig.modResults);
return manifestConfig;
});
};
+129
View File
@@ -0,0 +1,129 @@
import { AndroidConfig, withAndroidManifest, withMainActivity, type ConfigPlugin } from '@expo/config-plugins';
import { addImports, appendContentsInsideDeclarationBlock } from '@expo/config-plugins/build/android/codeMod';
const MODE_MARKER = 'OmniView.onActivityPipModeChanged(';
const UI_STATE_MARKER = 'OmniView.onActivityPipTransitionToPip(';
function patchMainActivityForPip(mainActivity: string, language: 'java' | 'kt'): string {
const isJava = language === 'java';
if (mainActivity.includes(MODE_MARKER) && mainActivity.includes(UI_STATE_MARKER)) {
return mainActivity;
}
const withRequiredImports = addImports(
mainActivity,
['android.app.PictureInPictureUiState', 'android.os.Build', 'dev.zoriya.omni.OmniView'],
isJava
);
let output = withRequiredImports;
if (!output.includes(MODE_MARKER)) {
const modeChangedBlock = isJava
? [
'\n @Override',
' public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode) {',
' super.onPictureInPictureModeChanged(isInPictureInPictureMode);',
' OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode);',
' }\n',
]
: [
'\n override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {',
' super.onPictureInPictureModeChanged(isInPictureInPictureMode)',
' OmniView.onActivityPipModeChanged(this, isInPictureInPictureMode)',
' }\n',
];
output = appendContentsInsideDeclarationBlock(output, 'class MainActivity', modeChangedBlock.join('\n'));
}
if (!output.includes(UI_STATE_MARKER)) {
const uiStateChangedBlock = isJava
? [
'\n @Override',
' public void onPictureInPictureUiStateChanged(PictureInPictureUiState pipState) {',
' super.onPictureInPictureUiStateChanged(pipState);',
' if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip()) {',
' OmniView.onActivityPipTransitionToPip(this);',
' }',
' }\n',
]
: [
'\n override fun onPictureInPictureUiStateChanged(pipState: PictureInPictureUiState) {',
' super.onPictureInPictureUiStateChanged(pipState)',
' if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && pipState.isTransitioningToPip) {',
' OmniView.onActivityPipTransitionToPip(this)',
' }',
' }\n',
];
output = appendContentsInsideDeclarationBlock(output, 'class MainActivity', uiStateChangedBlock.join('\n'));
}
return output;
}
function ensureUsesFeature(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
featureName: string,
required: 'true' | 'false'
): void {
const usesFeature = androidManifest.manifest['uses-feature'] ?? [];
const existing = usesFeature.find((feature) => feature.$['android:name'] === featureName);
if (existing) {
existing.$['android:required'] = required;
} else {
usesFeature.push({
$: {
'android:name': featureName,
'android:required': required,
},
});
}
androidManifest.manifest['uses-feature'] = usesFeature;
}
function ensureConfigChanges(mainActivity: AndroidConfig.Manifest.ManifestActivity): void {
const requiredChanges = ['screenSize', 'smallestScreenSize', 'screenLayout', 'orientation'];
const existing = mainActivity.$['android:configChanges'] ?? '';
const existingSet = new Set(
existing
.split('|')
.map((item) => item.trim())
.filter(Boolean)
);
requiredChanges.forEach((item) => existingSet.add(item));
mainActivity.$['android:configChanges'] = Array.from(existingSet).join('|');
}
function patchManifestForPip(
androidManifest: AndroidConfig.Manifest.AndroidManifest
): AndroidConfig.Manifest.AndroidManifest {
ensureUsesFeature(androidManifest, 'android.software.picture_in_picture', 'false');
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(androidManifest);
mainActivity.$['android:supportsPictureInPicture'] = 'true';
mainActivity.$['android:resizeableActivity'] = 'true';
ensureConfigChanges(mainActivity);
return androidManifest;
}
export const withPip: ConfigPlugin = (config) => {
const withMainActivityPatched = withMainActivity(config, (activityConfig) => {
activityConfig.modResults.contents = patchMainActivityForPip(
activityConfig.modResults.contents,
activityConfig.modResults.language
);
return activityConfig;
});
return withAndroidManifest(withMainActivityPatched, (manifestConfig) => {
manifestConfig.modResults = patchManifestForPip(manifestConfig.modResults);
return manifestConfig;
});
};
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "CommonJS",
"moduleResolution": "Node",
"declaration": true,
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}