Update Qlyra to 1.0.13 with messaging, media and plugin improvements
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-09-10 17:55:55 +03:00
parent 50816588e4
commit b22654b99f
4976 changed files with 18539 additions and 15865 deletions
@@ -62,6 +62,7 @@ class BubblePresentation {
class BubbleContext {
static const double photoMaxSize = 280.0;
static const double photoMinSize = 100.0;
static const double captionedMediaMinWidth = 200.0;
static const double photoBorderRadius = 12.0;
static const double bubbleBorderRadius = 20.0;
static const double captionPaddingHorizontal = 10.0;
@@ -11,8 +11,12 @@ import '../../../../core/utils/file_download.dart';
import '../../../../core/utils/media_cache.dart';
import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart';
import '../../../../core/media/audio_file_track.dart';
import '../../../../core/media/audio_playback_controller.dart';
import '../../../../core/media/media_playback.dart';
import '../../../../core/crypto/chat_crypto_service.dart';
import '../../../../core/crypto/encrypted_photo_cache.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../models/attachment.dart';
import '../../custom_notification.dart';
import '../../decrypted_photo.dart';
@@ -43,10 +47,12 @@ class FileBubble extends StatelessWidget {
final sizeStr = formatBytes(size);
final fileId = file.fileId;
final cacheName = '${fileId}_$name';
final audioFile = downloadKindForName(name) == DownloadKind.audio;
final preview = file.preview;
final previewUrl = preview?.baseUrl ?? preview?.previewData ?? '';
final previewWidget = _preview(
name: name,
cacheName: cacheName,
previewUrl: previewUrl,
encrypted: fileId != null && _isEncryptedImage(name),
@@ -72,7 +78,7 @@ class FileBubble extends StatelessWidget {
),
child: ctx.uploadProgress == null
? Icon(
Symbols.description,
audioFile ? Symbols.audio_file : Symbols.description,
color: isMe
? ctx.cs.onPrimaryContainer
: ctx.cs.primary,
@@ -165,19 +171,50 @@ class FileBubble extends StatelessWidget {
return ValueListenableBuilder<bool>(
valueListenable: MediaCache.presence(cacheName),
builder: (context, cached, _) => circle(
Icon(
cached ? Symbols.check : Symbols.download,
color: iconColor,
size: 18,
),
() => _downloadFile(ctx.context, file, name),
),
builder: (context, cached, _) {
if (!audioFile) {
return circle(
Icon(
cached ? Symbols.check : Symbols.download,
color: iconColor,
size: 18,
),
() => _downloadFile(ctx.context, file, name),
);
}
Widget button(IconData icon) => circle(
Icon(icon, color: iconColor, size: 18, fill: 1),
() => _playAudioFile(ctx.context, file, name),
);
return ValueListenableBuilder<AudioFileTrack?>(
valueListenable: MediaPlayback.instance.audioFile,
builder: (context, track, _) {
if (!cached) return button(Symbols.download);
if (track?.cacheName != cacheName ||
!AudioPlaybackController.isInitialized) {
return button(Symbols.play_arrow);
}
return ValueListenableBuilder<bool>(
valueListenable:
AudioPlaybackController.instance.playing,
builder: (context, playing, _) => button(
playing ? Symbols.pause : Symbols.play_arrow,
),
);
},
);
},
);
},
),
],
),
if (audioFile)
_AudioFilePlaybackControl(
cacheName: cacheName,
color: isMe ? ctx.cs.onPrimaryContainer : ctx.cs.primary,
textColor: ctx.dim,
),
ctx.meta(),
],
),
@@ -191,9 +228,26 @@ class FileBubble extends StatelessWidget {
);
}
static const Set<String> _coverExtensions = {
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.bmp',
'.heic',
'.heif',
};
static bool _isViewableImage(String name) =>
name.toLowerCase().endsWith('.png');
static bool _hasCover(String name) {
final dot = name.lastIndexOf('.');
if (dot < 0) return false;
return _coverExtensions.contains(name.substring(dot).toLowerCase());
}
bool _isEncryptedImage(String name) =>
_isViewableImage(name) &&
ChatCryptoService.instance.isEnabled(
@@ -202,6 +256,7 @@ class FileBubble extends StatelessWidget {
);
Widget? _preview({
required String name,
required String cacheName,
required String previewUrl,
required bool encrypted,
@@ -216,7 +271,7 @@ class FileBubble extends StatelessWidget {
builder: (view) => _encryptedPreview(view, previewUrl),
);
}
if (previewUrl.isEmpty) return null;
if (previewUrl.isEmpty || !_hasCover(name)) return null;
return _networkPreview(previewUrl);
}
@@ -255,11 +310,16 @@ class FileBubble extends StatelessWidget {
Widget _networkPreview(String url) => _framed(
CachedNetworkImage(
imageUrl: url,
width: _previewWidth,
height: _previewHeight,
fit: BoxFit.cover,
memCacheWidth: 480,
fadeInDuration: const Duration(milliseconds: 120),
imageBuilder: (context, image) => Image(
image: image,
width: _previewWidth,
height: _previewHeight,
fit: BoxFit.cover,
),
placeholder: (_, _) =>
const SizedBox(width: _previewWidth, height: _previewHeight),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
);
@@ -294,6 +354,9 @@ class FileBubble extends StatelessWidget {
child: ClipRRect(borderRadius: BorderRadius.circular(10), child: child),
);
String? get _thumbnailUrl =>
file.preview?.baseUrl ?? file.preview?.previewData ?? file.previewData;
Future<String?> _fileUrl() {
final fileId = file.fileId;
if (fileId == null) return Future.value(null);
@@ -340,13 +403,10 @@ class FileBubble extends StatelessWidget {
await DownloadHistory.record(
DownloadMetadata(
cacheName: cacheName,
name: kind == DownloadKind.file ? name : '',
name: name,
kind: kind,
sourceName: ctx.chatName ?? '',
thumbnailUrl:
file.preview?.baseUrl ??
file.preview?.previewData ??
file.previewData,
thumbnailUrl: _thumbnailUrl,
expectedSize: file.size ?? 0,
chatId: ctx.message.chatId,
messageId: ctx.message.id,
@@ -433,13 +493,10 @@ class FileBubble extends StatelessWidget {
},
download: DownloadMetadata(
cacheName: cacheName,
name: kind == DownloadKind.file ? name : '',
name: name,
kind: kind,
sourceName: ctx.chatName ?? '',
thumbnailUrl:
file.preview?.baseUrl ??
file.preview?.previewData ??
file.previewData,
thumbnailUrl: _thumbnailUrl,
expectedSize: file.size ?? 0,
chatId: ctx.message.chatId,
messageId: ctx.message.id,
@@ -454,4 +511,174 @@ class FileBubble extends StatelessWidget {
);
}
}
Future<void> _playAudioFile(
BuildContext context,
FileAttachment file,
String name,
) async {
final fileId = file.fileId;
if (fileId == null) {
showCustomNotification(context, 'Не удалось определить файл');
return;
}
Haptics.tap();
final cacheName = '${fileId}_$name';
final playback = MediaPlayback.instance;
if (playback.audioFile.value?.cacheName == cacheName &&
AudioPlaybackController.isInitialized) {
await AudioPlaybackController.instance.toggle();
return;
}
final cached = (await MediaCache.existing(cacheName)) != null;
if (!cached) MediaDownloadProgress.set(cacheName, 0);
final result = await ensureCachedFile(
cacheName,
_fileUrl,
onProgress: (value) => MediaDownloadProgress.set(cacheName, value),
onReady: () {
if (!cached) MediaDownloadProgress.set(cacheName, null);
},
download: DownloadMetadata(
cacheName: cacheName,
name: name,
kind: DownloadKind.audio,
sourceName: ctx.chatName ?? '',
thumbnailUrl: _thumbnailUrl,
expectedSize: file.size ?? 0,
chatId: ctx.message.chatId,
messageId: ctx.message.id,
messageTime: ctx.message.time,
),
);
if (!context.mounted) return;
if (!result.ok || result.path == null) {
showCustomNotification(
context,
'Ошибка загрузки: ${result.error ?? 'не удалось загрузить'}',
);
return;
}
final l10n = AppLocalizations.of(context)!;
try {
await playback.activateAudioFile(
AudioFileTrack(
cacheName: cacheName,
path: result.path!,
name: name,
sourceName: ctx.chatName ?? '',
chatId: ctx.message.chatId,
messageId: ctx.message.id,
messageTime: ctx.message.time,
thumbnailUrl: _thumbnailUrl,
),
notificationChannelName: l10n.audioPlaybackChannel,
);
} catch (_) {
if (context.mounted) {
showCustomNotification(context, l10n.audioPlaybackFailed);
}
}
}
}
class _AudioFilePlaybackControl extends StatelessWidget {
const _AudioFilePlaybackControl({
required this.cacheName,
required this.color,
required this.textColor,
});
final String cacheName;
final Color color;
final Color textColor;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<AudioFileTrack?>(
valueListenable: MediaPlayback.instance.audioFile,
builder: (context, track, _) =>
track?.cacheName != cacheName ||
!AudioPlaybackController.isInitialized
? const SizedBox.shrink()
: _AudioFileScrubber(color: color, textColor: textColor),
);
}
}
class _AudioFileScrubber extends StatefulWidget {
const _AudioFileScrubber({required this.color, required this.textColor});
final Color color;
final Color textColor;
@override
State<_AudioFileScrubber> createState() => _AudioFileScrubberState();
}
class _AudioFileScrubberState extends State<_AudioFileScrubber> {
double? _dragMilliseconds;
@override
Widget build(BuildContext context) {
final audio = AudioPlaybackController.instance;
return AnimatedBuilder(
animation: Listenable.merge([audio.position, audio.duration]),
builder: (context, _) {
final total = audio.duration.value.inMilliseconds;
final elapsed = _dragMilliseconds != null
? _dragMilliseconds!.round()
: audio.position.value.inMilliseconds.clamp(
0,
total > 0 ? total : 0,
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Text(
formatSecondsMmSs(elapsed ~/ 1000),
style: TextStyle(color: widget.textColor, fontSize: 10),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: widget.color,
inactiveTrackColor: widget.color.withValues(alpha: 0.2),
thumbColor: widget.color,
overlayColor: widget.color.withValues(alpha: 0.12),
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 5,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 12,
),
),
child: Slider(
min: 0,
max: total > 0 ? total.toDouble() : 1,
value: total > 0 ? elapsed.toDouble() : 0,
onChanged: total > 0
? (next) => setState(() => _dragMilliseconds = next)
: null,
onChangeEnd: total > 0
? (next) {
setState(() => _dragMilliseconds = null);
audio.seek(Duration(milliseconds: next.round()));
}
: null,
),
),
),
Text(
formatSecondsMmSs(audio.duration.value.inSeconds),
style: TextStyle(color: widget.textColor, fontSize: 10),
),
],
),
);
},
);
}
}
@@ -6,10 +6,14 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../core/media/preview_image.dart';
import '../../../../core/utils/format.dart';
import '../../../../models/attachment.dart';
import '../../photo_viewer.dart';
import '../photo_hero.dart';
import '../../text_with_meta.dart';
import 'bubble_context.dart';
import 'video_bubble.dart';
class PhotoBubble extends StatelessWidget {
static const Radius _bigRadius = Radius.circular(
@@ -21,24 +25,63 @@ class PhotoBubble extends StatelessWidget {
);
final BubbleContext ctx;
final List<PhotoAttachment> photos;
final List<MessageAttachment> media;
final bool hasContentAbove;
const PhotoBubble({
super.key,
required this.ctx,
required this.photos,
required this.media,
this.hasContentAbove = false,
});
static double layoutWidth(List<PhotoAttachment> photos) {
if (photos.length != 1) return BubbleContext.photoMaxSize;
return _displaySize(photos.single).width;
// #***! альбом собирает фото и обычные видео, кружки живут отдельно
static bool isAlbumMedia(MessageAttachment item) =>
item is PhotoAttachment || (item is VideoAttachment && !item.isNote);
static int? _intrinsicWidth(MessageAttachment item) => switch (item) {
PhotoAttachment(:final width) => width,
VideoAttachment(:final width) => width,
_ => null,
};
static int? _intrinsicHeight(MessageAttachment item) => switch (item) {
PhotoAttachment(:final height) => height,
VideoAttachment(:final height) => height,
_ => null,
};
static String? _localPathOf(MessageAttachment item) => switch (item) {
PhotoAttachment(:final localPath) => localPath,
VideoAttachment(:final localPath) => localPath,
_ => null,
};
// #***! у видео обложка отдельным полем, baseUrl это уже сам файл
static String _previewUrlOf(MessageAttachment item) {
if (item is VideoAttachment) {
final thumb = item.thumbnail;
if (thumb != null && thumb.isNotEmpty) return thumb;
return item.baseUrl ?? '';
}
if (item is PhotoAttachment) return item.baseUrl ?? '';
return '';
}
static Size _displaySize(PhotoAttachment photo) {
final width = photo.width?.toDouble() ?? 200;
final height = photo.height?.toDouble() ?? 200;
static double layoutWidth(
List<MessageAttachment> media, {
bool hasCaption = false,
}) {
if (media.length != 1) return BubbleContext.photoMaxSize;
return _displaySize(media.single, hasCaption: hasCaption).width;
}
static Size _displaySize(MessageAttachment item, {bool hasCaption = false}) {
final minWidth = hasCaption
? BubbleContext.captionedMediaMinWidth
: BubbleContext.photoMinSize;
final width = _intrinsicWidth(item)?.toDouble() ?? 200;
final height = _intrinsicHeight(item)?.toDouble() ?? 200;
final downScale = math.min(
1.0,
@@ -53,7 +96,7 @@ class PhotoBubble extends StatelessWidget {
final upScale = math.max(
1.0,
math.max(
BubbleContext.photoMinSize / displayWidth,
minWidth / displayWidth,
BubbleContext.photoMinSize / displayHeight,
),
);
@@ -61,10 +104,7 @@ class PhotoBubble extends StatelessWidget {
displayHeight *= upScale;
return Size(
displayWidth.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
),
displayWidth.clamp(minWidth, BubbleContext.photoMaxSize),
displayHeight.clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
@@ -77,22 +117,22 @@ class PhotoBubble extends StatelessWidget {
final hasMessageCaption = ctx.contentText?.isNotEmpty ?? false;
final resolvedCaption = hasMessageCaption ? ctx.caption() : null;
final hasCaption = resolvedCaption != null;
final count = photos.length;
final count = media.length;
Widget photosWidget;
if (count == 1) {
photosWidget = _buildSinglePhoto(
ctx,
photos[0],
media[0],
hasCaption: hasCaption,
hasContentAbove: hasContentAbove,
);
} else if (count == 2) {
photosWidget = _buildTwoPhotos(ctx, photos[0], photos[1]);
photosWidget = _buildTwoPhotos(ctx, media[0], media[1]);
} else if (count == 3) {
photosWidget = _buildThreePhotos(ctx, photos);
photosWidget = _buildThreePhotos(ctx, media);
} else {
photosWidget = _buildPhotoGrid(ctx, photos);
photosWidget = _buildPhotoGrid(ctx, media);
}
if (!hasCaption) {
@@ -110,7 +150,7 @@ class PhotoBubble extends StatelessWidget {
if (count == 1) {
return SizedBox(
width: layoutWidth(photos),
width: layoutWidth(media, hasCaption: true),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -123,12 +163,10 @@ class PhotoBubble extends StatelessWidget {
top: BubbleContext.captionPaddingTop,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: resolvedCaption),
ctx.meta(),
],
child: TextWithMeta(
text: resolvedCaption,
meta: ctx.meta(),
fillWidth: true,
),
),
],
@@ -148,12 +186,10 @@ class PhotoBubble extends StatelessWidget {
top: BubbleContext.captionPaddingTop,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: resolvedCaption),
ctx.meta(),
],
child: TextWithMeta(
text: resolvedCaption,
meta: ctx.meta(),
fillWidth: true,
),
),
],
@@ -162,11 +198,11 @@ class PhotoBubble extends StatelessWidget {
Widget _buildSinglePhoto(
BubbleContext ctx,
PhotoAttachment photo, {
MessageAttachment photo, {
required bool hasCaption,
required bool hasContentAbove,
}) {
final size = _displaySize(photo);
final size = _displaySize(photo, hasCaption: hasCaption);
final constrainedWidth = size.width;
final constrainedHeight = size.height;
final dpr = MediaQuery.of(ctx.context).devicePixelRatio;
@@ -203,6 +239,7 @@ class PhotoBubble extends StatelessWidget {
memWidth: memWidth,
memHeight: memHeight,
),
..._videoBadges(photo, compact: false),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, 0),
if (ctx.uploadProgress == null)
@@ -210,7 +247,7 @@ class PhotoBubble extends StatelessWidget {
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
onTap: () => _openMedia(
ctx.context,
0,
tileContext: tileContext,
@@ -228,13 +265,13 @@ class PhotoBubble extends StatelessWidget {
Widget _buildPhotoImage(
BubbleContext ctx,
PhotoAttachment photo,
MessageAttachment photo,
double width,
double height, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
final localPath = _localPathOf(photo);
if (localPath != null) {
return Image.file(
File(localPath),
@@ -247,8 +284,9 @@ class PhotoBubble extends StatelessWidget {
_buildPhotoPlaceholder(ctx.cs, width, height),
);
}
final imageUrl = photo.baseUrl ?? '';
if (imageUrl.isNotEmpty) {
final embedded = dataUriImage(photo, photo.previewData);
final imageUrl = _previewUrlOf(photo);
if (imageUrl.isNotEmpty && !imageUrl.startsWith('data:')) {
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
@@ -258,12 +296,72 @@ class PhotoBubble extends StatelessWidget {
memCacheHeight: memHeight,
fadeInDuration: Duration.zero,
placeholderFadeInDuration: Duration.zero,
errorWidget: (_, _, _) => _buildPhotoPlaceholder(ctx.cs, width, height),
errorWidget: (_, _, _) => embedded == null
? _buildPhotoPlaceholder(ctx.cs, width, height)
: Image(
image: embedded,
width: width,
height: height,
fit: BoxFit.cover,
),
);
}
if (embedded != null) {
return Image(
image: embedded,
width: width,
height: height,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
_buildPhotoPlaceholder(ctx.cs, width, height),
);
}
return _buildPhotoPlaceholder(ctx.cs, width, height);
}
// #***! плитка видео отличается от фото только кружком плеера и длительностью
List<Widget> _videoBadges(MessageAttachment item, {required bool compact}) {
if (item is! VideoAttachment) return const [];
final side = compact ? 36.0 : 48.0;
final durationMs = item.duration;
return [
Positioned.fill(
child: Center(
child: Container(
width: side,
height: side,
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(
Symbols.play_arrow,
color: Colors.white,
size: side * 0.625,
),
),
),
),
if (durationMs != null && durationMs > 0)
Positioned(
left: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(6),
),
child: Text(
formatSecondsMmSs((durationMs / 1000).round()),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
];
}
Widget _buildUploadOverlay(
ValueListenable<List<double>> progress,
int index,
@@ -312,8 +410,8 @@ class PhotoBubble extends StatelessWidget {
Widget _buildTwoPhotos(
BubbleContext ctx,
PhotoAttachment p1,
PhotoAttachment p2,
MessageAttachment p1,
MessageAttachment p2,
) {
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
@@ -337,7 +435,7 @@ class PhotoBubble extends StatelessWidget {
);
}
Widget _buildThreePhotos(BubbleContext ctx, List<PhotoAttachment> photos) {
Widget _buildThreePhotos(BubbleContext ctx, List<MessageAttachment> photos) {
final matchTop =
ctx.hasMultiplePhotosNoCaption && ctx.shape == BubbleShape.singleTop;
final matchBottom =
@@ -370,7 +468,7 @@ class PhotoBubble extends StatelessWidget {
);
}
Widget _buildPhotoGrid(BubbleContext ctx, List<PhotoAttachment> photos) {
Widget _buildPhotoGrid(BubbleContext ctx, List<MessageAttachment> photos) {
final displayCount = photos.length > 4 ? 4 : photos.length;
final remaining = photos.length - 4;
@@ -409,7 +507,7 @@ class PhotoBubble extends StatelessWidget {
Widget _buildGridTile(
BubbleContext ctx,
List<PhotoAttachment> photos,
List<MessageAttachment> photos,
int index,
int remaining,
) {
@@ -424,10 +522,13 @@ class PhotoBubble extends StatelessWidget {
return _buildPhotoTile(ctx, photos[index], index);
}
Widget _buildPhotoTile(BubbleContext ctx, PhotoAttachment photo, int index) =>
AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index));
Widget _buildPhotoTile(
BubbleContext ctx,
MessageAttachment photo,
int index,
) => AspectRatio(aspectRatio: 1, child: _buildFillTile(ctx, photo, index));
Widget _buildFillTile(BubbleContext ctx, PhotoAttachment photo, int index) {
Widget _buildFillTile(BubbleContext ctx, MessageAttachment photo, int index) {
final cachePx =
(BubbleContext.photoMaxSize /
2 *
@@ -443,6 +544,7 @@ class PhotoBubble extends StatelessWidget {
memWidth: cachePx,
memHeight: cachePx,
),
..._videoBadges(photo, compact: true),
if (ctx.uploadProgress != null)
_buildUploadOverlay(ctx.uploadProgress!, index),
if (ctx.uploadProgress == null)
@@ -456,7 +558,7 @@ class PhotoBubble extends StatelessWidget {
child: Builder(
builder: (tileContext) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openPhotoViewer(
onTap: () => _openMedia(
ctx.context,
index,
tileContext: tileContext,
@@ -471,7 +573,7 @@ class PhotoBubble extends StatelessWidget {
Widget _buildPhotoTileWithOverlay(
BubbleContext ctx,
PhotoAttachment photo,
MessageAttachment photo,
String overlay,
int index,
) {
@@ -541,11 +643,11 @@ class PhotoBubble extends StatelessWidget {
}
static ImageProvider? _photoProvider(
PhotoAttachment photo, {
MessageAttachment photo, {
required int memWidth,
required int memHeight,
}) {
final localPath = photo.localPath;
final localPath = _localPathOf(photo);
if (localPath != null) {
return ResizeImage.resizeIfNeeded(
memWidth,
@@ -553,8 +655,10 @@ class PhotoBubble extends StatelessWidget {
FileImage(File(localPath)),
);
}
final url = photo.baseUrl ?? '';
if (url.isEmpty) return null;
final url = _previewUrlOf(photo);
if (url.isEmpty || url.startsWith('data:')) {
return dataUriImage(photo, photo.previewData);
}
return ResizeImage.resizeIfNeeded(
memWidth,
memHeight,
@@ -562,14 +666,15 @@ class PhotoBubble extends StatelessWidget {
);
}
static Size? _photoSize(PhotoAttachment photo) {
final width = photo.width ?? 0;
final height = photo.height ?? 0;
static Size? _photoSize(MessageAttachment photo) {
final width = _intrinsicWidth(photo) ?? 0;
final height = _intrinsicHeight(photo) ?? 0;
if (width <= 0 || height <= 0) return null;
return Size(width.toDouble(), height.toDouble());
}
void _openPhotoViewer(
// #***! просмотрщик листает только фото, видео из альбома уходит в плеер
void _openMedia(
BuildContext context,
int index, {
required BuildContext tileContext,
@@ -577,7 +682,13 @@ class PhotoBubble extends StatelessWidget {
required int memWidth,
required int memHeight,
}) {
final photo = photos[index];
final photo = media[index];
if (photo is VideoAttachment) {
openVideoPlayer(ctx, photo);
return;
}
final photos = media.whereType<PhotoAttachment>().toList();
final photoIndex = photos.indexOf(photo as PhotoAttachment);
final hero = PhotoHeroController(
origin: () => photoHeroRectOf(tileContext),
image: _photoProvider(photo, memWidth: memWidth, memHeight: memHeight),
@@ -589,7 +700,7 @@ class PhotoBubble extends StatelessWidget {
hero: hero,
builder: (_) => PhotoViewerScreen(
photos: photos,
initialIndex: index,
initialIndex: photoIndex < 0 ? 0 : photoIndex,
chatId: ctx.chatId,
message: ctx.message,
actions: ctx.photoActions,
@@ -10,6 +10,7 @@ import '../../../../models/attachment.dart';
import '../../custom_notification.dart';
import '../../upload_progress_ring.dart';
import '../../photo_viewer.dart';
import '../../text_with_meta.dart';
import 'bubble_context.dart';
import 'video_note_bubble.dart';
@@ -19,9 +20,11 @@ class VideoBubble extends StatelessWidget {
const VideoBubble({super.key, required this.ctx, required this.video});
static double layoutWidth(VideoAttachment video) {
static double layoutWidth(VideoAttachment video, {bool hasCaption = false}) {
return (video.width?.toDouble() ?? 200.0).clamp(
BubbleContext.photoMinSize,
hasCaption
? BubbleContext.captionedMediaMinWidth
: BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
);
}
@@ -57,7 +60,7 @@ class VideoBubble extends StatelessWidget {
: (video.previewData ?? '');
final h = video.height;
final width = layoutWidth(video);
final width = layoutWidth(video, hasCaption: hasCaption);
final height = (h?.toDouble() ?? 150.0).clamp(
BubbleContext.photoMinSize,
BubbleContext.photoMaxSize,
@@ -197,12 +200,10 @@ class VideoBubble extends StatelessWidget {
top: BubbleContext.captionPaddingTop,
bottom: 6,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: resolvedCaption),
ctx.meta(),
],
child: TextWithMeta(
text: resolvedCaption,
meta: ctx.meta(),
fillWidth: true,
),
),
],
@@ -210,39 +211,44 @@ class VideoBubble extends StatelessWidget {
);
}
Future<void> _playVideo(BuildContext context, VideoAttachment video) async {
final videoId = video.videoId;
final token = video.videoToken;
if (videoId == null || token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
Future<void> _playVideo(BuildContext context, VideoAttachment video) =>
openVideoPlayer(ctx, video);
}
final sources = await messagesModule.getVideoSources(
messageId: ctx.sourceMessageId,
chatId: ctx.sourceChatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (sources.isEmpty) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
// #***! общий вход в плеер, зовут и одиночное видео и плитка альбома
Future<void> openVideoPlayer(BubbleContext ctx, VideoAttachment video) async {
final context = ctx.context;
final videoId = video.videoId;
final token = video.videoToken;
if (videoId == null || token == null) {
showCustomNotification(context, 'Не удалось открыть видео');
return;
}
Haptics.tap();
final sources = await messagesModule.getVideoSources(
messageId: ctx.sourceMessageId,
chatId: ctx.sourceChatId,
token: token,
videoId: videoId,
);
if (!context.mounted) return;
if (sources.isEmpty) {
showCustomNotification(context, 'Не удалось получить видео');
return;
}
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoViewerScreen.video(
attachment: video,
initialVideoSources: sources,
chatId: ctx.message.chatId,
message: ctx.message,
actions: ctx.photoActions,
sourceName: ctx.chatName,
),
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoViewerScreen.video(
attachment: video,
initialVideoSources: sources,
chatId: ctx.message.chatId,
message: ctx.message,
actions: ctx.photoActions,
sourceName: ctx.chatName,
),
);
}
),
);
}