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

View file

@ -6,8 +6,8 @@ import android.os.Handler
import android.os.Looper
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.PassThroughTrackStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@ -17,11 +17,6 @@ object VideoCompressionChannel {
// Compression parameters defined natively (as requested)
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) {
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)
if (method.name == "createOutputFormat" && result is MediaFormat) {
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
} as TrackStrategy
@ -61,13 +64,7 @@ object VideoCompressionChannel {
Transcoder.into(outputPath)
.addDataSource(inputPath)
.setVideoTrackStrategy(hevcStrategy)
.setAudioTrackStrategy(
DefaultAudioStrategy.builder()
.channels(AUDIO_CHANNELS)
.sampleRate(AUDIO_SAMPLE_RATE)
.bitRate(AUDIO_BITRATE)
.build()
)
.setAudioTrackStrategy(PassThroughTrackStrategy())
.setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
mainHandler.post {

View file

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

View file

@ -1760,5 +1760,5 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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;
}
Log.info('Uploading $receiptId');
final message = pb.Message.fromBuffer(receipt.message)
..receiptId = receiptId;
@ -110,6 +108,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
encryptedContent,
);
Log.info('Uploading $receiptId. (${pushNotification?.kind})');
Uint8List? pushData;
if (pushNotification != null && receipt.retryCount <= 1) {
// Only show the push notification the first two time.

View file

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

View file

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

View file

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

View file

@ -4,11 +4,14 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hashlib/random.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/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/messages.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 {
const RetransmissionDataView({super.key});
@ -49,6 +52,10 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
StreamSubscription<List<Contact>>? subscriptionContacts;
List<RetransMsg> messages = [];
Map<int, int> _contactCount = {};
int? _filterForUserId;
@override
void initState() {
super.initState();
@ -77,16 +84,39 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
subscriptionRetransmission = twonlyDB.receiptsDao.watchAll().listen((
updated,
) {
retransmissions = updated;
retransmissions = updated.reversed.toList();
if (contacts.isNotEmpty) {
messages = RetransMsg.fromRaw(retransmissions, contacts);
}
_contactCount = {};
for (final retransmission in updated) {
_contactCount[retransmission.contactId] =
(_contactCount[retransmission.contactId] ?? 0) + 1;
}
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
Widget build(BuildContext context) {
var messagesToShow = messages;
if (_filterForUserId != null) {
messagesToShow = messagesToShow
.where((m) => m.contact?.userId == _filterForUserId)
.toList();
}
return Scaffold(
appBar: AppBar(
title: const Text('Retransmission Database'),
@ -94,69 +124,106 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
body: Column(
children: [
Expanded(
child: ListView(
reverse: true,
children: messages
.map(
(retrans) => 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),
),
child: ListView.builder(
itemCount: 1 + messagesToShow.length + _contactCount.length,
itemBuilder: (context, index) {
if (index == 0) {
return Center(
child: FilledButton(
onPressed: _filterForUserId == null
? null
: deleteAllForSelectedUser,
child: const Text('Delete all shown entries'),
),
)
.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),
),
);
},
),
),
],