feat(offline+post): Offline Mode Overhaul & Post Details Screen Redesign (v2.2.0)

This commit is contained in:
2026-09-04 11:40:19 +03:00
parent c6a9dcb4d5
commit f603c37c91
11 changed files with 1305 additions and 253 deletions
@@ -94,6 +94,21 @@ class PostDetailsScreen extends ConsumerWidget {
: null;
final qualityMode =
MediaQualityMode.fromName(settings.mediaQualityMode);
final localMedia = ref
.watch(downloadedMediaByKeyProvider(post.cacheKey))
.value;
final fileSizeBytes = localMedia != null
? DownloadedMediaService.getFileSizeSync(localMedia)
: null;
final isDownloading = ref.watch(downloadTasksProvider).value?.any(
(t) =>
(t.post?.cacheKey == post.cacheKey ||
t.id == post.cacheKey) &&
(t.status == DownloadTaskStatus.running ||
t.status == DownloadTaskStatus.queued),
) ??
false;
if (Responsive.isMobile(context)) {
if (currentIndex >= 0 && feedPosts.length > 1) {
return _MobilePostPager(
@@ -111,6 +126,7 @@ class PostDetailsScreen extends ConsumerWidget {
strings,
mediaGestureLocked,
onMediaGestureLockChanged,
feedPosts,
),
);
}
@@ -124,6 +140,7 @@ class PostDetailsScreen extends ConsumerWidget {
strings,
false,
null,
feedPosts,
);
}
return Shortcuts(
@@ -207,6 +224,7 @@ class PostDetailsScreen extends ConsumerWidget {
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode,
mediaHeaders: ref
.watch(postMediaHeadersProvider(post))
@@ -251,11 +269,8 @@ class PostDetailsScreen extends ConsumerWidget {
PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey),
labels: _postActionLabels(strings),
downloaded: ref
.watch(
downloadedMediaByKeyProvider(post.cacheKey))
.value !=
null,
downloaded: localMedia != null,
isDownloading: isDownloading,
onFavorite: () =>
_toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
@@ -273,26 +288,51 @@ class PostDetailsScreen extends ConsumerWidget {
onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: Text(strings.source),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
_PostInfoCard(
post: post,
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
),
const SizedBox(height: 16),
Text(strings.tags,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
PostTagsPanel(post: post),
Container(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.35),
),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.tag_rounded,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'${strings.tags} (${post.tags.length})',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
PostTagsPanel(post: post),
],
),
),
const SizedBox(height: 16),
_CommentsSection(post: post),
],
@@ -314,9 +354,24 @@ class PostDetailsScreen extends ConsumerWidget {
MediaQualityMode qualityMode,
AppStrings strings,
bool mediaGestureLocked,
ValueChanged<bool>? onMediaGestureLockChanged,
) {
ValueChanged<bool>? onMediaGestureLockChanged, [
List<Post>? feedPosts,
]) {
final isVideo = MediaUrlSelector.isVideo(post);
final localMedia =
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value;
final fileSizeBytes = localMedia != null
? DownloadedMediaService.getFileSizeSync(localMedia)
: null;
final isDownloading = ref.watch(downloadTasksProvider).value?.any(
(t) =>
(t.post?.cacheKey == post.cacheKey ||
t.id == post.cacheKey) &&
(t.status == DownloadTaskStatus.running ||
t.status == DownloadTaskStatus.queued),
) ??
false;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onVerticalDragEnd: (details) {
@@ -340,6 +395,7 @@ class PostDetailsScreen extends ConsumerWidget {
child: PostMediaViewer(
key: ValueKey(post.cacheKey),
post: post,
localFilePath: localMedia?.savedPath,
qualityMode: qualityMode,
mediaHeaders:
ref.watch(postMediaHeadersProvider(post)).value ?? const {},
@@ -365,9 +421,8 @@ class PostDetailsScreen extends ConsumerWidget {
PostActionBar(
isFavorite: favoriteKeys.contains(post.cacheKey),
labels: _postActionLabels(strings),
downloaded:
ref.watch(downloadedMediaByKeyProvider(post.cacheKey)).value !=
null,
downloaded: localMedia != null,
isDownloading: isDownloading,
onFavorite: () => _toggleFavorite(ref, post, favoriteKeys),
onCollection: () => _addToCollection(context, ref, post),
onOpen: () => launchUrl(Uri.parse(post.fileUrl)),
@@ -382,33 +437,62 @@ class PostDetailsScreen extends ConsumerWidget {
onShare: () => _sharePost(context, ref, post),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(post.providerName)),
RatingBadge(rating: post.rating),
Chip(label: Text('${post.width} x ${post.height}')),
if (post.source != null && post.source!.isNotEmpty)
ActionChip(
label: Text(strings.source),
onPressed: () => launchUrl(Uri.parse(post.source!)),
),
],
_PostInfoCard(
post: post,
strings: strings,
localMedia: localMedia,
fileSizeBytes: fileSizeBytes,
),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
initiallyExpanded: false,
title: Text(strings.tags,
style: Theme.of(context).textTheme.titleMedium),
children: [
Align(
alignment: Alignment.centerLeft,
child: PostTagsPanel(post: post),
if (feedPosts != null && feedPosts.length > 1) ...[
const SizedBox(height: 12),
_NeighborStrip(
posts: feedPosts,
currentIndex: feedPosts.indexWhere(
(item) =>
item.providerId == post.providerId && item.id == post.id,
),
],
onOpen: (p) => _openPost(context, p),
),
],
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.35),
),
),
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
tilePadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
leading: Icon(
Icons.tag_rounded,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
'${strings.tags} (${post.tags.length})',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
Align(
alignment: Alignment.centerLeft,
child: PostTagsPanel(post: post),
),
],
),
),
const SizedBox(height: 12),
_CommentsSection(post: post),
],
),
@@ -939,53 +1023,355 @@ class _CommentsSectionState extends ConsumerState<_CommentsSection> {
),
)
: null;
return ExpansionTile(
tilePadding: EdgeInsets.zero,
initiallyExpanded: false,
onExpansionChanged: (value) => setState(() => _expanded = value),
title: Text(strings.comments,
style: Theme.of(context).textTheme.titleMedium),
children: [
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
loading: () => const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(),
),
error: (_, __) => Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(strings.commentsUnavailable),
),
),
data: (items) {
if (items.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(strings.noComments),
),
);
}
return Column(
children: [
for (final comment in items)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(
comment.authorName.isEmpty
? strings.anonymous
: comment.authorName,
),
subtitle: Text(comment.body),
),
],
);
},
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.35),
),
],
),
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
leading: Icon(
Icons.chat_bubble_outline_rounded,
color: scheme.primary,
),
onExpansionChanged: (value) => setState(() => _expanded = value),
title: Text(
strings.comments,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
children: [
(comments ?? const AsyncValue<List<PostComment>>.data([])).when(
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
strings.commentsUnavailable,
style: TextStyle(color: scheme.outline),
),
),
data: (items) {
if (items.isEmpty) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(
strings.noComments,
style: TextStyle(color: scheme.onSurfaceVariant),
),
);
}
return Column(
children: [
for (final comment in items)
Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor: scheme.primaryContainer,
child: Text(
(comment.authorName.isNotEmpty
? comment.authorName[0]
: '?')
.toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: scheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
comment.authorName.isEmpty
? strings.anonymous
: comment.authorName,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
comment.body,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
);
},
),
],
),
);
}
}
class _PostInfoCard extends StatelessWidget {
const _PostInfoCard({
required this.post,
required this.strings,
this.localMedia,
this.fileSizeBytes,
});
final Post post;
final AppStrings strings;
final DownloadedMedia? localMedia;
final int? fileSizeBytes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final artists = post.tagGroups['artist'] ??
post.tags
.where((t) => t.startsWith('artist:'))
.map((t) => t.replaceFirst('artist:', ''))
.toList();
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Artist / Provider Row
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [scheme.primaryContainer, scheme.tertiaryContainer],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Icon(
artists.isNotEmpty
? Icons.palette_rounded
: Icons.hub_rounded,
size: 18,
color: scheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (artists.isNotEmpty)
InkWell(
onTap: () {
context.push(
'/search?q=${Uri.encodeComponent('artist:${artists.first}')}',
);
},
borderRadius: BorderRadius.circular(6),
child: Text(
artists.join(', '),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: scheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
else
Text(
strings.ru ? 'Автор не указан' : 'Unknown Artist',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
children: [
Text(
post.providerName,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
Text(
' • ',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.outline,
),
),
Text(
_formatDate(post.createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
],
),
),
if (localMedia != null)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.offline_pin_rounded,
size: 14,
color: scheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
fileSizeBytes != null && fileSizeBytes! > 0
? DownloadedMediaService.formatBytes(fileSizeBytes!)
: (strings.ru ? 'Офлайн' : 'Offline'),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Metadata Spec Pills
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_SpecBadge(
icon: Icons.aspect_ratio_rounded,
label: '${post.width} × ${post.height}',
),
if (post.fileType.isNotEmpty)
_SpecBadge(
icon: Icons.insert_drive_file_outlined,
label: post.fileType.toUpperCase(),
),
RatingBadge(rating: post.rating),
if (post.score != 0)
_SpecBadge(
icon: Icons.star_rounded,
label: '${post.score}',
iconColor: Colors.amber,
),
if (post.source != null && post.source!.isNotEmpty)
InkWell(
onTap: () => launchUrl(Uri.parse(post.source!)),
borderRadius: BorderRadius.circular(10),
child: _SpecBadge(
icon: Icons.open_in_new_rounded,
label: strings.source,
color: scheme.surfaceContainerHigh,
),
),
],
),
],
),
);
}
static String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
}
class _SpecBadge extends StatelessWidget {
const _SpecBadge({
required this.icon,
required this.label,
this.iconColor,
this.color,
});
final IconData icon;
final String label;
final Color? iconColor;
final Color? color;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: color ?? scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.25),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: iconColor ?? scheme.onSurfaceVariant),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
color: scheme.onSurfaceVariant,
),
),
],
),
);
}
}
@@ -12,6 +12,7 @@ class PostActionBar extends StatelessWidget {
this.onDeleteLocalFile,
this.onShare,
this.downloaded = false,
this.isDownloading = false,
this.labels,
this.onSimilar,
this.onHide,
@@ -30,76 +31,268 @@ class PostActionBar extends StatelessWidget {
final VoidCallback? onSimilar;
final VoidCallback? onHide;
final bool downloaded;
final bool isDownloading;
final PostActionLabels? labels;
void _showMoreActions(BuildContext context, PostActionLabels text) {
final scheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Copy Link
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.copy_rounded, size: 20),
),
title: Text(text.copyLink),
onTap: () {
Navigator.pop(sheetContext);
onCopy();
},
),
// Open Original in Browser
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.open_in_new_rounded, size: 20),
),
title: Text(text.openOriginal),
onTap: () {
Navigator.pop(sheetContext);
onOpen();
},
),
// Open Source Page
if (onOpenSource != null)
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.article_rounded, size: 20),
),
title: Text(text.openSource),
onTap: () {
Navigator.pop(sheetContext);
onOpenSource!();
},
),
// Find Similar
if (onSimilar != null)
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.auto_awesome_rounded,
size: 20, color: scheme.primary),
),
title: Text(text.similar),
onTap: () {
Navigator.pop(sheetContext);
onSimilar!();
},
),
const Divider(height: 16),
// Delete Local File (if downloaded)
if (downloaded && onDeleteLocalFile != null)
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.errorContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.delete_outline_rounded,
size: 20, color: scheme.error),
),
title: Text(
text.deleteLocalFile,
style: TextStyle(color: scheme.error),
),
onTap: () {
Navigator.pop(sheetContext);
onDeleteLocalFile!();
},
),
// Hide Post
if (onHide != null)
ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.visibility_off_rounded, size: 20),
),
title: Text(text.hidePost),
onTap: () {
Navigator.pop(sheetContext);
onHide!();
},
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final text = labels ?? const PostActionLabels();
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: onFavorite,
icon: Icon(
isFavorite ? Icons.favorite_rounded : Icons.favorite_border_rounded,
),
label: Text(isFavorite ? text.unfavorite : text.favorite),
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.3),
),
FilledButton.tonalIcon(
onPressed: onCollection,
icon: const Icon(Icons.add_rounded),
label: Text(text.collection),
),
if (onSimilar != null)
FilledButton.tonalIcon(
onPressed: onSimilar,
icon: const Icon(Icons.auto_awesome_rounded),
label: Text(text.similar),
),
IconButton.filledTonal(
tooltip: text.openOriginal,
onPressed: onOpen,
icon: const Icon(Icons.open_in_new_rounded),
),
if (onOpenSource != null)
IconButton.filledTonal(
tooltip: text.openSource,
onPressed: onOpenSource,
icon: const Icon(Icons.article_rounded),
),
IconButton.filledTonal(
tooltip: text.copyLink,
onPressed: onCopy,
icon: const Icon(Icons.copy_rounded),
),
if (onDownload != null)
IconButton.filledTonal(
tooltip: text.download,
onPressed: onDownload,
icon: Icon(
downloaded ? Icons.download_done_rounded : Icons.download_rounded,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Favorite / Like Button
Expanded(
flex: 3,
child: FilledButton.tonalIcon(
style: FilledButton.styleFrom(
backgroundColor: isFavorite
? Colors.redAccent.withValues(alpha: 0.18)
: scheme.secondaryContainer.withValues(alpha: 0.5),
foregroundColor:
isFavorite ? Colors.redAccent : scheme.onSecondaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: onFavorite,
icon: Icon(
isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
size: 20,
color: isFavorite ? Colors.redAccent : null,
),
label: Text(
isFavorite ? text.unfavorite : text.favorite,
style: const TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
if (downloaded && onDeleteLocalFile != null)
IconButton.filledTonal(
tooltip: text.deleteLocalFile,
onPressed: onDeleteLocalFile,
icon: const Icon(Icons.delete_sweep_rounded),
const SizedBox(width: 8),
// Download Button with Status
if (onDownload != null) ...[
_ActionButton(
tooltip: downloaded ? text.downloaded : text.download,
icon: isDownloading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: scheme.primary,
),
)
: Icon(
downloaded
? Icons.offline_pin_rounded
: Icons.download_rounded,
color: downloaded ? Colors.green : null,
size: 22,
),
onTap: onDownload!,
),
const SizedBox(width: 6),
],
// Add to Collection
_ActionButton(
tooltip: text.collection,
icon: const Icon(Icons.bookmark_add_rounded, size: 22),
onTap: onCollection,
),
if (onHide != null)
IconButton.filledTonal(
tooltip: text.hidePost,
onPressed: onHide,
icon: const Icon(Icons.visibility_off_rounded),
const SizedBox(width: 6),
// Share
if (onShare != null) ...[
_ActionButton(
tooltip: text.share,
icon: const Icon(Icons.share_rounded, size: 22),
onTap: onShare!,
),
const SizedBox(width: 6),
],
// More Options Bottom Sheet
_ActionButton(
tooltip: text.more,
icon: const Icon(Icons.more_horiz_rounded, size: 22),
onTap: () => _showMoreActions(context, text),
),
if (onShare != null)
IconButton.filledTonal(
tooltip: text.share,
onPressed: onShare,
icon: const Icon(Icons.share_rounded),
),
],
],
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.tooltip,
required this.icon,
required this.onTap,
});
final String tooltip;
final Widget icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return IconButton(
tooltip: tooltip,
onPressed: onTap,
icon: icon,
style: IconButton.styleFrom(
backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.35),
foregroundColor: scheme.onSecondaryContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
minimumSize: const Size(44, 44),
),
);
}
}
@@ -107,16 +300,18 @@ class PostActionBar extends StatelessWidget {
class PostActionLabels {
const PostActionLabels({
this.favorite = 'Favorite',
this.unfavorite = 'Unfavorite',
this.unfavorite = 'Saved',
this.collection = 'Collection',
this.similar = 'Similar',
this.openOriginal = 'Open original',
this.openSource = 'Open source/page',
this.copyLink = 'Copy link',
this.download = 'Download',
this.downloaded = 'Downloaded',
this.deleteLocalFile = 'Delete local file',
this.hidePost = 'Hide post locally',
this.share = 'Share',
this.more = 'More',
});
final String favorite;
@@ -127,7 +322,9 @@ class PostActionLabels {
final String openSource;
final String copyLink;
final String download;
final String downloaded;
final String deleteLocalFile;
final String hidePost;
final String share;
final String more;
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
@@ -17,6 +18,7 @@ final Map<String, VideoPlaybackSnapshot> _playbackMemory =
class PostMediaViewer extends StatefulWidget {
const PostMediaViewer({
required this.post,
this.localFilePath,
this.fullscreen = false,
this.initialPosition = Duration.zero,
this.autoplay = false,
@@ -33,6 +35,7 @@ class PostMediaViewer extends StatefulWidget {
});
final Post post;
final String? localFilePath;
final bool fullscreen;
final Duration initialPosition;
final bool autoplay;
@@ -208,23 +211,40 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
final url = _imageUrls.isEmpty ? '' : _imageUrls[_imageIndex];
final isLocal = url.startsWith('/') || url.startsWith('file://');
final headers = _headersFor(widget.post);
final image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) {
return _DioImageFallback(
imageUrl: url,
final Widget image;
if (isLocal) {
final cleanPath =
url.startsWith('file://') ? url.replaceFirst('file://', '') : url;
image = Image.file(
File(cleanPath),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => _DioImageFallback(
imageUrl: _imageUrls.length > 1 ? _imageUrls[1] : '',
headers: headers,
fit: BoxFit.contain,
onFailed: _advanceImageFallback,
);
},
);
),
);
} else {
image = CachedNetworkImage(
key: ValueKey(url),
imageUrl: url,
httpHeaders: headers,
fit: BoxFit.contain,
placeholder: (context, url) =>
const Center(child: CircularProgressIndicator()),
errorWidget: (context, url, error) {
return _DioImageFallback(
imageUrl: url,
headers: headers,
fit: BoxFit.contain,
onFailed: _advanceImageFallback,
);
},
);
}
final child = Stack(
alignment: Alignment.center,
children: [
@@ -325,10 +345,13 @@ class _PostMediaViewerState extends State<PostMediaViewer>
try {
setState(() => _videoError = null);
await player.stop();
final currentSource = _videoUrls[_videoIndex];
final isLocal =
currentSource.startsWith('/') || currentSource.startsWith('file://');
await player.open(
Media(
_videoUrls[_videoIndex],
httpHeaders: _headersFor(widget.post),
currentSource,
httpHeaders: isLocal ? null : _headersFor(widget.post),
),
play: false,
);
@@ -349,43 +372,35 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
// If format error occurred on initial load (often due to async referer headers), retry once automatically
if (!_retriedFormatError &&
(message.toLowerCase().contains('format') ||
message.toLowerCase().contains('recognize'))) {
(message.contains('Failed to recognize file format') ||
message.contains('Demuxer error') ||
message.contains('Could not open') ||
message.contains('Stream format not recognized') ||
message.contains('format unrecognized'))) {
_retriedFormatError = true;
await Future<void>.delayed(const Duration(milliseconds: 350));
if (!mounted) return;
_videoIndex = 0;
await _openVideo(play: play);
return;
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted && _player != null) {
_videoIndex = 0;
await _openVideo(play: play);
return;
}
}
if (mounted) {
setState(() => _videoError = message);
}
if (!mounted) return;
setState(() => _videoError = message);
_showControls();
}
Future<void> _retryVideo() async {
final player = _player;
if (player == null) return;
setState(() {
_videoError = null;
_videoIndex = 0;
_retriedFormatError = false;
_controlsVisible = true;
});
await player.stop();
_videoIndex = 0;
_retriedFormatError = false;
await _openVideo(play: true);
_showControls();
}
void _disposeVideo() {
_hideTimer?.cancel();
_hideTimer = null;
_errorSubscription?.cancel();
_errorSubscription = null;
_positionSubscription?.cancel();
_positionSubscription = null;
_playingSubscription?.cancel();
_playingSubscription = null;
final snapshot = _snapshot();
_playbackMemory[widget.post.cacheKey] = snapshot;
widget.onPlaybackSnapshot?.call(snapshot);
@@ -395,11 +410,21 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
List<String> _buildImageUrls(Post post) {
return MediaUrlSelector.details(post, mode: widget.qualityMode);
final list = MediaUrlSelector.details(post, mode: widget.qualityMode);
final local = widget.localFilePath;
if (local != null && local.isNotEmpty && File(local).existsSync()) {
return [local, ...list];
}
return list;
}
List<String> _buildVideoUrls(Post post) {
return MediaUrlSelector.video(post);
final list = MediaUrlSelector.video(post);
final local = widget.localFilePath;
if (local != null && local.isNotEmpty && File(local).existsSync()) {
return [local, ...list];
}
return list;
}
bool _isVideo(Post post) {