fixes android video compression issues

This commit is contained in:
otsmr 2026-04-10 19:15:51 +02:00
parent 6aab76a47e
commit 840dfed950
9 changed files with 162 additions and 86 deletions

View file

@ -4,7 +4,9 @@
- New: Typing and chat open indicator - New: Typing and chat open indicator
- New: Screen lock for twonly (Can be enabled in the settings.) - New: Screen lock for twonly (Can be enabled in the settings.)
- Fix: Several minor issues with the user interface - Improve: Visual indication when connected to the server
- Improve: Several minor issues with the user interface
- Fix: Poor audio quality and edge distortions in videos sent from Android
## 0.1.3 ## 0.1.3

View file

@ -6,8 +6,8 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import com.otaliastudios.transcoder.Transcoder import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy import com.otaliastudios.transcoder.strategy.TrackStrategy
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@ -17,11 +17,6 @@ object VideoCompressionChannel {
// Compression parameters defined natively (as requested) // Compression parameters defined natively (as requested)
private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps
// Audio parameters defined natively
private const val AUDIO_BITRATE = 128_000L // 128 kbps
private const val AUDIO_SAMPLE_RATE = 44_100
private const val AUDIO_CHANNELS = 2
fun configure(flutterEngine: FlutterEngine, context: Context) { fun configure(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
@ -54,6 +49,14 @@ object VideoCompressionChannel {
val result = if (args != null) method.invoke(baseVideoStrategy, *args) else method.invoke(baseVideoStrategy) val result = if (args != null) method.invoke(baseVideoStrategy, *args) else method.invoke(baseVideoStrategy)
if (method.name == "createOutputFormat" && result is MediaFormat) { if (method.name == "createOutputFormat" && result is MediaFormat) {
result.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC) result.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC)
if (result.containsKey(MediaFormat.KEY_WIDTH) && result.containsKey(MediaFormat.KEY_HEIGHT)) {
val width = result.getInteger(MediaFormat.KEY_WIDTH)
val height = result.getInteger(MediaFormat.KEY_HEIGHT)
// Align dimensions to a multiple of 16 to prevent edge artifacts (green lines/distortions)
result.setInteger(MediaFormat.KEY_WIDTH, width - (width % 16))
result.setInteger(MediaFormat.KEY_HEIGHT, height - (height % 16))
}
} }
result result
} as TrackStrategy } as TrackStrategy
@ -61,13 +64,7 @@ object VideoCompressionChannel {
Transcoder.into(outputPath) Transcoder.into(outputPath)
.addDataSource(inputPath) .addDataSource(inputPath)
.setVideoTrackStrategy(hevcStrategy) .setVideoTrackStrategy(hevcStrategy)
.setAudioTrackStrategy( .setAudioTrackStrategy(PassThroughTrackStrategy())
DefaultAudioStrategy.builder()
.channels(AUDIO_CHANNELS)
.sampleRate(AUDIO_SAMPLE_RATE)
.bitRate(AUDIO_BITRATE)
.build()
)
.setListener(object : TranscoderListener { .setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) { override fun onTranscodeProgress(progress: Double) {
mainHandler.post { mainHandler.post {

View file

@ -54,6 +54,13 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
.go(); .go();
} }
Future<void> deleteReceiptForUser(int contactId) async {
await (delete(receipts)..where(
(t) => t.contactId.equals(contactId),
))
.go();
}
Future<void> purgeReceivedReceipts() async { Future<void> purgeReceivedReceipts() async {
await (delete(receivedReceipts)..where( await (delete(receivedReceipts)..where(
(t) => (t.createdAt.isSmallerThanValue( (t) => (t.createdAt.isSmallerThanValue(

View file

@ -1760,5 +1760,5 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsTypingIndicationSubtitle => String get settingsTypingIndicationSubtitle =>
'Bei deaktiviertem Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.'; 'Bei deaktivierten Tipp-Indikatoren kannst du nicht sehen, wenn andere gerade eine Nachricht tippen.';
} }

View file

@ -95,8 +95,6 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
return null; return null;
} }
Log.info('Uploading $receiptId');
final message = pb.Message.fromBuffer(receipt.message) final message = pb.Message.fromBuffer(receipt.message)
..receiptId = receiptId; ..receiptId = receiptId;
@ -110,6 +108,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
encryptedContent, encryptedContent,
); );
Log.info('Uploading $receiptId. (${pushNotification?.kind})');
Uint8List? pushData; Uint8List? pushData;
if (pushNotification != null && receipt.retryCount <= 1) { if (pushNotification != null && receipt.retryCount <= 1) {
// Only show the push notification the first two time. // Only show the push notification the first two time.

View file

@ -124,7 +124,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
if (gUser.typingIndicators) { if (gUser.typingIndicators) {
unawaited(sendTypingIndication(widget.groupId, false)); unawaited(sendTypingIndication(widget.groupId, false));
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 8), ( _nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), (
_, _,
) async { ) async {
await sendTypingIndication(widget.groupId, false); await sendTypingIndication(widget.groupId, false);

View file

@ -73,7 +73,7 @@ class _MessageInputState extends State<MessageInput> {
} }
widget.textFieldFocus.addListener(_handleTextFocusChange); widget.textFieldFocus.addListener(_handleTextFocusChange);
if (gUser.typingIndicators) { if (gUser.typingIndicators) {
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), ( _nextTypingIndicator = Timer.periodic(const Duration(seconds: 1), (
_, _,
) async { ) async {
if (widget.textFieldFocus.hasFocus) { if (widget.textFieldFocus.hasFocus) {

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -94,22 +95,24 @@ class _TypingIndicatorState extends State<TypingIndicator>
bool isTyping(GroupMember member) { bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null && return member.lastTypeIndicator != null &&
DateTime.now() clock
.now()
.difference( .difference(
member.lastTypeIndicator!, member.lastTypeIndicator!,
) )
.inSeconds <= .inSeconds <=
12; 2;
} }
bool hasChatOpen(GroupMember member) { bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null && return member.lastChatOpened != null &&
DateTime.now() clock
.now()
.difference( .difference(
member.lastChatOpened!, member.lastChatOpened!,
) )
.inSeconds <= .inSeconds <=
12; 8;
} }
@override @override

View file

@ -4,11 +4,14 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hashlib/random.dart'; import 'package:hashlib/random.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb; as pb;
import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
class RetransmissionDataView extends StatefulWidget { class RetransmissionDataView extends StatefulWidget {
const RetransmissionDataView({super.key}); const RetransmissionDataView({super.key});
@ -49,6 +52,10 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
StreamSubscription<List<Contact>>? subscriptionContacts; StreamSubscription<List<Contact>>? subscriptionContacts;
List<RetransMsg> messages = []; List<RetransMsg> messages = [];
Map<int, int> _contactCount = {};
int? _filterForUserId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -77,16 +84,39 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
subscriptionRetransmission = twonlyDB.receiptsDao.watchAll().listen(( subscriptionRetransmission = twonlyDB.receiptsDao.watchAll().listen((
updated, updated,
) { ) {
retransmissions = updated; retransmissions = updated.reversed.toList();
if (contacts.isNotEmpty) { if (contacts.isNotEmpty) {
messages = RetransMsg.fromRaw(retransmissions, contacts); messages = RetransMsg.fromRaw(retransmissions, contacts);
} }
_contactCount = {};
for (final retransmission in updated) {
_contactCount[retransmission.contactId] =
(_contactCount[retransmission.contactId] ?? 0) + 1;
}
setState(() {}); setState(() {});
}); });
} }
Future<void> deleteAllForSelectedUser() async {
final ok = await showAlertDialog(
context,
'Sure?',
'This will delete all retransmission messages for ${contacts[_filterForUserId!]!.username}',
);
if (ok) {
await twonlyDB.receiptsDao.deleteReceiptForUser(_filterForUserId!);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var messagesToShow = messages;
if (_filterForUserId != null) {
messagesToShow = messagesToShow
.where((m) => m.contact?.userId == _filterForUserId)
.toList();
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Retransmission Database'), title: const Text('Retransmission Database'),
@ -94,69 +124,106 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: ListView.builder(
reverse: true, itemCount: 1 + messagesToShow.length + _contactCount.length,
children: messages itemBuilder: (context, index) {
.map( if (index == 0) {
(retrans) => ListTile( return Center(
title: Text( child: FilledButton(
retrans.receipt.receiptId, onPressed: _filterForUserId == null
), ? null
subtitle: Column( : deleteAllForSelectedUser,
crossAxisAlignment: CrossAxisAlignment.start, child: const Text('Delete all shown entries'),
children: [
Text(
'To ${retrans.contact?.username}',
),
Text(
'Server-Ack: ${retrans.receipt.ackByServerAt}',
),
if (retrans.receipt.messageId != null)
Text(
'MessageId: ${retrans.receipt.messageId}',
),
if (retrans.receipt.messageId != null)
FutureBuilder(
future: getPushNotificationFromEncryptedContent(
retrans.receipt.contactId,
retrans.receipt.messageId,
pb.EncryptedContent.fromBuffer(
pb.Message.fromBuffer(
retrans.receipt.message,
).encryptedContent,
),
),
builder: (d, a) {
if (!a.hasData) return Container();
return Text(
'PushKind: ${a.data?.kind}',
);
},
),
Text(
'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}',
),
],
),
trailing: FilledButton.icon(
onPressed: () async {
final newReceiptId = uuid.v4();
await twonlyDB.receiptsDao.updateReceipt(
retrans.receipt.receiptId,
ReceiptsCompanion(
receiptId: Value(newReceiptId),
ackByServerAt: const Value(null),
),
);
await tryToSendCompleteMessage(
receiptId: newReceiptId,
);
},
label: const FaIcon(FontAwesomeIcons.arrowRotateRight),
),
), ),
) );
.toList(), }
index -= 1;
if (index < _contactCount.length) {
final contact = contacts[_contactCount.keys.elementAt(index)];
if (contact == null) return Container();
return ListTile(
leading: AvatarIcon(
contactId: contact.userId,
),
title: Text(
getContactDisplayName(contact),
),
trailing: Text(
_contactCount.values.elementAt(index).toString(),
),
onTap: () {
if (_filterForUserId == contact.userId) {
setState(() {
_filterForUserId = null;
});
} else {
setState(() {
_filterForUserId = contact.userId;
});
}
},
);
}
index -= _contactCount.length;
final retrans = messagesToShow[index];
return ListTile(
title: Text(
retrans.receipt.receiptId,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'To ${retrans.contact?.username}',
),
Text(
'Server-Ack: ${retrans.receipt.ackByServerAt}',
),
if (retrans.receipt.messageId != null)
Text(
'MessageId: ${retrans.receipt.messageId}',
),
if (retrans.receipt.messageId != null)
FutureBuilder(
future: getPushNotificationFromEncryptedContent(
retrans.receipt.contactId,
retrans.receipt.messageId,
pb.EncryptedContent.fromBuffer(
pb.Message.fromBuffer(
retrans.receipt.message,
).encryptedContent,
),
),
builder: (d, a) {
if (!a.hasData) return Container();
return Text(
'PushKind: ${a.data?.kind}',
);
},
),
Text(
'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}',
),
],
),
trailing: FilledButton.icon(
onPressed: () async {
final newReceiptId = uuid.v4();
await twonlyDB.receiptsDao.updateReceipt(
retrans.receipt.receiptId,
ReceiptsCompanion(
receiptId: Value(newReceiptId),
ackByServerAt: const Value(null),
),
);
await tryToSendCompleteMessage(
receiptId: newReceiptId,
);
},
label: const FaIcon(FontAwesomeIcons.arrowRotateRight),
),
);
},
), ),
), ),
], ],