Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2024-10-11 21:51:14 +04:00
commit 26f765c269
19 changed files with 5428 additions and 29687 deletions

View File

@ -332,7 +332,7 @@ private final class ChunkMediaPlayerContext {
audioRendererContext.start()
self.tick()
let tickTimer = SwiftSignalKit.Timer(timeout: 1.0 / 60.0, repeat: true, completion: { [weak self] in
let tickTimer = SwiftSignalKit.Timer(timeout: 1.0 / 25.0, repeat: true, completion: { [weak self] in
self?.tick()
}, queue: self.queue)
self.tickTimer = tickTimer
@ -608,15 +608,31 @@ private final class ChunkMediaPlayerContext {
}
}
/*if validParts.isEmpty, let initialSeekTimestamp = self.initialSeekTimestamp {
if validParts.isEmpty, let initialSeekTimestamp = self.initialSeekTimestamp {
for part in self.partsState.parts {
if initialSeekTimestamp >= part.startTime - 0.2 && initialSeekTimestamp < part.endTime {
self.initialSeekTimestamp = nil
self.seek(timestamp: part.startTime + 0.05)
self.videoRenderer.flush()
if let audioRenderer = self.audioRenderer {
self.isSeeking = true
let queue = self.queue
audioRenderer.renderer.flushBuffers(at: CMTime(seconds: part.startTime + 0.1, preferredTimescale: 44100), completion: { [weak self] in
queue.async {
guard let self else {
return
}
self.isSeeking = false
self.tick()
}
})
}
return
}
}
}*/
}
self.loadedState.partStates.removeAll(where: { partState in
if !validParts.contains(where: { $0.id == partState.part.id }) {

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Developement</title>
<meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body>
<script src="runtime.bundle.js"></script><script src="index.bundle.js"></script></body>
</html>
<!doctype html><html><head><meta charset="utf-8"><title>Production</title><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><script src="runtime.bundle.js"></script><script src="index.bundle.js"></script></body></html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
# Node modules
node_modules/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Webpack and build artifacts
/dist
/build
# Environment files
.env
.env.local
.env.*.local
# OS generated
.DS_Store
Thumbs.db

View File

@ -0,0 +1,8 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
]
}

View File

@ -0,0 +1,6 @@
#!/bin/sh
rm -rf ../HlsBundle
mkdir ../HlsBundle
npm run build-$1
cp ./dist/* ../HlsBundle/

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "myhls",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build-development": "webpack --config webpack.dev.js",
"build-release": "webpack --config webpack.prod.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"expose-loader": "^5.0.0",
"express": "^4.18.2",
"html-webpack-plugin": "^5.5.3",
"style-loader": "^3.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-dev-server": "^4.15.1",
"webpack-merge": "^6.0.1"
},
"dependencies": {
"hls.js": "^1.5.15"
}
}

View File

@ -0,0 +1,20 @@
const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);
// Tell express to use the webpack-dev-middleware and use the webpack.config.js
// configuration file as a base.
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
})
);
// Serve the files on port 3000.
app.listen(3000, function () {
console.log('Example app listening on port 3000!\n');
});

View File

@ -0,0 +1,210 @@
import { TimeRangesStub } from "./TimeRangesStub.js"
function bytesToBase64(bytes) {
const binString = Array.from(bytes, (byte) =>
String.fromCodePoint(byte),
).join("");
return btoa(binString);
}
export class SourceBufferListStub extends EventTarget {
constructor() {
super();
this._buffers = [];
}
_add(buffer) {
this._buffers.push(buffer);
this.dispatchEvent(new Event('addsourcebuffer'));
}
_remove(buffer) {
const index = this._buffers.indexOf(buffer);
if (index === -1) {
return false;
}
this._buffers.splice(index, 1);
this.dispatchEvent(new Event('removesourcebuffer'));
return true;
}
get length() {
return this._buffers.length;
}
item(index) {
return this._buffers[index];
}
[Symbol.iterator]() {
return this._buffers[Symbol.iterator]();
}
}
export class SourceBufferStub extends EventTarget {
constructor(mediaSource, mimeType) {
super();
this.mediaSource = mediaSource;
this.mimeType = mimeType;
this.updating = false;
this.buffered = new TimeRangesStub();
this.timestampOffset = 0;
this.appendWindowStart = 0;
this.appendWindowEnd = Infinity;
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "constructor", {
"mimeType": mimeType
});
}
appendBuffer(data) {
if (this.updating) {
throw new DOMException('SourceBuffer is updating', 'InvalidStateError');
}
this.updating = true;
this.dispatchEvent(new Event('updatestart'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "appendBuffer", {
"data": bytesToBase64(data)
}).then((result) => {
const updatedRanges = result["ranges"];
var ranges = [];
for (var i = 0; i < updatedRanges.length; i += 2) {
ranges.push({
start: updatedRanges[i],
end: updatedRanges[i + 1]
});
}
this.buffered._ranges = ranges;
this.mediaSource._reopen();
this.updating = false;
this.dispatchEvent(new Event('update'));
this.dispatchEvent(new Event('updateend'));
});
}
abort() {
if (this.updating) {
this.updating = false;
this.dispatchEvent(new Event('abort'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "abort", {}).then((result) => {
});
}
}
remove(start, end) {
if (this.updating) {
throw new DOMException('SourceBuffer is updating', 'InvalidStateError');
}
this.updating = true;
this.dispatchEvent(new Event('updatestart'));
window.bridgeInvokeAsync(this.bridgeId, "SourceBuffer", "remove", {
"start": start,
"end": end
}).then((result) => {
const updatedRanges = result["ranges"];
var ranges = [];
for (var i = 0; i < updatedRanges.length; i += 2) {
ranges.push({
start: updatedRanges[i],
end: updatedRanges[i + 1]
});
}
this.buffered._ranges = ranges;
this.mediaSource._reopen();
this.updating = false;
this.dispatchEvent(new Event('update'));
this.dispatchEvent(new Event('updateend'));
});
}
}
export class MediaSourceStub extends EventTarget {
constructor() {
super();
this.internalId = window.nextInternalId;
window.nextInternalId += 1;
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
this.sourceBuffers = new SourceBufferListStub();
this.activeSourceBuffers = new SourceBufferListStub();
this.readyState = 'closed';
this._duration = NaN;
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "constructor", {
});
// Simulate asynchronous opening of MediaSource
setTimeout(() => {
this.readyState = 'open';
this.dispatchEvent(new Event('sourceopen'));
}, 0);
}
static isTypeSupported(mimeType) {
// Assume all MIME types are supported in this stub
return true;
}
addSourceBuffer(mimeType) {
if (this.readyState !== 'open') {
throw new DOMException('MediaSource is not open', 'InvalidStateError');
}
const sourceBuffer = new SourceBufferStub(this, mimeType);
this.sourceBuffers._add(sourceBuffer);
this.activeSourceBuffers._add(sourceBuffer);
return sourceBuffer;
}
removeSourceBuffer(sourceBuffer) {
if (!this.sourceBuffers._remove(sourceBuffer)) {
throw new DOMException('SourceBuffer not found', 'NotFoundError');
}
this.activeSourceBuffers._remove(sourceBuffer);
}
endOfStream(error) {
if (this.readyState !== 'open') {
throw new DOMException('MediaSource is not open', 'InvalidStateError');
}
this.readyState = 'ended';
this.dispatchEvent(new Event('sourceended'));
}
_reopen() {
if (this.readyState !== 'open') {
this.readyState = 'open';
this.dispatchEvent(new Event('sourceopen'));
}
}
set duration(value) {
if (this.readyState === 'closed') {
throw new DOMException('MediaSource is closed', 'InvalidStateError');
}
this._duration = value;
window.bridgeInvokeAsync(this.bridgeId, "MediaSource", "setDuration", {
"duration": value
}).then((result) => {
})
}
get duration() {
return this._duration;
}
}

View File

@ -0,0 +1,85 @@
export class TextTrackStub extends EventTarget {
constructor(kind = '', label = '', language = '') {
super();
this.kind = kind;
this.label = label;
this.language = language;
this.mode = 'disabled'; // 'disabled', 'hidden', or 'showing'
this.cues = new TextTrackCueListStub();
this.activeCues = new TextTrackCueListStub();
}
addCue(cue) {
this.cues._add(cue);
}
removeCue(cue) {
this.cues._remove(cue);
}
}
export class TextTrackCueListStub {
constructor() {
this._cues = [];
}
get length() {
return this._cues.length;
}
item(index) {
return this._cues[index];
}
getCueById(id) {
return this._cues.find(cue => cue.id === id) || null;
}
_add(cue) {
this._cues.push(cue);
}
_remove(cue) {
const index = this._cues.indexOf(cue);
if (index !== -1) {
this._cues.splice(index, 1);
}
}
[Symbol.iterator]() {
return this._cues[Symbol.iterator]();
}
}
export class TextTrackListStub extends EventTarget {
constructor() {
super();
this._tracks = [];
}
get length() {
return this._tracks.length;
}
item(index) {
return this._tracks[index];
}
_add(track) {
this._tracks.push(track);
this.dispatchEvent(new Event('addtrack'));
}
_remove(track) {
const index = this._tracks.indexOf(track);
if (index !== -1) {
this._tracks.splice(index, 1);
this.dispatchEvent(new Event('removetrack'));
}
}
[Symbol.iterator]() {
return this._tracks[Symbol.iterator]();
}
}

View File

@ -0,0 +1,74 @@
export class TimeRangesStub {
constructor() {
this._ranges = [];
}
get length() {
return this._ranges.length;
}
start(index) {
if (index < 0 || index >= this._ranges.length) {
throw new DOMException('Invalid index', 'IndexSizeError');
}
return this._ranges[index].start;
}
end(index) {
if (index < 0 || index >= this._ranges.length) {
throw new DOMException('Invalid index', 'IndexSizeError');
}
return this._ranges[index].end;
}
// Helper method to add a range
_addRange(start, end) {
this._ranges.push({ start, end });
this._normalizeRanges();
}
// Helper method to remove ranges that overlap with a given range
_removeRange(start, end) {
let updatedRanges = [];
for (let range of this._ranges) {
if (range.end <= start || range.start >= end) {
// No overlap, keep the range as is
updatedRanges.push(range);
} else if (range.start < start && range.end > end) {
// The range fully covers the removal range, split into two ranges
updatedRanges.push({ start: range.start, end: start });
updatedRanges.push({ start: end, end: range.end });
} else if (range.start >= start && range.end <= end) {
// The range is entirely within the removal range, remove it
// Do not add to updatedRanges
} else if (range.start < start && range.end > start && range.end <= end) {
// The range overlaps with the removal range on the left
updatedRanges.push({ start: range.start, end: start });
} else if (range.start >= start && range.start < end && range.end > end) {
// The range overlaps with the removal range on the right
updatedRanges.push({ start: end, end: range.end });
}
}
this._ranges = updatedRanges;
}
// Normalize and merge overlapping ranges
_normalizeRanges() {
this._ranges.sort((a, b) => a.start - b.start);
let normalized = [];
for (let range of this._ranges) {
if (normalized.length === 0) {
normalized.push(range);
} else {
let last = normalized[normalized.length - 1];
if (range.start <= last.end) {
last.end = Math.max(last.end, range.end);
} else {
normalized.push(range);
}
}
}
this._ranges = normalized;
}
}

View File

@ -0,0 +1,173 @@
import { TimeRangesStub } from "./TimeRangesStub.js"
import { TextTrackStub, TextTrackListStub } from "./TextTrackStub.js"
export class VideoElementStub extends EventTarget {
constructor() {
super();
this.bridgeId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeObjectMap[this.bridgeId] = this;
this._currentTime = 0.0;
this.duration = NaN;
this.paused = true;
this.playbackRate = 1.0;
this.volume = 1.0;
this.muted = false;
this.readyState = 0;
this.networkState = 0;
this.buffered = new TimeRangesStub();
this.seeking = false;
this.loop = false;
this.autoplay = false;
this.controls = false;
this.error = null;
this.src = '';
this.videoWidth = 0;
this.videoHeight = 0;
this.textTracks = new TextTrackListStub();
this.isWaiting = false;
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "constructor", {
});
setTimeout(() => {
this.readyState = 4; // HAVE_ENOUGH_DATA
this.dispatchEvent(new Event('loadedmetadata'));
this.dispatchEvent(new Event('loadeddata'));
this.dispatchEvent(new Event('canplay'));
this.dispatchEvent(new Event('canplaythrough'));
}, 0);
}
get currentTime() {
return this._currentTime;
}
set currentTime(value) {
if (this._currentTime != value) {
this._currentTime = value;
this.dispatchEvent(new Event('seeking'));
window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "setCurrentTime", {
"currentTime": value
}).then((result) => {
this.dispatchEvent(new Event('seeked'));
})
}
}
bridgeUpdateBuffered(value) {
const updatedRanges = value;
var ranges = [];
for (var i = 0; i < updatedRanges.length; i += 2) {
ranges.push({
start: updatedRanges[i],
end: updatedRanges[i + 1]
});
}
this.buffered._ranges = ranges;
}
bridgeUpdateStatus(dict) {
var paused = !dict["isPlaying"];
var isWaiting = dict["isWaiting"];
var currentTime = dict["currentTime"];
if (this.paused != paused) {
this.paused = paused;
if (paused) {
this.dispatchEvent(new Event('pause'));
} else {
this.dispatchEvent(new Event('play'));
this.dispatchEvent(new Event('playing'));
}
}
if (this.isWaiting != isWaiting) {
this.isWaiting = isWaiting;
if (isWaiting) {
this.dispatchEvent(new Event('waiting'));
}
}
if (this._currentTime != currentTime) {
this._currentTime = currentTime;
this.dispatchEvent(new Event('timeupdate'));
}
}
play() {
if (this.paused) {
return window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "play", {
}).then((result) => {
this.dispatchEvent(new Event('play'));
this.dispatchEvent(new Event('playing'));
})
} else {
return Promise.resolve();
}
}
pause() {
if (!this.paused) {
this.paused = true;
this.dispatchEvent(new Event('pause'));
return window.bridgeInvokeAsync(this.bridgeId, "VideoElement", "pause", {
}).then((result) => {
})
}
}
canPlayType(type) {
return 'probably';
}
_getMedia() {
return window.mediaSourceMap[this.src];
}
/*_simulateTimeUpdate() {
if (this._isPlaying) {
// Simulate time progression
setTimeout(() => {
var bufferedEnd = 0.0;
const media = this._getMedia();
if (media) {
if (media.sourceBuffers.length != 0) {
this.buffered = media.sourceBuffers._buffers[0].buffered;
bufferedEnd = this.buffered.length == 0 ? 0 : this.buffered.end(this.buffered.length - 1);
}
}
// Consume buffered data
if (this.currentTime < bufferedEnd) {
// Advance currentTime
this._currentTime += 0.1 * this.playbackRate; // Increment currentTime
this.dispatchEvent(new Event('timeupdate'));
// Continue simulation
this._simulateTimeUpdate();
} else {
console.log("Buffer underrun");
// Buffer underrun
this._isPlaying = false;
this.paused = true;
this.dispatchEvent(new Event('waiting'));
// The player should react by buffering more data
}
}, 100);
}
}*/
addTextTrack(kind, label, language) {
const textTrack = new TextTrackStub(kind, label, language);
this.textTracks._add(textTrack);
return textTrack;
}
}

View File

@ -0,0 +1,234 @@
import Hls from "hls.js";
import { VideoElementStub } from "./VideoElementStub.js"
import { MediaSourceStub, SourceBufferStub } from "./MediaSourceStub.js"
window.bridgeObjectMap = {};
window.bridgeCallbackMap = {};
function bridgeInvokeAsync(bridgeId, className, methodName, params) {
var promiseResolve;
var promiseReject;
var result = new Promise(function(resolve, reject) {
promiseResolve = resolve;
promiseReject = reject;
});
const callbackId = window.nextInternalId;
window.nextInternalId += 1;
window.bridgeCallbackMap[callbackId] = promiseResolve;
if (window.webkit.messageHandlers) {
window.webkit.messageHandlers.performAction.postMessage({
'event': 'bridgeInvoke',
'data': {
'bridgeId': bridgeId,
'className': className,
'methodName': methodName,
'params': params,
'callbackId': callbackId
}
});
}
return result;
}
window.bridgeInvokeAsync = bridgeInvokeAsync
export function bridgeInvokeCallback(callbackId, result) {
const callback = window.bridgeCallbackMap[callbackId];
if (callback) {
callback(result);
}
}
var useStubs = true;
window.nextInternalId = 0;
window.mediaSourceMap = {};
// Replace the global MediaSource with our stub
if (useStubs && typeof window !== 'undefined') {
window.MediaSource = MediaSourceStub;
window.ManagedMediaSource = MediaSourceStub;
window.SourceBuffer = SourceBufferStub;
URL.createObjectURL = function(ms) {
const url = "blob:mock-media-source:" + ms.internalId;
window.mediaSourceMap[url] = ms;
return url;
};
}
function postPlayerEvent(eventName, eventData) {
if (window.webkit && window.webkit.messageHandlers) {
window.webkit.messageHandlers.performAction.postMessage({'event': eventName, 'data': eventData});
}
};
var video;
var hls;
var isManifestParsed = false;
var isFirstFrameReady = false;
var currentTimeUpdateTimeout = null;
export function playerInitialize(params) {
video.muted = false;
video.addEventListener('loadeddata', (event) => {
if (!isFirstFrameReady) {
isFirstFrameReady = true;
refreshPlayerStatus();
}
});
video.addEventListener("playing", function() {
refreshPlayerStatus();
});
video.addEventListener("pause", function() {
refreshPlayerStatus();
});
video.addEventListener("seeking", function() {
refreshPlayerStatus();
});
video.addEventListener("waiting", function() {
refreshPlayerStatus();
});
hls = new Hls({
startLevel: 0,
testBandwidth: false,
debug: params['debug'] || true,
autoStartLoad: false,
backBufferLength: 30,
maxBufferLength: 60,
maxMaxBufferLength: 60,
maxFragLookUpTolerance: 0.001,
nudgeMaxRetry: 10000
});
hls.on(Hls.Events.MANIFEST_PARSED, function() {
isManifestParsed = true;
refreshPlayerStatus();
});
hls.on(Hls.Events.LEVEL_SWITCHED, function() {
refreshPlayerStatus();
});
hls.on(Hls.Events.LEVELS_UPDATED, function() {
refreshPlayerStatus();
});
hls.loadSource('master.m3u8');
hls.attachMedia(video);
}
export function playerLoad(initialLevelIndex) {
hls.startLevel = initialLevelIndex;
hls.startLoad(-1, false);
}
export function playerPlay() {
video.play();
}
export function playerPause() {
video.pause();
}
export function playerSetBaseRate(value) {
video.playbackRate = value;
}
export function playerSetLevel(level) {
if (level >= 0) {
hls.currentLevel = level;
} else {
hls.currentLevel = -1;
}
}
export function playerSeek(value) {
video.currentTime = value;
}
export function playerSetIsMuted(value) {
video.muted = value;
}
function getLevels() {
var levels = [];
for (var i = 0; i < hls.levels.length; i++) {
var level = hls.levels[i];
levels.push({
'index': i,
'bitrate': level.bitrate || 0,
'width': level.width || 0,
'height': level.height || 0
});
}
return levels;
}
function refreshPlayerStatus() {
var isPlaying = false;
if (!video.paused && !video.ended && video.readyState > 2) {
isPlaying = true;
}
postPlayerEvent('playerStatus', {
'isReady': isManifestParsed,
'isFirstFrameReady': isFirstFrameReady,
'isPlaying': !video.paused,
'rate': isPlaying ? video.playbackRate : 0.0,
'defaultRate': video.playbackRate,
'levels': getLevels(),
'currentLevel': hls.currentLevel
});
refreshPlayerCurrentTime();
if (isPlaying) {
if (currentTimeUpdateTimeout == null) {
currentTimeUpdateTimeout = setTimeout(() => {
refreshPlayerCurrentTime();
}, 200);
}
} else {
if(currentTimeUpdateTimeout != null){
clearTimeout(currentTimeUpdateTimeout);
currentTimeUpdateTimeout = null;
}
}
}
function refreshPlayerCurrentTime() {
postPlayerEvent('playerCurrentTime', {
'value': video.currentTime
});
currentTimeUpdateTimeout = setTimeout(() => {
refreshPlayerCurrentTime()
}, 200);
}
window.onload = () => {
if (useStubs) {
video = new VideoElementStub();
} else {
video = document.createElement('video');
video.playsInline = true;
video.controls = true;
document.body.appendChild(video);
}
postPlayerEvent('windowOnLoad', {
});
};
window.playerInitialize = playerInitialize;
window.playerLoad = playerLoad;
window.playerPlay = playerPlay;
window.playerPause = playerPause;
window.playerSetBaseRate = playerSetBaseRate;
window.playerSetLevel = playerSetLevel;
window.playerSeek = playerSeek;
window.playerSetIsMuted = playerSetIsMuted;
window.bridgeInvokeCallback = bridgeInvokeCallback;

View File

@ -0,0 +1,15 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
video {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
object-fit: fill;
}

View File

@ -0,0 +1,35 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
},
plugins: [
new HtmlWebpackPlugin({
title: 'Production',
scriptLoading: 'blocking',
})
],
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
publicPath: '',
},
module: {
rules: [
{
test: /\.js$/,
include: path.resolve(__dirname, 'src/index.js'),
},
{
test: /\.css$/i,
use: [
'style-loader',
'css-loader'
],
},
],
},
};

View File

@ -0,0 +1,10 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
devtool: 'source-map',
devServer: {
static: './dist',
},
});

View File

@ -0,0 +1,16 @@
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
terserOptions: {
compress: true,
},
})],
runtimeChunk: 'single',
},
});