diff --git a/lib/src/core/design_system/components/organisms/side_action_bar.dart b/lib/src/core/design_system/components/organisms/side_action_bar.dart index 29ecdf3f..b94cd535 100644 --- a/lib/src/core/design_system/components/organisms/side_action_bar.dart +++ b/lib/src/core/design_system/components/organisms/side_action_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart'; class SparkSideActionBar extends StatefulWidget { const SparkSideActionBar({ @@ -94,15 +95,10 @@ class _SparkSideActionBarState extends State { ]); } - // Only show sound item if cover URL is valid - final soundCover = widget.soundCover; - if (soundCover != null && - soundCover.isNotEmpty && - (soundCover.startsWith('http://') || - soundCover.startsWith('https://'))) { + if (widget.onSoundTap != null) { children.addAll([ const SizedBox(height: 13), - _SoundItem(cover: soundCover, onTap: widget.onSoundTap), + _SoundItem(cover: widget.soundCover, onTap: widget.onSoundTap), ]); } @@ -206,32 +202,24 @@ class _ActionItemState extends State<_ActionItem> } class _SoundItem extends StatelessWidget { - const _SoundItem({required this.cover, this.onTap}); + const _SoundItem({this.cover, this.onTap}); - final String cover; + final String? cover; final VoidCallback? onTap; @override Widget build(BuildContext context) { const albumSize = 35.0; - // Don't render if cover URL is empty or invalid - final hasValidCover = - cover.isNotEmpty && - (cover.startsWith('http://') || cover.startsWith('https://')); - return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Container( - width: albumSize, - height: albumSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: hasValidCover ? null : Colors.grey[800], - image: hasValidCover - ? DecorationImage(image: NetworkImage(cover), fit: BoxFit.cover) - : null, + child: ClipOval( + child: SoundArtwork( + imageUrl: cover, + size: albumSize, + borderRadius: albumSize / 2, + backgroundColor: Colors.grey.shade800, ), ), ); diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index 7c5ede2d..a5de199f 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -38,6 +38,10 @@ class ImageReviewPageTemplate extends StatelessWidget { required this.crossPostValue, required this.onCrossPostChanged, super.key, + this.selectedSoundTitle, + this.selectedSoundSubtitle, + this.onAddSound, + this.onRemoveSound, this.descriptionController, this.mentionController, this.onMentionsChanged, @@ -66,6 +70,10 @@ class ImageReviewPageTemplate extends StatelessWidget { final bool crossPostValue; final ValueChanged onCrossPostChanged; final bool showCrossPostWarning; + final String? selectedSoundTitle; + final String? selectedSoundSubtitle; + final VoidCallback? onAddSound; + final VoidCallback? onRemoveSound; final String postLabel; final VoidCallback? onPost; final bool isPosting; @@ -124,6 +132,15 @@ class ImageReviewPageTemplate extends StatelessWidget { ), ), ], + if (onAddSound != null) ...[ + const SizedBox(height: 20), + _SoundSection( + title: selectedSoundTitle, + subtitle: selectedSoundSubtitle, + onAddSound: onAddSound!, + onRemoveSound: onRemoveSound, + ), + ], const SizedBox(height: 20), _DescriptionSection( controller: descriptionController, @@ -359,6 +376,69 @@ class _ImagePager extends StatelessWidget { } } +class _SoundSection extends StatelessWidget { + const _SoundSection({ + required this.title, + required this.subtitle, + required this.onAddSound, + required this.onRemoveSound, + }); + + final String? title; + final String? subtitle; + final VoidCallback onAddSound; + final VoidCallback? onRemoveSound; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasSound = title != null && title!.trim().isNotEmpty; + + if (!hasSound) { + return SizedBox( + width: double.infinity, + child: AppButton( + label: l10n.buttonAddSound, + onPressed: onAddSound, + size: AppButtonSize.compact, + fullWidth: true, + ), + ); + } + + return Material( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: colorScheme.outline.withValues(alpha: 0.6)), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + leading: const Icon(Icons.music_note_rounded), + title: Text( + title!, + style: AppTypography.textMediumBold.copyWith( + color: colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle == null || subtitle!.isEmpty + ? null + : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: IconButton( + tooltip: l10n.buttonRemove, + icon: const Icon(Icons.close_rounded), + onPressed: onRemoveSound, + ), + onTap: onAddSound, + ), + ); + } +} + class _DescriptionSection extends StatelessWidget { const _DescriptionSection({ this.controller, @@ -442,11 +522,11 @@ class _CrossPostSection extends StatelessWidget { return Column( children: [ - Container( - decoration: BoxDecoration( - color: tileColor, + Material( + color: tileColor, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), - border: Border.all(color: borderColor), + side: BorderSide(color: borderColor), ), child: ListTile( contentPadding: const EdgeInsets.symmetric( diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository.dart b/lib/src/core/network/atproto/data/repositories/feed_repository.dart index 4b21c317..a5a99571 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository.dart @@ -144,12 +144,14 @@ abstract class FeedRepository { /// [altTexts] Map of file paths to alt texts /// [crosspostToBsky] Whether to also post to Bluesky /// [facets] Optional list of facets for text formatting (mentions, links, etc.) + /// [soundRef] Optional reusable sound reference for Spark image/carousel posts. Future postImages( String text, List imageFiles, Map altTexts, { bool crosspostToBsky = false, List facets = const [], + RepoStrongRef? soundRef, }); /// Upload images to the server diff --git a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart index 6fca3ac9..ac25f25c 100644 --- a/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart +++ b/lib/src/core/network/atproto/data/repositories/feed_repository_impl.dart @@ -1041,6 +1041,7 @@ class FeedRepositoryImpl implements FeedRepository { Map altTexts, { bool crosspostToBsky = false, List facets = const [], + RepoStrongRef? soundRef, }) async { if (imageFiles.isEmpty) { _logger.e('No images provided for image post'); @@ -1067,6 +1068,7 @@ class FeedRepositoryImpl implements FeedRepository { caption: CaptionRef(text: text, facets: facets), media: Media.images(images: uploadedImageMaps), createdAt: DateTime.now().toUtc(), + sound: soundRef, ); final result = await _client.repo.createRecord( diff --git a/lib/src/core/pro_video_editor/models/sound_audio_track.dart b/lib/src/core/pro_video_editor/models/sound_audio_track.dart index abc8d85b..2a370555 100644 --- a/lib/src/core/pro_video_editor/models/sound_audio_track.dart +++ b/lib/src/core/pro_video_editor/models/sound_audio_track.dart @@ -11,6 +11,7 @@ const _fallbackAudioFileExtension = 'mp3'; AudioTrack? audioViewToAudioTrack(AudioView audio) { final audioUrl = playableAudioUrl(audio); if (audioUrl == null || audioUrl.isEmpty) return null; + final coverArtUrl = soundCoverArtUrl(audio); return AudioTrack( id: encodeSoundTrackId( @@ -23,7 +24,7 @@ AudioTrack? audioViewToAudioTrack(AudioView audio) { title: audio.displayTitle, subtitle: audio.author.handle, duration: audioDuration(audio), - image: EditorImage(networkUrl: audio.coverArt.toString()), + image: coverArtUrl != null ? EditorImage(networkUrl: coverArtUrl) : null, audio: EditorAudio(networkUrl: audioUrl), ); } @@ -154,6 +155,14 @@ Duration audioDuration(AudioView audio) { return _fallbackAudioDuration; } +String? soundCoverArtUrl(AudioView audio) { + final coverArtUrl = audio.coverArt.toString().trim(); + if (coverArtUrl.isEmpty || coverArtUrl == 'null') { + return null; + } + return coverArtUrl; +} + bool _isSparkMediaSoundUrl(String url) { final uri = Uri.tryParse(url); if (uri == null) return false; diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart index 73fd1f37..a426c12a 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_waveform_selector.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart'; /// Displays edit controls for the selected audio track. /// @@ -104,24 +106,11 @@ class _AudioEditControlsSectionState extends State { ), child: Row( children: [ - if (widget.audioTrack.image != null) - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - width: 48, - height: 48, - color: _style.audioTrackImageBackground, - child: widget.audioTrack.image != null - ? Image.network( - widget.audioTrack.image!.networkUrl ?? '', - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildDefaultIcon(), - ) - : _buildDefaultIcon(), - ), - ) - else - _buildDefaultIcon(), + SoundArtwork( + imageUrl: widget.audioTrack.image?.networkUrl, + size: 48, + backgroundColor: _style.audioTrackImageBackground.withAlpha(80), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -155,19 +144,6 @@ class _AudioEditControlsSectionState extends State { ); } - Widget _buildDefaultIcon() { - final color = _style.audioTrackImageBackground; - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: color.withAlpha(80), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(_configs.icons.audioTrackDefaultIcon, color: color, size: 24), - ); - } - Widget _buildBalanceSlider() { final balanceSliderBackground = _style.balanceSliderBackground; final colorScheme = Theme.of(context).colorScheme; @@ -248,37 +224,20 @@ class _AudioEditControlsSectionState extends State { spacing: 12, children: [ Expanded( - child: OutlinedButton.icon( + child: AppButton( + label: _i18n.editTrack, onPressed: widget.onChangeTrack, - icon: const Icon(Icons.music_note), - label: Text(_i18n.editTrack), - style: OutlinedButton.styleFrom( - foregroundColor: _style.buttonEditTrackColor, - side: BorderSide(color: _style.buttonEditTrackColor), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - _style.buttonEditTrackBorderRadius, - ), - ), - ), + variant: AppButtonVariant.secondary, + fullWidth: true, + leading: const Icon(Icons.music_note_rounded, size: 18), ), ), Expanded( - child: ElevatedButton.icon( + child: AppButton( + label: _i18n.confirmChanges, onPressed: widget.onConfirm, - icon: const Icon(Icons.check), - label: Text(_i18n.confirmChanges), - style: ElevatedButton.styleFrom( - backgroundColor: _style.buttonConfirmBackground, - foregroundColor: _style.buttonConfirmColor, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - _style.buttonConfirmBorderRadius, - ), - ), - ), + fullWidth: true, + leading: const Icon(Icons.check_rounded, size: 18), ), ), ], diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart index 10dffd9d..6e387633 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_selection_bottom_sheet.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_edit_controls_section.dart'; import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_picker_sheet_scaffold.dart'; /// A bottom sheet for selecting and editing audio tracks. /// @@ -136,98 +138,45 @@ class _AudioSelectionBottomSheetState extends State { @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildDragHandle(), - _buildHeader(), - Flexible( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.1), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: _showEditControls && _selectedTrack != null - ? AudioEditControlsSection( - key: const ValueKey('edit_controls'), - configs: widget.configs, - audioTrack: _selectedTrack!, - videoDuration: widget.videoDuration, - onBalanceChanged: _handleBalanceChange, - onStartTimeChanged: _handleStartTimeChange, - onChangeTrack: _handleChangeTrack, - onConfirm: _handleConfirm, - ) - : AudioTrackListSection( - key: const ValueKey('track_list'), - configs: widget.configs, - videoDuration: widget.videoDuration, - selectedTrack: _selectedTrack, - onTrackSelected: _handleTrackSelection, - ), - ), - ), - if (!_showEditControls) _buildContinueButton(), - ], - ), - ); - } - - Widget _buildDragHandle() { - final colorScheme = Theme.of(context).colorScheme; - - return Container( - margin: const EdgeInsets.only(top: 12, bottom: 8), - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ); - } - - Widget _buildHeader() { final i18n = widget.configs.i18n.audioEditor; final l10n = AppLocalizations.of(context); - final colorScheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Expanded( - child: Text( - _showEditControls ? i18n.editTrack : l10n.titleSelectSound, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, - ), - ), - ), - if (!_showEditControls) - IconButton( - icon: Icon(Icons.close, color: colorScheme.onSurface), - onPressed: () => Navigator.of(context).pop(), + + return SoundPickerSheetScaffold( + title: _showEditControls ? i18n.editTrack : l10n.titleSelectSound, + onClose: _showEditControls ? null : () => Navigator.of(context).pop(), + footer: _showEditControls ? null : _buildContinueButton(), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(animation), + child: child, ), - ], + ); + }, + child: _showEditControls && _selectedTrack != null + ? AudioEditControlsSection( + key: const ValueKey('edit_controls'), + configs: widget.configs, + audioTrack: _selectedTrack!, + videoDuration: widget.videoDuration, + onBalanceChanged: _handleBalanceChange, + onStartTimeChanged: _handleStartTimeChange, + onChangeTrack: _handleChangeTrack, + onConfirm: _handleConfirm, + ) + : AudioTrackListSection( + key: const ValueKey('track_list'), + configs: widget.configs, + videoDuration: widget.videoDuration, + selectedTrack: _selectedTrack, + onTrackSelected: _handleTrackSelection, + ), ), ); } @@ -239,12 +188,10 @@ class _AudioSelectionBottomSheetState extends State { top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _selectedTrack == null ? null : _handleContinue, - child: Text(l10n.buttonContinue), - ), + child: AppButton( + label: l10n.buttonContinue, + onPressed: _selectedTrack == null ? null : _handleContinue, + fullWidth: true, ), ), ); diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart index 80551d9d..5778181c 100644 --- a/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/audio_track_list_section.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pro_image_editor/features/audio_editor/widgets/audio_track_list_tile.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_provider.dart'; import 'package:spark/src/core/pro_video_editor/providers/sound_picker_search_state.dart'; +import 'package:spark/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart'; /// Displays a scrollable list of audio tracks for selection. class AudioTrackListSection extends ConsumerStatefulWidget { @@ -155,10 +158,9 @@ class _AudioTrackListSectionState extends ConsumerState { final audioTrack = tracks[index]; return Padding( padding: const EdgeInsets.only(bottom: 8), - child: AudioTrackListTile( + child: AudioTrackSelectionTile( configs: widget.configs, audioTrack: audioTrack, - videoDuration: widget.videoDuration, isSelected: audioTrack.id == widget.selectedTrack?.id, onTap: () => widget.onTrackSelected(audioTrack), ), @@ -196,3 +198,101 @@ class _AudioTrackListSectionState extends ConsumerState { ); } } + +class AudioTrackSelectionTile extends StatelessWidget { + const AudioTrackSelectionTile({ + required this.configs, + required this.audioTrack, + required this.isSelected, + required this.onTap, + super.key, + }); + + final ProImageEditorConfigs configs; + final AudioTrack audioTrack; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final imageBackground = configs.audioEditor.style.audioTrackImageBackground; + final borderRadius = BorderRadius.circular(8); + final backgroundColor = isSelected + ? AppColors.primary500.withValues(alpha: 0.12) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.5); + final borderColor = isSelected + ? AppColors.primary500.withValues(alpha: 0.75) + : colorScheme.outline.withValues(alpha: 0.45); + final titleColor = isSelected + ? AppColors.primary500 + : colorScheme.onSurface; + final subtitleColor = colorScheme.onSurfaceVariant; + + return Semantics( + button: true, + selected: isSelected, + child: InteractivePressable( + onTap: onTap, + pressedScale: 0.98, + borderRadius: borderRadius, + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: borderRadius, + border: Border.all(color: borderColor), + ), + child: ClipRRect( + borderRadius: borderRadius, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + SoundArtwork( + imageUrl: audioTrack.image?.networkUrl, + size: 50, + borderRadius: 8, + backgroundColor: imageBackground.withAlpha(80), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + audioTrack.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.textMediumBold.copyWith( + color: titleColor, + ), + ), + const SizedBox(height: 4), + Text( + audioTrack.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.textSmallMedium.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + audioTrack.formattedDuration, + style: AppTypography.textSmallMedium.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart new file mode 100644 index 00000000..22a48fb8 --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/sound_artwork.dart @@ -0,0 +1,69 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class SoundArtwork extends StatelessWidget { + const SoundArtwork({ + required this.size, + super.key, + this.imageUrl, + this.borderRadius = 8, + this.backgroundColor, + }); + + final String? imageUrl; + final double size; + final double borderRadius; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + final normalizedImageUrl = imageUrl?.trim(); + final hasImageUrl = + normalizedImageUrl != null && + normalizedImageUrl.isNotEmpty && + normalizedImageUrl != 'null'; + + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: ColoredBox( + color: backgroundColor ?? Theme.of(context).colorScheme.surface, + child: SizedBox.square( + dimension: size, + child: hasImageUrl + ? CachedNetworkImage( + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + imageUrl: normalizedImageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + _DefaultSoundArtwork(size: size), + errorWidget: (context, url, error) => + _DefaultSoundArtwork(size: size), + ) + : _DefaultSoundArtwork(size: size), + ), + ), + ); + } +} + +class _DefaultSoundArtwork extends StatelessWidget { + const _DefaultSoundArtwork({required this.size}); + + static const _assetName = 'images/profile.svg'; + static const _assetPackage = 'assets'; + + final double size; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + _assetName, + package: _assetPackage, + width: size, + height: size, + fit: BoxFit.cover, + ); + } +} diff --git a/lib/src/core/pro_video_editor/ui/widgets/audio/sound_picker_sheet_scaffold.dart b/lib/src/core/pro_video_editor/ui/widgets/audio/sound_picker_sheet_scaffold.dart new file mode 100644 index 00000000..b65fb1ca --- /dev/null +++ b/lib/src/core/pro_video_editor/ui/widgets/audio/sound_picker_sheet_scaffold.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/typography.dart'; + +class SoundPickerSheetScaffold extends StatelessWidget { + const SoundPickerSheetScaffold({ + required this.title, + required this.child, + super.key, + this.footer, + this.onClose, + }); + + final String title; + final Widget child; + final Widget? footer; + final VoidCallback? onClose; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: AppTypography.textLargeBold.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + ?_buildCloseButton(colorScheme), + ], + ), + ), + Flexible(child: child), + ?footer, + ], + ), + ); + } + + Widget? _buildCloseButton(ColorScheme colorScheme) { + final onClose = this.onClose; + if (onClose == null) return null; + return IconButton( + icon: Icon(Icons.close, color: colorScheme.onSurface), + onPressed: onClose, + ); + } +} diff --git a/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart b/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart index 5bb403be..d8448515 100644 --- a/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart +++ b/lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart @@ -34,6 +34,7 @@ class SideActionBar extends ConsumerStatefulWidget { this.profileImageUrl, this.isImage = false, this.onProfilePressed, + this.onMediaPauseRequested, this.showBlockOption = true, }); final Feed? feed; @@ -45,6 +46,7 @@ class SideActionBar extends ConsumerStatefulWidget { final PostView post; final bool isImage; final VoidCallback? onProfilePressed; + final VoidCallback? onMediaPauseRequested; /// Whether to show the block option in the options panel. /// Set to false for profile feeds where blocking doesn't make sense. @@ -262,12 +264,14 @@ class SideActionBarState extends ConsumerState { } Future _handleShareLongPress() async { + widget.onMediaPauseRequested?.call(); final currentPost = _currentPost ?? widget.post; final shareUrl = _buildShareUrl(currentPost); await SharePlus.instance.share(ShareParams(uri: Uri.parse(shareUrl))); } void _handleShare() { + widget.onMediaPauseRequested?.call(); final currentPost = _currentPost ?? widget.post; final originalAtUri = currentPost.uri.toString(); final shareUrl = _buildShareUrl(currentPost); @@ -283,6 +287,7 @@ class SideActionBarState extends ConsumerState { } void _handleCommentPressed() { + widget.onMediaPauseRequested?.call(); final currentPost = _currentPost ?? widget.post; context.router.push( CommentsRoute( @@ -296,6 +301,7 @@ class SideActionBarState extends ConsumerState { void _handleSoundTap() { final currentPost = _currentPost ?? widget.post; if (currentPost.sound != null) { + widget.onMediaPauseRequested?.call(); context.router.push( SoundRoute(audioUri: currentPost.sound!.uri.toString()), ); @@ -419,15 +425,18 @@ class SideActionBarState extends ConsumerState { onShare: _handleShare, onShareLongPress: _handleShareLongPress, onSoundTap: currentPost.sound != null ? _handleSoundTap : null, - onOptions: () => OptionsPanel.show( - context: context, - onReport: isCurrentUserAuthor ? null : _handleReport, - onDelete: isCurrentUserAuthor ? _handleDeletePost : null, - onBlock: widget.showBlockOption && !isCurrentUserAuthor - ? _handleBlock - : null, - isBlocked: isBlocking(currentPost.author.viewer), - ), + onOptions: () { + widget.onMediaPauseRequested?.call(); + OptionsPanel.show( + context: context, + onReport: isCurrentUserAuthor ? null : _handleReport, + onDelete: isCurrentUserAuthor ? _handleDeletePost : null, + onBlock: widget.showBlockOption && !isCurrentUserAuthor + ? _handleBlock + : null, + isBlocked: isBlocking(currentPost.author.viewer), + ); + }, likeCount: _likeCount.toString(), commentCount: commentCount.toString(), repostCount: _repostCount.toString(), diff --git a/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart b/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart index 8086c4c6..0c82d454 100644 --- a/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart +++ b/lib/src/features/feed/ui/widgets/post/feed_post_widget.dart @@ -12,9 +12,9 @@ import 'package:spark/src/core/ui/widgets/heart_animation.dart'; import 'package:spark/src/core/utils/label_utils.dart'; import 'package:spark/src/features/feed/providers/feed_provider.dart'; import 'package:spark/src/features/feed/providers/like_post.dart'; -import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; +import 'package:spark/src/features/feed/ui/widgets/post/post_media_viewer.dart'; import 'package:spark/src/features/feed/ui/widgets/post/post_overlay.dart'; -import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; +import 'package:spark/src/features/home/providers/feed_settings_visibility_provider.dart'; import 'package:spark/src/features/home/providers/navigation_provider.dart'; import 'package:spark/src/features/settings/providers/preferences_provider.dart'; @@ -31,8 +31,8 @@ class FeedPostWidget extends ConsumerStatefulWidget { class _FeedPostWidgetState extends ConsumerState { Future? _postFuture; String? _lastPostUri; - final GlobalKey _videoPlayerKey = - GlobalKey(); + final GlobalKey _mediaViewerKey = + GlobalKey(); bool _isAnimatingHeart = false; bool _showWarningOverlay = false; bool _userDismissedWarning = false; @@ -157,6 +157,7 @@ class _FeedPostWidgetState extends ConsumerState { // Check if we need to reload post due to state changes final feedState = ref.watch(feedProvider(widget.feed)); final navigationState = ref.watch(navigationProvider); + final feedSettingsVisible = ref.watch(feedSettingsVisibilityProvider); // Check if user is not on feeds tab (index 0) final isOnFeedsTab = navigationState.currentIndex == 0; @@ -219,6 +220,12 @@ class _FeedPostWidgetState extends ConsumerState { } } + final isMediaActive = + isOnFeedsTab && + feedState.index == widget.index && + !feedSettingsVisible && + !_showWarningOverlay; + final mainContent = HeartAnimation( isAnimating: _isAnimatingHeart, bottomOffset: MediaQuery.of(context).padding.bottom, @@ -236,23 +243,15 @@ class _FeedPostWidgetState extends ConsumerState { child: GestureDetector( behavior: HitTestBehavior.opaque, onDoubleTap: () => _handleDoubleTapLike(postData), - child: postData.videoUrl.isNotEmpty - ? PostVideoPlayer( - key: _videoPlayerKey, - videoUrl: postData.videoUrl, + child: + postData.videoUrl.isNotEmpty || + postData.imageUrls.isNotEmpty + ? PostMediaViewer( + key: _mediaViewerKey, + post: currentPost, + isActive: isMediaActive, feed: widget.feed, index: widget.index, - thumbnail: postData.thumbnailUrl, - ) - : postData.imageUrls.isNotEmpty - ? ImageCarousel( - imageUrls: postData.imageUrls, - hasKnownInteractions: - currentPost.viewer?.knownInteractions != null && - currentPost - .viewer! - .knownInteractions! - .isNotEmpty, ) : const DecoratedBox( decoration: BoxDecoration(color: AppColors.black), @@ -268,10 +267,13 @@ class _FeedPostWidgetState extends ConsumerState { _overrideIsLiked ?? (currentPost.viewer?.like != null), labels: labels, onProfilePressed: () { - _videoPlayerKey.currentState?.pauseVideo(); + _mediaViewerKey.currentState?.pauseMedia(); + }, + onMediaPauseRequested: () { + _mediaViewerKey.currentState?.pauseMedia(); }, onUsernameTap: () { - _videoPlayerKey.currentState?.pauseVideo(); + _mediaViewerKey.currentState?.pauseMedia(); final isBskyPost = currentPost.uri.collection .toString() .startsWith('app.bsky'); diff --git a/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart new file mode 100644 index 00000000..771f358f --- /dev/null +++ b/lib/src/features/feed/ui/widgets/post/post_media_viewer.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:spark/src/core/design_system/tokens/colors.dart'; +import 'package:spark/src/core/network/atproto/data/models/feed_models.dart'; +import 'package:spark/src/core/pro_video_editor/models/sound_audio_track.dart'; +import 'package:spark/src/features/feed/ui/widgets/images/image_carousel.dart'; +import 'package:spark/src/features/feed/ui/widgets/post/static_media_sound_player.dart'; +import 'package:spark/src/features/feed/ui/widgets/videos/video_player.dart'; + +class PostMediaViewer extends StatefulWidget { + const PostMediaViewer({ + required this.post, + required this.isActive, + super.key, + this.feed, + this.index, + this.profileFeedUri, + this.isInitialPost = false, + }); + + final PostView post; + final bool isActive; + final Feed? feed; + final int? index; + final String? profileFeedUri; + final bool isInitialPost; + + @override + State createState() => PostMediaViewerState(); +} + +class PostMediaViewerState extends State { + final GlobalKey _videoPlayerKey = + GlobalKey(); + final StaticMediaSoundController _staticSoundController = + StaticMediaSoundController(); + + @override + void dispose() { + unawaited(_staticSoundController.dispose()); + super.dispose(); + } + + void pauseMedia() { + _videoPlayerKey.currentState?.pauseVideo(); + unawaited(_staticSoundController.sync(audioUrl: null, shouldPlay: false)); + } + + @override + Widget build(BuildContext context) { + final post = widget.post; + + if (post.videoUrl.isNotEmpty) { + return PostVideoPlayer( + key: _videoPlayerKey, + videoUrl: post.videoUrl, + thumbnail: post.thumbnailUrl, + feed: widget.feed, + index: widget.index, + profileFeedUri: widget.profileFeedUri, + isInitialPost: widget.isInitialPost, + ); + } + + if (post.imageUrls.isNotEmpty) { + final sound = post.localSound; + return StaticMediaSoundPlayer( + audioUrl: sound == null ? null : playableAudioUrl(sound), + mimeType: sound == null ? null : audioMimeType(sound), + shouldPlay: widget.isActive, + controller: _staticSoundController, + child: ImageCarousel( + imageUrls: post.imageUrls, + hasKnownInteractions: + post.viewer?.knownInteractions != null && + post.viewer!.knownInteractions!.isNotEmpty, + ), + ); + } + + return const DecoratedBox( + decoration: BoxDecoration(color: AppColors.black), + ); + } +} diff --git a/lib/src/features/feed/ui/widgets/post/post_overlay.dart b/lib/src/features/feed/ui/widgets/post/post_overlay.dart index 7ea37965..a8f40b92 100644 --- a/lib/src/features/feed/ui/widgets/post/post_overlay.dart +++ b/lib/src/features/feed/ui/widgets/post/post_overlay.dart @@ -16,6 +16,7 @@ class PostOverlay extends ConsumerWidget { this.isLiked = false, this.onProfilePressed, this.onUsernameTap, + this.onMediaPauseRequested, this.labels = const [], this.showBlockOption = true, }); @@ -25,6 +26,7 @@ class PostOverlay extends ConsumerWidget { final bool isLiked; final VoidCallback? onProfilePressed; final VoidCallback? onUsernameTap; + final VoidCallback? onMediaPauseRequested; final List