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
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:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user