capacitor-native-video-compressor 1.2.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -33
- package/android/build.gradle +6 -4
- package/android/src/main/AndroidManifest.xml +8 -17
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressor.java +134 -4
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +385 -122
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionWorker.java +134 -0
- package/dist/docs.json +24 -24
- package/dist/esm/definitions.d.ts +52 -9
- package/dist/esm/definitions.js +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/opfs-output.d.ts +7 -0
- package/dist/esm/opfs-output.js +94 -0
- package/dist/esm/opfs-output.js.map +1 -0
- package/dist/esm/web.d.ts +21 -3
- package/dist/esm/web.js +194 -129
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +306 -128
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +307 -129
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressor.swift +34 -0
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressorPlugin.swift +118 -79
- package/ios/Tests/NativeVideoCompressorPluginTests/NativeVideoCompressorTests.swift +44 -0
- package/package.json +9 -4
- package/android/src/main/java/com/atomic/videocompressor/VideoCompressionService.java +0 -310
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capacitor-native-video-compressor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Native video compressor using LightCompressor and AVFoundation",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
|
|
34
34
|
"verify:ios": "xcodebuild -scheme CapacitorNativeVideoCompressor -destination generic/platform=iOS",
|
|
35
35
|
"verify:android": "cd android && ./gradlew clean build test && cd ..",
|
|
36
|
-
"verify:web": "npm run build",
|
|
36
|
+
"verify:web": "npm run build && npm test",
|
|
37
|
+
"test": "tsc -p tsconfig.test.json && vitest run",
|
|
37
38
|
"lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
|
|
38
39
|
"fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
|
|
39
40
|
"eslint": "eslint . --ext ts",
|
|
@@ -60,7 +61,8 @@
|
|
|
60
61
|
"rimraf": "^6.1.0",
|
|
61
62
|
"rollup": "^4.53.2",
|
|
62
63
|
"swiftlint": "^2.0.0",
|
|
63
|
-
"typescript": "^5.9.3"
|
|
64
|
+
"typescript": "^5.9.3",
|
|
65
|
+
"vitest": "^3.2.7"
|
|
64
66
|
},
|
|
65
67
|
"peerDependencies": {
|
|
66
68
|
"@capacitor/core": ">=8.0.0"
|
|
@@ -68,7 +70,10 @@
|
|
|
68
70
|
"prettier": "@ionic/prettier-config",
|
|
69
71
|
"swiftlint": "@ionic/swiftlint-config",
|
|
70
72
|
"eslintConfig": {
|
|
71
|
-
"extends": "@ionic/eslint-config/recommended"
|
|
73
|
+
"extends": "@ionic/eslint-config/recommended",
|
|
74
|
+
"ignorePatterns": [
|
|
75
|
+
"dist"
|
|
76
|
+
]
|
|
72
77
|
},
|
|
73
78
|
"capacitor": {
|
|
74
79
|
"ios": {
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
package com.atomic.videocompressor;
|
|
2
|
-
|
|
3
|
-
import android.app.Notification;
|
|
4
|
-
import android.app.NotificationChannel;
|
|
5
|
-
import android.app.NotificationManager;
|
|
6
|
-
import android.app.PendingIntent;
|
|
7
|
-
import android.app.Service;
|
|
8
|
-
import android.content.Intent;
|
|
9
|
-
import android.content.pm.ServiceInfo;
|
|
10
|
-
import android.os.Handler;
|
|
11
|
-
import android.os.IBinder;
|
|
12
|
-
import android.os.Looper;
|
|
13
|
-
import android.os.SystemClock;
|
|
14
|
-
import android.util.Log;
|
|
15
|
-
import androidx.annotation.Nullable;
|
|
16
|
-
import androidx.core.app.NotificationCompat;
|
|
17
|
-
import androidx.core.app.ServiceCompat;
|
|
18
|
-
import java.util.LinkedHashMap;
|
|
19
|
-
import java.util.Map;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Giữ pha nén sống khi user rời app.
|
|
23
|
-
*
|
|
24
|
-
* Chỉ pha nén, không kèm upload: upload chạy bằng JS trong webview nên nó đứng
|
|
25
|
-
* lại ngay khi app xuống nền, và một foreground service ngồi chờ user quay lại
|
|
26
|
-
* là khai một việc không diễn ra - Play từ chối version code 30 vì đúng loại
|
|
27
|
-
* sai lệch đó (nén khai dưới dataSync). Service giờ sống đúng bằng pha nén.
|
|
28
|
-
*
|
|
29
|
-
* Mọi thứ trong đây phải đáp ứng được 5 tiêu chí foreground service của Google
|
|
30
|
-
* Play, vì bản thiếu chúng (1.1.0) đã bị từ chối ở version code 29:
|
|
31
|
-
*
|
|
32
|
-
* - user perceptible -> notification có progress thật, cập nhật theo % nén,
|
|
33
|
-
* chứ không phải một dòng chữ tĩnh
|
|
34
|
-
* - stoppable by user -> action "Huỷ" ngay trên notification, vì khi user đã
|
|
35
|
-
* rời app thì UI trong app không còn với tới được
|
|
36
|
-
* - runs only as long as necessary -> watchdog tự tắt khi không còn ai báo
|
|
37
|
-
* tiến trình, để một lần JS chết giữa đường không để lại FGS chạy vô hạn
|
|
38
|
-
*/
|
|
39
|
-
public class VideoCompressionService extends Service {
|
|
40
|
-
|
|
41
|
-
private static final String TAG = "VideoCompressionService";
|
|
42
|
-
private static final String CHANNEL_ID = "VideoCompressionChannel";
|
|
43
|
-
private static final int NOTIFICATION_ID = 1001;
|
|
44
|
-
|
|
45
|
-
static final String ACTION_START = "com.atomic.videocompressor.START";
|
|
46
|
-
static final String ACTION_FINISH = "com.atomic.videocompressor.FINISH";
|
|
47
|
-
/** Do chính user bấm từ notification. */
|
|
48
|
-
static final String ACTION_CANCEL = "com.atomic.videocompressor.CANCEL";
|
|
49
|
-
|
|
50
|
-
static final String EXTRA_JOB_ID = "jobId";
|
|
51
|
-
static final String EXTRA_PERCENT = "percent";
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Không nhận được update nào trong khoảng này thì coi như phía gọi đã chết
|
|
55
|
-
* (WebView bị kill, hoặc một exception thoát ra ngoài mọi nhánh kết thúc
|
|
56
|
-
* job) và tự dừng. Không có nó, `jobs` không bao giờ rỗng và service chạy
|
|
57
|
-
* mãi với một notification treo - đúng thứ khiến Play đánh trượt tiêu chí
|
|
58
|
-
* "runs only for as long as necessary".
|
|
59
|
-
*
|
|
60
|
-
* Chọn 15 phút: dài hơn mọi khoảng lặng hợp lệ (pha nén chưa kịp bắn
|
|
61
|
-
* progress đầu tiên trên một video dài), ngắn hơn nhiều so với mức mà user
|
|
62
|
-
* kịp nhận ra có gì đó treo.
|
|
63
|
-
*/
|
|
64
|
-
private static final long WATCHDOG_MS = 15 * 60 * 1000L;
|
|
65
|
-
|
|
66
|
-
/** Đừng vẽ lại notification mỗi tick progress của LightCompressor. */
|
|
67
|
-
private static final long MIN_REDRAW_INTERVAL_MS = 500L;
|
|
68
|
-
|
|
69
|
-
private static final class Job {
|
|
70
|
-
int percent = 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** LinkedHashMap: notification một job hiển thị đúng job đã bắt đầu trước. */
|
|
74
|
-
private final Map<String, Job> jobs = new LinkedHashMap<>();
|
|
75
|
-
|
|
76
|
-
private final Handler handler = new Handler(Looper.getMainLooper());
|
|
77
|
-
private final Runnable watchdog = this::onWatchdogFired;
|
|
78
|
-
|
|
79
|
-
private long lastRedrawUptime = 0;
|
|
80
|
-
private boolean inForeground = false;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Instance đang sống, để plugin đẩy % vào mà không phải gửi một Intent cho
|
|
84
|
-
* mỗi tick.
|
|
85
|
-
*
|
|
86
|
-
* volatile vì được đọc từ thread nền: LightCompressor gọi onProgress thẳng
|
|
87
|
-
* từ coroutine encoder (Dispatchers.Default), không hop về main - đã kiểm
|
|
88
|
-
* bằng bytecode, onProgressChanged gọi listener trực tiếp không qua
|
|
89
|
-
* withContext. Thiếu volatile thì thread đó có thể đọc phải một tham chiếu
|
|
90
|
-
* cũ của service đã destroy.
|
|
91
|
-
*/
|
|
92
|
-
@Nullable
|
|
93
|
-
private static volatile VideoCompressionService instance = null;
|
|
94
|
-
|
|
95
|
-
@Override
|
|
96
|
-
public void onCreate() {
|
|
97
|
-
super.onCreate();
|
|
98
|
-
instance = this;
|
|
99
|
-
NotificationChannel channel = new NotificationChannel(
|
|
100
|
-
CHANNEL_ID, "Nén video", NotificationManager.IMPORTANCE_LOW
|
|
101
|
-
);
|
|
102
|
-
channel.setDescription("Tiến trình nén video");
|
|
103
|
-
NotificationManager manager = getSystemService(NotificationManager.class);
|
|
104
|
-
if (manager != null) manager.createNotificationChannel(channel);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
@Override
|
|
108
|
-
public void onDestroy() {
|
|
109
|
-
handler.removeCallbacks(watchdog);
|
|
110
|
-
if (instance == this) instance = null;
|
|
111
|
-
super.onDestroy();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Cập nhật tiến độ nén. Gọi được từ thread bất kỳ.
|
|
116
|
-
*
|
|
117
|
-
* No-op khi service chưa/không còn sống: progress là thông tin trang trí,
|
|
118
|
-
* không đáng để dựng lại một foreground service đã tắt.
|
|
119
|
-
*/
|
|
120
|
-
static void publishProgress(String jobId, int percent) {
|
|
121
|
-
VideoCompressionService service = instance;
|
|
122
|
-
if (service == null) return;
|
|
123
|
-
// Chuyển về main thread: lời gọi tới đây nằm trên thread encoder của
|
|
124
|
-
// LightCompressor, còn jobs/handler/notification đều chỉ được chạm từ
|
|
125
|
-
// main. Không post thì LinkedHashMap bị sửa đồng thời với onStartCommand.
|
|
126
|
-
service.handler.post(() -> service.applyProgress(jobId, percent));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
private void applyProgress(String jobId, int percent) {
|
|
130
|
-
Job job = jobs.get(jobId);
|
|
131
|
-
if (job == null) return;
|
|
132
|
-
|
|
133
|
-
int clamped = Math.max(0, Math.min(100, percent));
|
|
134
|
-
// Mỗi update đều gia hạn watchdog, kể cả khi bỏ qua việc vẽ lại:
|
|
135
|
-
// throttle chỉ để tiết kiệm vẽ, không phải dấu hiệu job đã chết.
|
|
136
|
-
armWatchdog();
|
|
137
|
-
if (job.percent == clamped) return;
|
|
138
|
-
job.percent = clamped;
|
|
139
|
-
|
|
140
|
-
long now = SystemClock.uptimeMillis();
|
|
141
|
-
if (now - lastRedrawUptime < MIN_REDRAW_INTERVAL_MS && clamped < 100) return;
|
|
142
|
-
lastRedrawUptime = now;
|
|
143
|
-
render();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
@Override
|
|
147
|
-
public int onStartCommand(Intent intent, int flags, int startId) {
|
|
148
|
-
if (intent == null) {
|
|
149
|
-
// START_NOT_STICKY nên chỉ xảy ra khi hệ thống restart service;
|
|
150
|
-
// không có job nào để phục hồi, dừng cho sạch.
|
|
151
|
-
stopEverything();
|
|
152
|
-
return START_NOT_STICKY;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
String action = intent.getAction();
|
|
156
|
-
|
|
157
|
-
if (ACTION_CANCEL.equals(action)) {
|
|
158
|
-
// Notification là thứ duy nhất user còn thấy khi đã rời app, nên
|
|
159
|
-
// nút này huỷ tất cả: không có UI nào ở đây để chọn từng job.
|
|
160
|
-
NativeVideoCompressorPlugin.cancelAllFromNotification();
|
|
161
|
-
stopEverything();
|
|
162
|
-
return START_NOT_STICKY;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
String jobId = intent.getStringExtra(EXTRA_JOB_ID);
|
|
166
|
-
if (jobId == null) return START_NOT_STICKY;
|
|
167
|
-
|
|
168
|
-
if (ACTION_START.equals(action)) {
|
|
169
|
-
jobs.put(jobId, new Job());
|
|
170
|
-
} else if (ACTION_FINISH.equals(action)) {
|
|
171
|
-
jobs.remove(jobId);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (jobs.isEmpty()) {
|
|
175
|
-
stopEverything();
|
|
176
|
-
return START_NOT_STICKY;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
armWatchdog();
|
|
180
|
-
lastRedrawUptime = SystemClock.uptimeMillis();
|
|
181
|
-
render();
|
|
182
|
-
return START_NOT_STICKY;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* startForeground() phải được gọi trong mọi nhánh của onStartCommand đến
|
|
187
|
-
* được từ startForegroundService(), kể cả nhánh dừng ngay: nếu không,
|
|
188
|
-
* Android giết cả app bằng ForegroundServiceDidNotStartInTimeException.
|
|
189
|
-
*/
|
|
190
|
-
private void render() {
|
|
191
|
-
Job shown = jobs.values().iterator().next();
|
|
192
|
-
int running = jobs.size();
|
|
193
|
-
|
|
194
|
-
String title = running > 1
|
|
195
|
-
? "Đang nén " + running + " video"
|
|
196
|
-
: "Đang nén video";
|
|
197
|
-
|
|
198
|
-
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
|
199
|
-
.setContentTitle(title)
|
|
200
|
-
.setContentText(shown.percent + "% - đang nén")
|
|
201
|
-
.setSmallIcon(resolveSmallIcon())
|
|
202
|
-
.setProgress(100, shown.percent, false)
|
|
203
|
-
.setOngoing(true)
|
|
204
|
-
// Không có nó, mỗi lần vẽ lại progress là một lần notification
|
|
205
|
-
// nhảy lên đầu danh sách và rung máy.
|
|
206
|
-
.setOnlyAlertOnce(true)
|
|
207
|
-
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
208
|
-
.setContentIntent(buildOpenAppIntent())
|
|
209
|
-
.addAction(0, "Huỷ", buildCancelIntent())
|
|
210
|
-
.build();
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
// Gọi thẳng Service.startForeground, KHÔNG qua ServiceCompat:
|
|
214
|
-
// ServiceCompat.Api34Impl AND type với một mask cứng các type có từ
|
|
215
|
-
// Android 14 (FOREGROUND_SERVICE_TYPE_ALLOWED_SINCE_U), mà
|
|
216
|
-
// mediaProcessing thì mới có ở Android 15. Qua nó, 8192 & mask = 0
|
|
217
|
-
// và hệ thống ném InvalidForegroundServiceTypeException "type none".
|
|
218
|
-
// Đo trên emulator API 36: startForegroundCount=0, service không bao
|
|
219
|
-
// giờ vào foreground.
|
|
220
|
-
startForeground(
|
|
221
|
-
NOTIFICATION_ID, notification,
|
|
222
|
-
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
|
223
|
-
);
|
|
224
|
-
inForeground = true;
|
|
225
|
-
} catch (Exception e) {
|
|
226
|
-
// Nuốt để không giết app, nhưng log: service chạy nền âm thầm thì
|
|
227
|
-
// nén vẫn bị huỷ ngay khi user rời app và không ai biết tại sao.
|
|
228
|
-
Log.e(TAG, "startForeground thất bại - tiến trình có thể bị huỷ khi rời app", e);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Icon brand của app nếu có drawable `ic_stat_video_compress`, nếu không thì
|
|
234
|
-
* icon hệ thống. Notification mang icon hệ thống trông như của Android chứ
|
|
235
|
-
* không phải của app - trên video demo gửi Play, đó là một điểm trừ.
|
|
236
|
-
*/
|
|
237
|
-
private int resolveSmallIcon() {
|
|
238
|
-
int id = getResources().getIdentifier("ic_stat_video_compress", "drawable", getPackageName());
|
|
239
|
-
return id != 0 ? id : android.R.drawable.ic_popup_sync;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
private PendingIntent buildCancelIntent() {
|
|
243
|
-
Intent intent = new Intent(this, VideoCompressionService.class).setAction(ACTION_CANCEL);
|
|
244
|
-
return PendingIntent.getService(
|
|
245
|
-
this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Bấm vào notification thì quay lại app, chứ không phải không có gì xảy ra. */
|
|
250
|
-
@Nullable
|
|
251
|
-
private PendingIntent buildOpenAppIntent() {
|
|
252
|
-
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
|
253
|
-
if (launch == null) return null;
|
|
254
|
-
launch.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
|
255
|
-
return PendingIntent.getActivity(
|
|
256
|
-
this, 2, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private void armWatchdog() {
|
|
261
|
-
handler.removeCallbacks(watchdog);
|
|
262
|
-
handler.postDelayed(watchdog, WATCHDOG_MS);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
private void onWatchdogFired() {
|
|
266
|
-
Log.w(TAG, "Không có tiến trình nào trong " + (WATCHDOG_MS / 60000) + " phút - tự dừng");
|
|
267
|
-
NativeVideoCompressorPlugin.cancelAllFromNotification();
|
|
268
|
-
stopEverything();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Android 15+ giới hạn mediaProcessing tổng 6 giờ/24 giờ rồi gọi onTimeout. Phải
|
|
273
|
-
* dừng trong vài giây, nếu không app ăn ANR. destini-web đặt minSdk 35 nên
|
|
274
|
-
* mọi thiết bị đều ở trong diện này.
|
|
275
|
-
*/
|
|
276
|
-
@Override
|
|
277
|
-
public void onTimeout(int startId, int fgsType) {
|
|
278
|
-
Log.w(TAG, "Hệ thống hết hạn foreground service (type " + fgsType + ") - dừng ngay");
|
|
279
|
-
NativeVideoCompressorPlugin.cancelAllFromNotification();
|
|
280
|
-
stopEverything();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Overload của API 34. minSdk 35 nên hệ thống luôn gọi bản hai tham số ở
|
|
285
|
-
* trên; giữ lại vì bản mặc định của Service không làm gì, và một thiết bị
|
|
286
|
-
* gọi nhầm vào đây mà service không dừng thì app ăn ANR.
|
|
287
|
-
*/
|
|
288
|
-
@Override
|
|
289
|
-
public void onTimeout(int startId) {
|
|
290
|
-
Log.w(TAG, "Hệ thống hết hạn foreground service - dừng ngay");
|
|
291
|
-
NativeVideoCompressorPlugin.cancelAllFromNotification();
|
|
292
|
-
stopEverything();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
private void stopEverything() {
|
|
296
|
-
handler.removeCallbacks(watchdog);
|
|
297
|
-
jobs.clear();
|
|
298
|
-
if (inForeground) {
|
|
299
|
-
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
|
300
|
-
inForeground = false;
|
|
301
|
-
}
|
|
302
|
-
stopSelf();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
@Nullable
|
|
306
|
-
@Override
|
|
307
|
-
public IBinder onBind(Intent intent) {
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
}
|