Improved: Media thumbnails for faster loading

This commit is contained in:
otsmr 2026-05-16 16:21:44 +02:00
parent f3b64646f5
commit 5556532879
17 changed files with 14922 additions and 134 deletions

View file

@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.2.13
- Improved: Media thumbnails for faster loading
## 0.2.12 ## 0.2.12
- New: Automatically mark identical media as opened across all chats (Settings > Chats). - New: Automatically mark identical media as opened across all chats (Settings > Chats).

View file

@ -114,16 +114,15 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
.get(); .get();
} }
Future<List<MediaFile>> getAllNonHashedStoredMediaFiles() async { Future<List<MediaFile>> getAllMediaFilesPendingMigration() async {
return (select(mediaFiles)..where( return (select(mediaFiles)..where(
(t) => t.stored.equals(true) & t.storedFileHash.isNull(), (t) =>
)) t.stored.equals(true) &
.get(); (t.storedFileHash.isNull() |
} t.hasCropAnalyzed.equals(false) |
(t.hasThumbnail.equals(false) &
Future<List<MediaFile>> getAllUnanalyzedStoredMediaFiles() async { t.type.equals(MediaType.audio.name).not()) |
return (select(mediaFiles)..where( t.sizeInBytes.isNull()),
(t) => t.stored.equals(true) & t.hasCropAnalyzed.equals(false),
)) ))
.get(); .get();
} }

File diff suppressed because it is too large Load diff

View file

@ -69,6 +69,11 @@ class MediaFiles extends Table {
BlobColumn get storedFileHash => blob().nullable()(); BlobColumn get storedFileHash => blob().nullable()();
BoolColumn get hasThumbnail =>
boolean().withDefault(const Constant(false))();
IntColumn get sizeInBytes => integer().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get createdAtMonth => text().nullable()(); TextColumn get createdAtMonth => text().nullable()();

View file

@ -81,7 +81,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection); TwonlyDB.forTesting(DatabaseConnection super.connection);
@override @override
int get schemaVersion => 15; int get schemaVersion => 16;
static QueryExecutor _openConnection() { static QueryExecutor _openConnection() {
return driftDatabase( return driftDatabase(
@ -211,6 +211,13 @@ class TwonlyDB extends _$TwonlyDB {
from14To15: (m, schema) async { from14To15: (m, schema) async {
await m.createTable(schema.signalSignedPreKeyStores); await m.createTable(schema.signalSignedPreKeyStores);
}, },
from15To16: (m, schema) async {
await m.addColumn(
schema.mediaFiles,
schema.mediaFiles.hasThumbnail,
);
await m.addColumn(schema.mediaFiles, schema.mediaFiles.sizeInBytes);
},
)(m, from, to); )(m, from, to);
}, },
); );

View file

@ -2810,6 +2810,32 @@ class $MediaFilesTable extends MediaFiles
type: DriftSqlType.blob, type: DriftSqlType.blob,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _hasThumbnailMeta = const VerificationMeta(
'hasThumbnail',
);
@override
late final GeneratedColumn<bool> hasThumbnail = GeneratedColumn<bool>(
'has_thumbnail',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("has_thumbnail" IN (0, 1))',
),
defaultValue: const Constant(false),
);
static const VerificationMeta _sizeInBytesMeta = const VerificationMeta(
'sizeInBytes',
);
@override
late final GeneratedColumn<int> sizeInBytes = GeneratedColumn<int>(
'size_in_bytes',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta( static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt', 'createdAt',
); );
@ -2853,6 +2879,8 @@ class $MediaFilesTable extends MediaFiles
encryptionMac, encryptionMac,
encryptionNonce, encryptionNonce,
storedFileHash, storedFileHash,
hasThumbnail,
sizeInBytes,
createdAt, createdAt,
createdAtMonth, createdAtMonth,
]; ];
@ -2987,6 +3015,24 @@ class $MediaFilesTable extends MediaFiles
), ),
); );
} }
if (data.containsKey('has_thumbnail')) {
context.handle(
_hasThumbnailMeta,
hasThumbnail.isAcceptableOrUnknown(
data['has_thumbnail']!,
_hasThumbnailMeta,
),
);
}
if (data.containsKey('size_in_bytes')) {
context.handle(
_sizeInBytesMeta,
sizeInBytes.isAcceptableOrUnknown(
data['size_in_bytes']!,
_sizeInBytesMeta,
),
);
}
if (data.containsKey('created_at')) { if (data.containsKey('created_at')) {
context.handle( context.handle(
_createdAtMeta, _createdAtMeta,
@ -3092,6 +3138,14 @@ class $MediaFilesTable extends MediaFiles
DriftSqlType.blob, DriftSqlType.blob,
data['${effectivePrefix}stored_file_hash'], data['${effectivePrefix}stored_file_hash'],
), ),
hasThumbnail: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}has_thumbnail'],
)!,
sizeInBytes: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}size_in_bytes'],
),
createdAt: attachedDatabase.typeMapping.read( createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, DriftSqlType.dateTime,
data['${effectivePrefix}created_at'], data['${effectivePrefix}created_at'],
@ -3147,6 +3201,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
final Uint8List? encryptionMac; final Uint8List? encryptionMac;
final Uint8List? encryptionNonce; final Uint8List? encryptionNonce;
final Uint8List? storedFileHash; final Uint8List? storedFileHash;
final bool hasThumbnail;
final int? sizeInBytes;
final DateTime createdAt; final DateTime createdAt;
final String? createdAtMonth; final String? createdAtMonth;
const MediaFile({ const MediaFile({
@ -3168,6 +3224,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
this.encryptionMac, this.encryptionMac,
this.encryptionNonce, this.encryptionNonce,
this.storedFileHash, this.storedFileHash,
required this.hasThumbnail,
this.sizeInBytes,
required this.createdAt, required this.createdAt,
this.createdAtMonth, this.createdAtMonth,
}); });
@ -3228,6 +3286,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
if (!nullToAbsent || storedFileHash != null) { if (!nullToAbsent || storedFileHash != null) {
map['stored_file_hash'] = Variable<Uint8List>(storedFileHash); map['stored_file_hash'] = Variable<Uint8List>(storedFileHash);
} }
map['has_thumbnail'] = Variable<bool>(hasThumbnail);
if (!nullToAbsent || sizeInBytes != null) {
map['size_in_bytes'] = Variable<int>(sizeInBytes);
}
map['created_at'] = Variable<DateTime>(createdAt); map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || createdAtMonth != null) { if (!nullToAbsent || createdAtMonth != null) {
map['created_at_month'] = Variable<String>(createdAtMonth); map['created_at_month'] = Variable<String>(createdAtMonth);
@ -3278,6 +3340,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: storedFileHash == null && nullToAbsent storedFileHash: storedFileHash == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(storedFileHash), : Value(storedFileHash),
hasThumbnail: Value(hasThumbnail),
sizeInBytes: sizeInBytes == null && nullToAbsent
? const Value.absent()
: Value(sizeInBytes),
createdAt: Value(createdAt), createdAt: Value(createdAt),
createdAtMonth: createdAtMonth == null && nullToAbsent createdAtMonth: createdAtMonth == null && nullToAbsent
? const Value.absent() ? const Value.absent()
@ -3323,6 +3389,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']), encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']),
encryptionNonce: serializer.fromJson<Uint8List?>(json['encryptionNonce']), encryptionNonce: serializer.fromJson<Uint8List?>(json['encryptionNonce']),
storedFileHash: serializer.fromJson<Uint8List?>(json['storedFileHash']), storedFileHash: serializer.fromJson<Uint8List?>(json['storedFileHash']),
hasThumbnail: serializer.fromJson<bool>(json['hasThumbnail']),
sizeInBytes: serializer.fromJson<int?>(json['sizeInBytes']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']), createdAt: serializer.fromJson<DateTime>(json['createdAt']),
createdAtMonth: serializer.fromJson<String?>(json['createdAtMonth']), createdAtMonth: serializer.fromJson<String?>(json['createdAtMonth']),
); );
@ -3357,6 +3425,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac), 'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac),
'encryptionNonce': serializer.toJson<Uint8List?>(encryptionNonce), 'encryptionNonce': serializer.toJson<Uint8List?>(encryptionNonce),
'storedFileHash': serializer.toJson<Uint8List?>(storedFileHash), 'storedFileHash': serializer.toJson<Uint8List?>(storedFileHash),
'hasThumbnail': serializer.toJson<bool>(hasThumbnail),
'sizeInBytes': serializer.toJson<int?>(sizeInBytes),
'createdAt': serializer.toJson<DateTime>(createdAt), 'createdAt': serializer.toJson<DateTime>(createdAt),
'createdAtMonth': serializer.toJson<String?>(createdAtMonth), 'createdAtMonth': serializer.toJson<String?>(createdAtMonth),
}; };
@ -3381,6 +3451,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
Value<Uint8List?> encryptionMac = const Value.absent(), Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(), Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(), Value<Uint8List?> storedFileHash = const Value.absent(),
bool? hasThumbnail,
Value<int?> sizeInBytes = const Value.absent(),
DateTime? createdAt, DateTime? createdAt,
Value<String?> createdAtMonth = const Value.absent(), Value<String?> createdAtMonth = const Value.absent(),
}) => MediaFile( }) => MediaFile(
@ -3421,6 +3493,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: storedFileHash.present storedFileHash: storedFileHash.present
? storedFileHash.value ? storedFileHash.value
: this.storedFileHash, : this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes.present ? sizeInBytes.value : this.sizeInBytes,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth.present createdAtMonth: createdAtMonth.present
? createdAtMonth.value ? createdAtMonth.value
@ -3476,6 +3550,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: data.storedFileHash.present storedFileHash: data.storedFileHash.present
? data.storedFileHash.value ? data.storedFileHash.value
: this.storedFileHash, : this.storedFileHash,
hasThumbnail: data.hasThumbnail.present
? data.hasThumbnail.value
: this.hasThumbnail,
sizeInBytes: data.sizeInBytes.present
? data.sizeInBytes.value
: this.sizeInBytes,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
createdAtMonth: data.createdAtMonth.present createdAtMonth: data.createdAtMonth.present
? data.createdAtMonth.value ? data.createdAtMonth.value
@ -3504,6 +3584,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
..write('encryptionMac: $encryptionMac, ') ..write('encryptionMac: $encryptionMac, ')
..write('encryptionNonce: $encryptionNonce, ') ..write('encryptionNonce: $encryptionNonce, ')
..write('storedFileHash: $storedFileHash, ') ..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('createdAt: $createdAt, ') ..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth') ..write('createdAtMonth: $createdAtMonth')
..write(')')) ..write(')'))
@ -3511,7 +3593,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
} }
@override @override
int get hashCode => Object.hash( int get hashCode => Object.hashAll([
mediaId, mediaId,
type, type,
uploadState, uploadState,
@ -3530,9 +3612,11 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
$driftBlobEquality.hash(encryptionMac), $driftBlobEquality.hash(encryptionMac),
$driftBlobEquality.hash(encryptionNonce), $driftBlobEquality.hash(encryptionNonce),
$driftBlobEquality.hash(storedFileHash), $driftBlobEquality.hash(storedFileHash),
hasThumbnail,
sizeInBytes,
createdAt, createdAt,
createdAtMonth, createdAtMonth,
); ]);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
@ -3561,6 +3645,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
other.storedFileHash, other.storedFileHash,
this.storedFileHash, this.storedFileHash,
) && ) &&
other.hasThumbnail == this.hasThumbnail &&
other.sizeInBytes == this.sizeInBytes &&
other.createdAt == this.createdAt && other.createdAt == this.createdAt &&
other.createdAtMonth == this.createdAtMonth); other.createdAtMonth == this.createdAtMonth);
} }
@ -3584,6 +3670,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
final Value<Uint8List?> encryptionMac; final Value<Uint8List?> encryptionMac;
final Value<Uint8List?> encryptionNonce; final Value<Uint8List?> encryptionNonce;
final Value<Uint8List?> storedFileHash; final Value<Uint8List?> storedFileHash;
final Value<bool> hasThumbnail;
final Value<int?> sizeInBytes;
final Value<DateTime> createdAt; final Value<DateTime> createdAt;
final Value<String?> createdAtMonth; final Value<String?> createdAtMonth;
final Value<int> rowid; final Value<int> rowid;
@ -3606,6 +3694,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.encryptionMac = const Value.absent(), this.encryptionMac = const Value.absent(),
this.encryptionNonce = const Value.absent(), this.encryptionNonce = const Value.absent(),
this.storedFileHash = const Value.absent(), this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.createdAt = const Value.absent(), this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(), this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
@ -3629,6 +3719,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.encryptionMac = const Value.absent(), this.encryptionMac = const Value.absent(),
this.encryptionNonce = const Value.absent(), this.encryptionNonce = const Value.absent(),
this.storedFileHash = const Value.absent(), this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.createdAt = const Value.absent(), this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(), this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(), this.rowid = const Value.absent(),
@ -3653,6 +3745,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Expression<Uint8List>? encryptionMac, Expression<Uint8List>? encryptionMac,
Expression<Uint8List>? encryptionNonce, Expression<Uint8List>? encryptionNonce,
Expression<Uint8List>? storedFileHash, Expression<Uint8List>? storedFileHash,
Expression<bool>? hasThumbnail,
Expression<int>? sizeInBytes,
Expression<DateTime>? createdAt, Expression<DateTime>? createdAt,
Expression<String>? createdAtMonth, Expression<String>? createdAtMonth,
Expression<int>? rowid, Expression<int>? rowid,
@ -3680,6 +3774,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (encryptionMac != null) 'encryption_mac': encryptionMac, if (encryptionMac != null) 'encryption_mac': encryptionMac,
if (encryptionNonce != null) 'encryption_nonce': encryptionNonce, if (encryptionNonce != null) 'encryption_nonce': encryptionNonce,
if (storedFileHash != null) 'stored_file_hash': storedFileHash, if (storedFileHash != null) 'stored_file_hash': storedFileHash,
if (hasThumbnail != null) 'has_thumbnail': hasThumbnail,
if (sizeInBytes != null) 'size_in_bytes': sizeInBytes,
if (createdAt != null) 'created_at': createdAt, if (createdAt != null) 'created_at': createdAt,
if (createdAtMonth != null) 'created_at_month': createdAtMonth, if (createdAtMonth != null) 'created_at_month': createdAtMonth,
if (rowid != null) 'rowid': rowid, if (rowid != null) 'rowid': rowid,
@ -3705,6 +3801,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Value<Uint8List?>? encryptionMac, Value<Uint8List?>? encryptionMac,
Value<Uint8List?>? encryptionNonce, Value<Uint8List?>? encryptionNonce,
Value<Uint8List?>? storedFileHash, Value<Uint8List?>? storedFileHash,
Value<bool>? hasThumbnail,
Value<int?>? sizeInBytes,
Value<DateTime>? createdAt, Value<DateTime>? createdAt,
Value<String?>? createdAtMonth, Value<String?>? createdAtMonth,
Value<int>? rowid, Value<int>? rowid,
@ -3731,6 +3829,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
encryptionMac: encryptionMac ?? this.encryptionMac, encryptionMac: encryptionMac ?? this.encryptionMac,
encryptionNonce: encryptionNonce ?? this.encryptionNonce, encryptionNonce: encryptionNonce ?? this.encryptionNonce,
storedFileHash: storedFileHash ?? this.storedFileHash, storedFileHash: storedFileHash ?? this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes ?? this.sizeInBytes,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth ?? this.createdAtMonth, createdAtMonth: createdAtMonth ?? this.createdAtMonth,
rowid: rowid ?? this.rowid, rowid: rowid ?? this.rowid,
@ -3810,6 +3910,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (storedFileHash.present) { if (storedFileHash.present) {
map['stored_file_hash'] = Variable<Uint8List>(storedFileHash.value); map['stored_file_hash'] = Variable<Uint8List>(storedFileHash.value);
} }
if (hasThumbnail.present) {
map['has_thumbnail'] = Variable<bool>(hasThumbnail.value);
}
if (sizeInBytes.present) {
map['size_in_bytes'] = Variable<int>(sizeInBytes.value);
}
if (createdAt.present) { if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value); map['created_at'] = Variable<DateTime>(createdAt.value);
} }
@ -3843,6 +3949,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
..write('encryptionMac: $encryptionMac, ') ..write('encryptionMac: $encryptionMac, ')
..write('encryptionNonce: $encryptionNonce, ') ..write('encryptionNonce: $encryptionNonce, ')
..write('storedFileHash: $storedFileHash, ') ..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('createdAt: $createdAt, ') ..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth, ') ..write('createdAtMonth: $createdAtMonth, ')
..write('rowid: $rowid') ..write('rowid: $rowid')
@ -15344,6 +15452,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
Value<Uint8List?> encryptionMac, Value<Uint8List?> encryptionMac,
Value<Uint8List?> encryptionNonce, Value<Uint8List?> encryptionNonce,
Value<Uint8List?> storedFileHash, Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<DateTime> createdAt, Value<DateTime> createdAt,
Value<String?> createdAtMonth, Value<String?> createdAtMonth,
Value<int> rowid, Value<int> rowid,
@ -15368,6 +15478,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
Value<Uint8List?> encryptionMac, Value<Uint8List?> encryptionMac,
Value<Uint8List?> encryptionNonce, Value<Uint8List?> encryptionNonce,
Value<Uint8List?> storedFileHash, Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<DateTime> createdAt, Value<DateTime> createdAt,
Value<String?> createdAtMonth, Value<String?> createdAtMonth,
Value<int> rowid, Value<int> rowid,
@ -15499,6 +15611,16 @@ class $$MediaFilesTableFilterComposer
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder( ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, column: $table.createdAt,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@ -15634,6 +15756,16 @@ class $$MediaFilesTableOrderingComposer
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder( ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, column: $table.createdAt,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@ -15741,6 +15873,16 @@ class $$MediaFilesTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumn<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => column,
);
GeneratedColumn<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt => GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column); $composableBuilder(column: $table.createdAt, builder: (column) => column);
@ -15821,6 +15963,8 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> encryptionMac = const Value.absent(), Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(), Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(), Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(), Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
@ -15843,6 +15987,8 @@ class $$MediaFilesTableTableManager
encryptionMac: encryptionMac, encryptionMac: encryptionMac,
encryptionNonce: encryptionNonce, encryptionNonce: encryptionNonce,
storedFileHash: storedFileHash, storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
createdAt: createdAt, createdAt: createdAt,
createdAtMonth: createdAtMonth, createdAtMonth: createdAtMonth,
rowid: rowid, rowid: rowid,
@ -15867,6 +16013,8 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> encryptionMac = const Value.absent(), Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(), Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(), Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(), Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(), Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(), Value<int> rowid = const Value.absent(),
@ -15889,6 +16037,8 @@ class $$MediaFilesTableTableManager
encryptionMac: encryptionMac, encryptionMac: encryptionMac,
encryptionNonce: encryptionNonce, encryptionNonce: encryptionNonce,
storedFileHash: storedFileHash, storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
createdAt: createdAt, createdAt: createdAt,
createdAtMonth: createdAtMonth, createdAtMonth: createdAtMonth,
rowid: rowid, rowid: rowid,

View file

@ -8032,6 +8032,519 @@ i1.GeneratedColumn<i2.Uint8List> _column_243(String aliasedName) =>
type: i1.DriftSqlType.blob, type: i1.DriftSqlType.blob,
$customConstraints: 'NOT NULL', $customConstraints: 'NOT NULL',
); );
final class Schema16 extends i0.VersionedSchema {
Schema16({required super.database}) : super(version: 16);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalSignedPreKeyStores,
messageActions,
groupHistories,
keyVerifications,
verificationTokens,
userDiscoveryAnnouncedUsers,
userDiscoveryUserRelations,
userDiscoveryOtherPromotions,
userDiscoveryOwnPromotions,
userDiscoveryShares,
shortcuts,
shortcutMembers,
];
late final Shape39 contacts = Shape39(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(user_id)'],
columns: [
_column_106,
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_211,
_column_212,
_column_213,
_column_214,
_column_215,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 groups = Shape23(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id)'],
columns: [
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
_column_130,
_column_131,
_column_132,
_column_133,
_column_134,
_column_118,
_column_135,
_column_136,
_column_137,
_column_138,
_column_139,
_column_140,
_column_141,
_column_142,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 mediaFiles = Shape51(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(media_id)'],
columns: [
_column_143,
_column_144,
_column_145,
_column_146,
_column_147,
_column_148,
_column_149,
_column_239,
_column_240,
_column_207,
_column_150,
_column_151,
_column_152,
_column_153,
_column_154,
_column_155,
_column_156,
_column_157,
_column_244,
_column_245,
_column_118,
_column_241,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 messages = Shape25(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id)'],
columns: [
_column_158,
_column_159,
_column_160,
_column_144,
_column_161,
_column_162,
_column_163,
_column_164,
_column_165,
_column_153,
_column_166,
_column_167,
_column_168,
_column_169,
_column_118,
_column_170,
_column_171,
_column_172,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 messageHistories = Shape26(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_174,
_column_175,
_column_161,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 reactions = Shape27(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
columns: [_column_174, _column_176, _column_177, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 groupMembers = Shape38(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
columns: [
_column_158,
_column_178,
_column_179,
_column_180,
_column_209,
_column_210,
_column_181,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape37 receipts = Shape37(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
_column_208,
_column_187,
_column_188,
_column_189,
_column_190,
_column_191,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 receivedReceipts = Shape30(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [_column_182, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 signalIdentityKeyStores = Shape31(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_194, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 signalPreKeyStores = Shape32(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
columns: [_column_195, _column_196, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 signalSenderKeyStores = Shape11(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
columns: [_column_197, _column_198],
attachedDatabase: database,
),
alias: null,
);
late final Shape33 signalSessionStores = Shape33(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_199, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 signalSignedPreKeyStores = Shape50(
source: i0.VersionedTable(
entityName: 'signal_signed_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
columns: [_column_242, _column_243, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 messageActions = Shape34(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
columns: [_column_174, _column_183, _column_144, _column_200],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 groupHistories = Shape35(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_history_id)'],
columns: [
_column_201,
_column_158,
_column_202,
_column_203,
_column_204,
_column_205,
_column_206,
_column_144,
_column_200,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape40 keyVerifications = Shape40(
source: i0.VersionedTable(
entityName: 'key_verifications',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_216, _column_183, _column_144, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 verificationTokens = Shape41(
source: i0.VersionedTable(
entityName: 'verification_tokens',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_217, _column_218, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 userDiscoveryAnnouncedUsers = Shape42(
source: i0.VersionedTable(
entityName: 'user_discovery_announced_users',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
columns: [
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_224,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 userDiscoveryUserRelations = Shape43(
source: i0.VersionedTable(
entityName: 'user_discovery_user_relations',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
columns: [_column_225, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 userDiscoveryOtherPromotions = Shape44(
source: i0.VersionedTable(
entityName: 'user_discovery_other_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
columns: [
_column_226,
_column_228,
_column_229,
_column_230,
_column_231,
_column_227,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 userDiscoveryOwnPromotions = Shape45(
source: i0.VersionedTable(
entityName: 'user_discovery_own_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_232, _column_183, _column_233],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 userDiscoveryShares = Shape46(
source: i0.VersionedTable(
entityName: 'user_discovery_shares',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_234, _column_235, _column_175],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 shortcuts = Shape47(
source: i0.VersionedTable(
entityName: 'shortcuts',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_173, _column_236, _column_237],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 shortcutMembers = Shape48(
source: i0.VersionedTable(
entityName: 'shortcut_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
columns: [_column_238, _column_158],
attachedDatabase: database,
),
alias: null,
);
}
class Shape51 extends i0.VersionedTable {
Shape51({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get mediaId =>
columnsByName['media_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uploadState =>
columnsByName['upload_state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadState =>
columnsByName['download_state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get requiresAuthentication =>
columnsByName['requires_authentication']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get stored =>
columnsByName['stored']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isDraftMedia =>
columnsByName['is_draft_media']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get hasCropAnalyzed =>
columnsByName['has_crop_analyzed']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get preProgressingProcess =>
columnsByName['pre_progressing_process']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get reuploadRequestedBy =>
columnsByName['reupload_requested_by']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get displayLimitInMilliseconds =>
columnsByName['display_limit_in_milliseconds']!
as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get removeAudio =>
columnsByName['remove_audio']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get downloadToken =>
columnsByName['download_token']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionKey =>
columnsByName['encryption_key']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionMac =>
columnsByName['encryption_mac']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionNonce =>
columnsByName['encryption_nonce']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get storedFileHash =>
columnsByName['stored_file_hash']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get hasThumbnail =>
columnsByName['has_thumbnail']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get sizeInBytes =>
columnsByName['size_in_bytes']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAtMonth =>
columnsByName['created_at_month']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_244(String aliasedName) =>
i1.GeneratedColumn<int>(
'has_thumbnail',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 0 CHECK (has_thumbnail IN (0, 1))',
defaultValue: const i1.CustomExpression('0'),
);
i1.GeneratedColumn<int> _column_245(String aliasedName) =>
i1.GeneratedColumn<int>(
'size_in_bytes',
aliasedName,
true,
type: i1.DriftSqlType.int,
$customConstraints: 'NULL',
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -8047,6 +8560,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15, required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -8120,6 +8634,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema); await from14To15(migrator, schema);
return 15; return 15;
case 15:
final schema = Schema16(database: database);
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -8141,6 +8660,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13, required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14, required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15, required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@ -8157,5 +8677,6 @@ i1.OnUpgrade stepByStep({
from12To13: from12To13, from12To13: from12To13,
from13To14: from13To14, from13To14: from13To14,
from14To15: from14To15, from14To15: from14To15,
from15To16: from15To16,
), ),
); );

View file

@ -33,6 +33,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt() ..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
..requestedAudioPermission = ..requestedAudioPermission =
json['requestedAudioPermission'] as bool? ?? false json['requestedAudioPermission'] as bool? ?? false
..automaticallyMarkEqualMediaFilesAsOpened =
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
..videoStabilizationEnabled = ..videoStabilizationEnabled =
json['videoStabilizationEnabled'] as bool? ?? true json['videoStabilizationEnabled'] as bool? ?? true
..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true ..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true
@ -121,6 +123,8 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'defaultShowTime': instance.defaultShowTime, 'defaultShowTime': instance.defaultShowTime,
'requestedAudioPermission': instance.requestedAudioPermission, 'requestedAudioPermission': instance.requestedAudioPermission,
'automaticallyMarkEqualMediaFilesAsOpened':
instance.automaticallyMarkEqualMediaFilesAsOpened,
'videoStabilizationEnabled': instance.videoStabilizationEnabled, 'videoStabilizationEnabled': instance.videoStabilizationEnabled,
'showFeedbackShortcut': instance.showFeedbackShortcut, 'showFeedbackShortcut': instance.showFeedbackShortcut,
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending, 'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,

View file

@ -200,14 +200,24 @@ class MediaFileService {
Log.error('Could not create Thumbnail as stored media does not exists.'); Log.error('Could not create Thumbnail as stored media does not exists.');
return; return;
} }
var success = false;
switch (mediaFile.type) { switch (mediaFile.type) {
case MediaType.gif: case MediaType.gif:
case MediaType.audio: success = await createThumbnailsForGif(storedPath, thumbnailPath);
case MediaType.image: case MediaType.image:
// all images are already compress.. success = await createThumbnailsForImage(storedPath, thumbnailPath);
break;
case MediaType.video: case MediaType.video:
await createThumbnailsForVideo(storedPath, thumbnailPath); success = await createThumbnailsForVideo(storedPath, thumbnailPath);
case MediaType.audio:
break;
}
if (success) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
await updateFromDB();
} }
} }
@ -253,7 +263,9 @@ class MediaFileService {
tempPath.existsSync(); tempPath.existsSync();
bool get imagePreviewAvailable => bool get imagePreviewAvailable =>
thumbnailPath.existsSync() || storedPath.existsSync(); mediaFile.hasThumbnail ||
thumbnailPath.existsSync() ||
storedPath.existsSync();
Future<void> storeMediaFile() async { Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}'); Log.info('Storing media file ${mediaFile.mediaId}');
@ -284,10 +296,24 @@ class MediaFileService {
); );
} }
unawaited(createThumbnail()); unawaited(createThumbnail());
await calculateAndSaveSize();
await hashMediaFile(); await hashMediaFile();
// updateFromDb is done in hashStoredMedia() // updateFromDb is done in hashStoredMedia()
} }
Future<void> calculateAndSaveSize() async {
if (storedPath.existsSync()) {
final size = storedPath.lengthSync();
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
sizeInBytes: Value(size),
),
);
await updateFromDB();
}
}
Future<void> hashMediaFile() async { Future<void> hashMediaFile() async {
late final List<int> checksum; late final List<int> checksum;
if (storedPath.existsSync()) { if (storedPath.existsSync()) {

View file

@ -1,16 +1,18 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:pro_video_editor/pro_video_editor.dart'; import 'package:pro_video_editor/pro_video_editor.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
Future<void> createThumbnailsForVideo( Future<bool> createThumbnailsForVideo(
File sourceFile, File sourceFile,
File destinationFile, File destinationFile,
) async { ) async {
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) { if (destinationFile.existsSync()) {
return; return true;
} }
final images = await ProVideoEditor.instance.getThumbnails( final images = await ProVideoEditor.instance.getThumbnails(
@ -28,11 +30,89 @@ Future<void> createThumbnailsForVideo(
stopwatch.stop(); stopwatch.stop();
destinationFile.writeAsBytesSync(images.first); destinationFile.writeAsBytesSync(images.first);
Log.info( Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the thumbnail.', 'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
); );
return true;
} else { } else {
Log.warn( Log.warn(
'Thumbnail creation failed for the video with exit code.', 'Thumbnail creation failed for the video.',
); );
return false;
}
}
Future<bool> createThumbnailsForImage(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) {
return true;
}
try {
final bytes = sourceFile.readAsBytesSync();
final result = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 200,
minHeight: 200,
quality: 70,
format: CompressFormat.webp,
);
destinationFile.writeAsBytesSync(result);
stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
);
return true;
} catch (e) {
Log.error('Error creating image thumbnail: $e');
return false;
}
}
Future<bool> createThumbnailsForGif(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) {
return true;
}
try {
// For GIFs, we decode the first frame and save it as WebP
final bytes = sourceFile.readAsBytesSync();
final image = img.decodeGif(bytes);
if (image == null) {
Log.error('Could not decode GIF for thumbnail.');
return false;
}
final thumbnail = img.copyResize(
image,
width: image.width > image.height ? 200 : null,
height: image.height >= image.width ? 200 : null,
);
final pngBytes = img.encodePng(thumbnail);
final webp = await FlutterImageCompress.compressWithList(
pngBytes,
format: CompressFormat.webp,
quality: 70,
);
destinationFile.writeAsBytesSync(webp);
stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
);
return true;
} catch (e) {
Log.error('Error creating GIF thumbnail: $e');
return false;
} }
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart';
@ -170,25 +171,38 @@ class MemoriesService {
Future<void> _initAsync() async { Future<void> _initAsync() async {
try { try {
// 1. Perform Inventory / Migration of non-hashed stored files // 1. Perform Inventory / Migration of stored files
final nonHashedFiles = await twonlyDB.mediaFilesDao final pendingFiles = await twonlyDB.mediaFilesDao
.getAllNonHashedStoredMediaFiles(); .getAllMediaFilesPendingMigration();
final unanalyzedFiles = await twonlyDB.mediaFilesDao
.getAllUnanalyzedStoredMediaFiles();
final totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length; if (pendingFiles.isNotEmpty) {
if (totalToMigrate > 0) { _updateState(filesToMigrate: pendingFiles.length);
_updateState(filesToMigrate: totalToMigrate);
for (final mediaFile in nonHashedFiles) { for (final mediaFile in pendingFiles) {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
await mediaService.hashMediaFile();
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
}
for (final mediaFile in unanalyzedFiles) { if (mediaService.mediaFile.storedFileHash == null) {
final mediaService = MediaFileService(mediaFile); await mediaService.hashMediaFile();
await mediaService.cropTransparentBorders(); }
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
_updateState(filesToMigrate: _currentState.filesToMigrate - 1); _updateState(filesToMigrate: _currentState.filesToMigrate - 1);
} }
@ -234,10 +248,9 @@ class MemoriesService {
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue; if (!mediaService.imagePreviewAvailable) continue;
if (mediaService.mediaFile.type == MediaType.video) { if (!mediaService.mediaFile.hasThumbnail &&
if (!mediaService.thumbnailPath.existsSync()) { mediaService.mediaFile.type != MediaType.audio) {
unawaited(mediaService.createThumbnail()); unawaited(mediaService.createThumbnail());
}
} }
final senderContact = mediaIdToSenderContact[mediaFile.mediaId]; final senderContact = mediaIdToSenderContact[mediaFile.mediaId];

View file

@ -0,0 +1,342 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
typedef TextLabelBuilder = Widget Function(String label);
class DraggableScrollbar extends StatefulWidget {
const DraggableScrollbar({
required this.controller,
required this.child,
this.labelBuilder,
super.key,
});
final ScrollController controller;
final Widget child;
final String? Function(double scrollOffset)? labelBuilder;
@override
State<DraggableScrollbar> createState() => _DraggableScrollbarState();
static const double labelThumbPadding = 16;
}
class _DraggableScrollbarState extends State<DraggableScrollbar>
with TickerProviderStateMixin {
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0);
final ValueNotifier<double> _viewOffsetNotifier = ValueNotifier(0);
bool _isDragInProcess = false;
double _boundlessThumbOffset = 0;
late AnimationController _thumbAnimationController;
late CurvedAnimation _thumbAnimation;
late AnimationController _labelAnimationController;
late CurvedAnimation _labelAnimation;
Timer? _fadeoutTimer;
static const double thumbHeight = 60;
static const double thumbWidth = 20;
@override
void initState() {
super.initState();
_thumbAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastOutSlowIn,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void dispose() {
_thumbOffsetNotifier.dispose();
_viewOffsetNotifier.dispose();
_thumbAnimation.dispose();
_thumbAnimationController.dispose();
_labelAnimation.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
ScrollController get controller => widget.controller;
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_onScrollNotification(notification);
return false;
},
child: Stack(
children: [
RepaintBoundary(
child: widget.child,
),
// Scrollbar layer restricted to SafeArea
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final maxThumbOffset = constraints.maxHeight - thumbHeight;
return ExcludeSemantics(
child: RepaintBoundary(
child: ValueListenableBuilder<double>(
valueListenable: _thumbOffsetNotifier,
builder: (context, thumbOffset, child) {
final isDark =
Theme.of(context).brightness == Brightness.dark;
final handleColor = isDark
? Colors.grey.shade900
: Colors.white;
final iconColor = isDark
? Colors.white70
: Colors.black54;
final label = widget.labelBuilder?.call(
_viewOffsetNotifier.value,
);
return Container(
alignment: AlignmentDirectional.topEnd,
padding: EdgeInsets.only(top: thumbOffset),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => _onVerticalDragStart(),
onVerticalDragUpdate: (details) =>
_onVerticalDragUpdate(
details.delta.dy,
maxThumbOffset,
),
onVerticalDragEnd: (_) => _onVerticalDragEnd(),
child: SlideFadeTransition(
animation: _thumbAnimation,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (label != null && _isDragInProcess)
FadeTransition(
opacity: _labelAnimation,
child: ScaleTransition(
scale: _labelAnimation,
child: Container(
margin: const EdgeInsets.only(
right: DraggableScrollbar
.labelThumbPadding,
),
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 12,
),
decoration: BoxDecoration(
color:
(isDark
? Colors.grey.shade900
: Colors.grey.shade200)
.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(
8,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.2,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
label,
style: TextStyle(
color: isDark
? Colors.white
: Colors.black,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
),
Container(
width: thumbWidth,
height: thumbHeight,
decoration: BoxDecoration(
color: handleColor,
border: Border.all(
color: isDark
? Colors.white10
: Colors.black12,
width: 0.5,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(
top: 4,
),
child: FaIcon(
FontAwesomeIcons.angleUp,
size: 14,
color: iconColor,
),
),
Padding(
padding: const EdgeInsets.only(
bottom: 4,
),
child: FaIcon(
FontAwesomeIcons.angleDown,
size: 14,
color: iconColor,
),
),
],
),
),
],
),
),
),
);
},
),
),
);
},
),
),
],
),
);
}
void _onScrollNotification(ScrollNotification notification) {
final scrollMetrics = notification.metrics;
if (scrollMetrics.minScrollExtent >= scrollMetrics.maxScrollExtent) return;
_viewOffsetNotifier.value = scrollMetrics.pixels;
if (!_isDragInProcess) {
if (notification is ScrollUpdateNotification) {
// Find constraints and update thumb offset
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// Subtract SafeArea top/bottom
final padding = MediaQuery.paddingOf(context);
final availableHeight = renderBox.size.height - padding.vertical;
final maxThumbOffset = availableHeight - thumbHeight;
final scrollExtent =
scrollMetrics.pixels /
scrollMetrics.maxScrollExtent *
maxThumbOffset;
_thumbOffsetNotifier.value = scrollExtent.clamp(0.0, maxThumbOffset);
});
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
_showThumb();
_scheduleFadeout();
}
}
}
void _onVerticalDragStart() {
_boundlessThumbOffset = _thumbOffsetNotifier.value;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
_showThumb();
setState(() => _isDragInProcess = true);
}
void _onVerticalDragUpdate(double deltaY, double maxThumbOffset) {
_showThumb();
if (_isDragInProcess && maxThumbOffset > 0) {
_boundlessThumbOffset += deltaY;
_thumbOffsetNotifier.value = _boundlessThumbOffset.clamp(
0.0,
maxThumbOffset,
);
final max = controller.position.maxScrollExtent;
final scrollOffset = (_thumbOffsetNotifier.value / maxThumbOffset) * max;
controller.jumpTo(
scrollOffset.clamp(0.0, controller.position.maxScrollExtent),
);
}
}
void _onVerticalDragEnd() {
_scheduleFadeout();
setState(() => _isDragInProcess = false);
}
void _showThumb() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
}
void _scheduleFadeout() {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(const Duration(milliseconds: 1500), () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
class SlideFadeTransition extends StatelessWidget {
const SlideFadeTransition({
required this.animation,
required this.child,
super.key,
});
final Animation<double> animation;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: animation.value,
child: child,
);
},
child: child,
);
}
}

View file

@ -286,15 +286,14 @@ class HomeViewState extends State<HomeView> {
bottomNavigationBar: AnimatedSize( bottomNavigationBar: AnimatedSize(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: _isBottomNavVisible child: (_activePageIdx != 2 || _isBottomNavVisible)
? BottomNavigationBar( ? BottomNavigationBar(
showSelectedLabels: false, showSelectedLabels: false,
showUnselectedLabels: false, showUnselectedLabels: false,
unselectedIconTheme: IconThemeData( unselectedIconTheme: IconThemeData(
color: Theme.of(context) color: Theme.of(
.colorScheme context,
.inverseSurface ).colorScheme.inverseSurface.withAlpha(150),
.withAlpha(150),
), ),
selectedIconTheme: IconThemeData( selectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface, color: Theme.of(context).colorScheme.inverseSurface,

View file

@ -7,6 +7,7 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories.service.dart'; import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart'; import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
@ -371,99 +372,128 @@ class MemoriesViewState extends State<MemoriesView> {
orderedByMonth = filteredOrdered; orderedByMonth = filteredOrdered;
} }
return Scrollbar( return LayoutBuilder(
controller: _scrollController, builder: (context, constraints) {
thickness: 12, return DraggableScrollbar(
radius: const Radius.circular(6), controller: _scrollController,
interactive: true, labelBuilder: (offset) {
child: CustomScrollView( final state = _service.currentState;
controller: _scrollController, if (state.isEmpty) return null;
physics: const BouncingScrollPhysics(),
slivers: [ // Simple heuristic to find month by offset
SliverAppBar( double currentOffset = 56;
title: const Text( if (state.galleryItemsLastYears.isNotEmpty) {
'Memories', currentOffset += 220;
style: TextStyle(fontWeight: FontWeight.bold), }
),
floating: true, final screenWidth = MediaQuery.sizeOf(context).width;
snap: true, final itemWidth = (screenWidth - 8) / 4;
elevation: 0, final itemHeight = itemWidth * (16 / 9);
backgroundColor: context.color.surface, final rowHeight = itemHeight + 2;
actions: [
IconButton( for (final month in state.months) {
icon: Icon( final indices = state.orderedByMonth[month]!;
_filterFavoritesOnly final totalRows = (indices.length + 3) ~/ 4;
? Icons.favorite final monthHeight = 44 + (totalRows * rowHeight);
: Icons.favorite_border,
color: _filterFavoritesOnly if (offset < currentOffset + monthHeight) {
? Colors.redAccent return month;
: null, }
currentOffset += monthHeight;
}
return state.months.last;
},
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
title: const Text(
'Memories',
style: TextStyle(fontWeight: FontWeight.bold),
), ),
onPressed: () { floating: true,
setState(() { snap: true,
_filterFavoritesOnly = !_filterFavoritesOnly; elevation: 0,
}); backgroundColor: context.color.surface,
}, actions: [
tooltip: _filterFavoritesOnly IconButton(
? 'Show all' icon: Icon(
: 'Show favorites only', _filterFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _filterFavoritesOnly
? Colors.redAccent
: null,
),
onPressed: () {
setState(() {
_filterFavoritesOnly = !_filterFavoritesOnly;
});
},
tooltip: _filterFavoritesOnly
? 'Show all'
: 'Show favorites only',
),
],
),
MemoriesFlashbackBannerComp(
lastYears: lastYears,
onOpenFlashback: (items, idx) =>
_openViewer(items, idx, isFlashback: true),
),
for (final month in months) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
sliver: SliverToBoxAdapter(
child: Text(
month,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 9 / 16,
),
delegate: SliverChildBuilderDelegate(
(context, idx) {
final globalIndex = orderedByMonth[month]![idx];
final item = state.galleryItems[globalIndex];
final mediaId =
item.mediaService.mediaFile.mediaId;
final isSelected = _selectedMediaIds.contains(
mediaId,
);
return MemoriesThumbnailComp(
galleryItem: item,
index: globalIndex,
selectionMode: _selectionMode,
isSelected: isSelected,
activeMediaIdNotifier: _activeMediaIdNotifier,
onLongPress: () => _onLongPressItem(mediaId),
onTap: () => _onTapItem(mediaId, globalIndex),
);
},
childCount: orderedByMonth[month]!.length,
),
),
],
const SliverPadding(
padding: EdgeInsets.only(bottom: 32),
), ),
], ],
), ),
);
MemoriesFlashbackBannerComp( },
lastYears: lastYears,
onOpenFlashback: (items, idx) =>
_openViewer(items, idx, isFlashback: true),
),
for (final month in months) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
sliver: SliverToBoxAdapter(
child: Text(
month,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 9 / 16,
),
delegate: SliverChildBuilderDelegate(
(context, idx) {
final globalIndex = orderedByMonth[month]![idx];
final item = state.galleryItems[globalIndex];
final mediaId = item.mediaService.mediaFile.mediaId;
final isSelected = _selectedMediaIds.contains(
mediaId,
);
return MemoriesThumbnailComp(
galleryItem: item,
index: globalIndex,
selectionMode: _selectionMode,
isSelected: isSelected,
activeMediaIdNotifier: _activeMediaIdNotifier,
onLongPress: () => _onLongPressItem(mediaId),
onTap: () => _onTapItem(mediaId, globalIndex),
);
},
childCount: orderedByMonth[month]!.length,
),
),
],
const SliverPadding(padding: EdgeInsets.only(bottom: 32)),
],
),
); );
}, },
), ),

View file

@ -216,6 +216,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
isDraftMedia: false, isDraftMedia: false,
isFavorite: false, isFavorite: false,
hasCropAnalyzed: false, hasCropAnalyzed: false,
hasThumbnail: false,
createdAt: now, createdAt: now,
); );
final mediaService = MediaFileService(mediaFile); final mediaService = MediaFileService(mediaFile);

View file

@ -19,6 +19,7 @@ import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13; import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14; import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15; import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -54,6 +55,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v14.DatabaseAtV14(db); return v14.DatabaseAtV14(db);
case 15: case 15:
return v15.DatabaseAtV15(db); return v15.DatabaseAtV15(db);
case 16:
return v16.DatabaseAtV16(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@ -75,5 +78,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
13, 13,
14, 14,
15, 15,
16,
]; ];
} }

File diff suppressed because it is too large Load diff