Merge branch 'feat/video-embed-support'

+3
lib/main.dart
···
import 'screens/auth/login_screen.dart';
import 'screens/home/main_shell_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
void main() async {
···
);
},
),
+
// StreamableService for video embeds
+
Provider<StreamableService>(create: (_) => StreamableService()),
],
child: const CovesApp(),
),
+21
lib/models/post.dart
···
this.description,
this.thumb,
this.domain,
+
this.embedType,
+
this.provider,
+
this.images,
+
this.totalCount,
});
factory ExternalEmbed.fromJson(Map<String, dynamic> json) {
+
// Thumb is always a string URL (backend transforms blob refs before sending)
+
+
// Handle images array if present
+
List<Map<String, dynamic>>? imagesList;
+
if (json['images'] != null && json['images'] is List) {
+
imagesList =
+
(json['images'] as List).whereType<Map<String, dynamic>>().toList();
+
}
+
return ExternalEmbed(
uri: json['uri'] as String,
title: json['title'] as String?,
description: json['description'] as String?,
thumb: json['thumb'] as String?,
domain: json['domain'] as String?,
+
embedType: json['embedType'] as String?,
+
provider: json['provider'] as String?,
+
images: imagesList,
+
totalCount: json['totalCount'] as int?,
);
}
final String uri;
···
final String? description;
final String? thumb;
final String? domain;
+
final String? embedType;
+
final String? provider;
+
final List<Map<String, dynamic>>? images;
+
final int? totalCount;
}
class PostFacet {
+151
lib/services/streamable_service.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
/// Service for interacting with Streamable API
+
///
+
/// Fetches video data from Streamable to get direct MP4 URLs
+
/// for in-app video playback.
+
///
+
/// Implements caching to reduce API calls for recently accessed videos.
+
class StreamableService {
+
StreamableService({Dio? dio}) : _dio = dio ?? _sharedDio;
+
+
// Singleton Dio instance for efficient connection reuse
+
static final Dio _sharedDio = Dio(
+
BaseOptions(
+
connectTimeout: const Duration(seconds: 10),
+
receiveTimeout: const Duration(seconds: 10),
+
sendTimeout: const Duration(seconds: 10),
+
),
+
);
+
+
final Dio _dio;
+
+
// Cache for video URLs (shortcode -> {url, timestamp})
+
// Short-lived cache (5 min) to reduce API calls
+
final Map<String, ({String url, DateTime cachedAt})> _urlCache = {};
+
static const Duration _cacheDuration = Duration(minutes: 5);
+
+
/// Extracts the Streamable shortcode from a URL
+
///
+
/// Examples:
+
/// - https://streamable.com/7kpdft -> 7kpdft
+
/// - https://streamable.com/e/abc123 -> abc123
+
/// - streamable.com/abc123 -> abc123
+
static String? extractShortcode(String url) {
+
try {
+
// Handle URLs without scheme
+
var urlToParse = url;
+
if (!url.contains('://')) {
+
urlToParse = 'https://$url';
+
}
+
+
final uri = Uri.parse(urlToParse);
+
final path = uri.path;
+
+
// Get the last non-empty path segment (handles /e/ prefix and other cases)
+
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
+
if (segments.isEmpty) {
+
return null;
+
}
+
+
final shortcode = segments.last;
+
+
if (shortcode.isEmpty) {
+
return null;
+
}
+
+
return shortcode;
+
} on FormatException catch (e) {
+
if (kDebugMode) {
+
debugPrint('Error extracting Streamable shortcode: $e');
+
}
+
return null;
+
}
+
}
+
+
/// Fetches the MP4 video URL for a Streamable video
+
///
+
/// Returns the direct MP4 URL or null if the video cannot be fetched.
+
/// Uses a 5-minute cache to reduce API calls for repeated access.
+
Future<String?> getVideoUrl(String streamableUrl) async {
+
try {
+
final shortcode = extractShortcode(streamableUrl);
+
if (shortcode == null) {
+
if (kDebugMode) {
+
debugPrint('Failed to extract shortcode from: $streamableUrl');
+
}
+
return null;
+
}
+
+
// Check cache first
+
final cached = _urlCache[shortcode];
+
if (cached != null) {
+
final age = DateTime.now().difference(cached.cachedAt);
+
if (age < _cacheDuration) {
+
if (kDebugMode) {
+
debugPrint('Using cached URL for shortcode: $shortcode');
+
}
+
return cached.url;
+
}
+
// Cache expired, remove it
+
_urlCache.remove(shortcode);
+
}
+
+
// Fetch video data from Streamable API
+
final response = await _dio.get<Map<String, dynamic>>(
+
'https://api.streamable.com/videos/$shortcode',
+
);
+
+
if (response.statusCode == 200 && response.data != null) {
+
final data = response.data!;
+
+
// Extract MP4 URL from response
+
// Response structure: { "files": { "mp4": { "url": "//..." } } }
+
final files = data['files'] as Map<String, dynamic>?;
+
if (files == null) {
+
if (kDebugMode) {
+
debugPrint('No files found in Streamable response');
+
}
+
return null;
+
}
+
+
final mp4 = files['mp4'] as Map<String, dynamic>?;
+
if (mp4 == null) {
+
if (kDebugMode) {
+
debugPrint('No MP4 file found in Streamable response');
+
}
+
return null;
+
}
+
+
var videoUrl = mp4['url'] as String?;
+
if (videoUrl == null) {
+
if (kDebugMode) {
+
debugPrint('No URL found in MP4 data');
+
}
+
return null;
+
}
+
+
// Prepend https: if URL is protocol-relative
+
if (videoUrl.startsWith('//')) {
+
videoUrl = 'https:$videoUrl';
+
}
+
+
// Cache the URL for future requests
+
_urlCache[shortcode] = (url: videoUrl, cachedAt: DateTime.now());
+
+
return videoUrl;
+
}
+
+
if (kDebugMode) {
+
debugPrint('Failed to fetch Streamable video: ${response.statusCode}');
+
}
+
return null;
+
} on DioException catch (e) {
+
if (kDebugMode) {
+
debugPrint('Error fetching Streamable video URL: $e');
+
}
+
return null;
+
}
+
}
+
}
+172
lib/widgets/fullscreen_video_player.dart
···
+
import 'package:flutter/foundation.dart';
+
import 'package:flutter/material.dart';
+
import 'package:video_player/video_player.dart';
+
+
import '../constants/app_colors.dart';
+
import 'minimal_video_controls.dart';
+
+
/// Fullscreen video player with swipe-to-dismiss gesture
+
///
+
/// Displays the video player in fullscreen with a black background.
+
/// Supports vertical swipe-down gesture to dismiss (like Instagram/TikTok).
+
class FullscreenVideoPlayer extends StatefulWidget {
+
const FullscreenVideoPlayer({required this.videoUrl, super.key});
+
+
final String videoUrl;
+
+
@override
+
State<FullscreenVideoPlayer> createState() => _FullscreenVideoPlayerState();
+
}
+
+
class _FullscreenVideoPlayerState extends State<FullscreenVideoPlayer>
+
with WidgetsBindingObserver {
+
double _dragOffsetX = 0;
+
double _dragOffsetY = 0;
+
bool _isDragging = false;
+
VideoPlayerController? _videoController;
+
bool _isInitializing = true;
+
+
@override
+
void initState() {
+
super.initState();
+
WidgetsBinding.instance.addObserver(this);
+
_initializePlayer();
+
}
+
+
@override
+
void dispose() {
+
WidgetsBinding.instance.removeObserver(this);
+
_videoController?.dispose();
+
super.dispose();
+
}
+
+
@override
+
void didChangeAppLifecycleState(AppLifecycleState state) {
+
// Pause video when app goes to background
+
if (state == AppLifecycleState.paused ||
+
state == AppLifecycleState.inactive) {
+
_videoController?.pause();
+
}
+
}
+
+
Future<void> _initializePlayer() async {
+
try {
+
_videoController = VideoPlayerController.networkUrl(
+
Uri.parse(widget.videoUrl),
+
);
+
+
await _videoController!.initialize();
+
await _videoController!.play();
+
+
if (mounted) {
+
setState(() {
+
_isInitializing = false;
+
});
+
}
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('Error initializing video: $e');
+
}
+
if (mounted) {
+
setState(() {
+
_isInitializing = false;
+
});
+
}
+
}
+
}
+
+
void _onPanUpdate(DragUpdateDetails details) {
+
setState(() {
+
_isDragging = true;
+
// Track both horizontal and vertical movement
+
_dragOffsetX += details.delta.dx;
+
_dragOffsetY += details.delta.dy;
+
});
+
}
+
+
void _onPanEnd(DragEndDetails details) {
+
// If dragged more than 100 pixels vertically, dismiss
+
if (_dragOffsetY.abs() > 100) {
+
Navigator.of(context).pop();
+
} else {
+
// Otherwise, animate back to original position
+
setState(() {
+
_dragOffsetX = 0.0;
+
_dragOffsetY = 0.0;
+
_isDragging = false;
+
});
+
}
+
}
+
+
void _togglePlayPause() {
+
if (_videoController == null || !_videoController!.value.isInitialized) {
+
return;
+
}
+
+
setState(() {
+
if (_videoController!.value.isPlaying) {
+
_videoController!.pause();
+
} else {
+
_videoController!.play();
+
}
+
});
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
// Calculate opacity based on drag offset (fade out as user drags)
+
final opacity = (1.0 - (_dragOffsetY.abs() / 300)).clamp(0.0, 1.0);
+
+
return Scaffold(
+
backgroundColor: Colors.black.withValues(alpha: opacity),
+
body: GestureDetector(
+
onPanUpdate: _onPanUpdate,
+
onPanEnd: _onPanEnd,
+
onTap: _togglePlayPause,
+
child: Stack(
+
children: [
+
// Video player - fills entire screen and moves with drag
+
AnimatedContainer(
+
duration:
+
_isDragging
+
? Duration.zero
+
: const Duration(milliseconds: 200),
+
curve: Curves.easeOut,
+
transform: Matrix4.translationValues(
+
_dragOffsetX,
+
_dragOffsetY,
+
0,
+
),
+
child: SizedBox.expand(
+
child:
+
_isInitializing || _videoController == null
+
? const Center(
+
child: CircularProgressIndicator(
+
color: AppColors.loadingIndicator,
+
),
+
)
+
: Center(
+
child: AspectRatio(
+
aspectRatio: _videoController!.value.aspectRatio,
+
child: VideoPlayer(_videoController!),
+
),
+
),
+
),
+
),
+
// Minimal controls at bottom (scrubber only)
+
if (_videoController != null &&
+
_videoController!.value.isInitialized)
+
Positioned(
+
bottom: 0,
+
left: 0,
+
right: 0,
+
child: SafeArea(
+
child: MinimalVideoControls(controller: _videoController!),
+
),
+
),
+
],
+
),
+
),
+
);
+
}
+
}
+144
lib/widgets/minimal_video_controls.dart
···
+
import 'package:flutter/material.dart';
+
import 'package:video_player/video_player.dart';
+
+
import '../constants/app_colors.dart';
+
+
/// Minimal video controls showing only a scrubber/progress bar
+
///
+
/// Always visible at the bottom of the video, positioned above
+
/// the Android navigation bar using SafeArea.
+
class MinimalVideoControls extends StatefulWidget {
+
const MinimalVideoControls({
+
required this.controller,
+
super.key,
+
});
+
+
final VideoPlayerController controller;
+
+
@override
+
State<MinimalVideoControls> createState() => _MinimalVideoControlsState();
+
}
+
+
class _MinimalVideoControlsState extends State<MinimalVideoControls> {
+
double _sliderValue = 0;
+
bool _isUserDragging = false;
+
+
@override
+
void initState() {
+
super.initState();
+
widget.controller.addListener(_updateSlider);
+
}
+
+
@override
+
void dispose() {
+
widget.controller.removeListener(_updateSlider);
+
super.dispose();
+
}
+
+
void _updateSlider() {
+
if (!_isUserDragging && mounted) {
+
final position =
+
widget.controller.value.position.inMilliseconds.toDouble();
+
final duration =
+
widget.controller.value.duration.inMilliseconds.toDouble();
+
+
if (duration > 0) {
+
setState(() {
+
_sliderValue = position / duration;
+
});
+
}
+
}
+
}
+
+
void _onSliderChanged(double value) {
+
setState(() {
+
_sliderValue = value;
+
});
+
}
+
+
void _onSliderChangeStart(double value) {
+
_isUserDragging = true;
+
}
+
+
void _onSliderChangeEnd(double value) {
+
_isUserDragging = false;
+
final duration = widget.controller.value.duration;
+
final position = duration * value;
+
widget.controller.seekTo(position);
+
}
+
+
String _formatDuration(Duration duration) {
+
final minutes = duration.inMinutes;
+
final seconds = duration.inSeconds % 60;
+
return '${minutes.toString().padLeft(1, '0')}:'
+
'${seconds.toString().padLeft(2, '0')}';
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
final position = widget.controller.value.position;
+
final duration = widget.controller.value.duration;
+
+
return Container(
+
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+
decoration: BoxDecoration(
+
gradient: LinearGradient(
+
begin: Alignment.bottomCenter,
+
end: Alignment.topCenter,
+
colors: [
+
Colors.black.withValues(alpha: 0.7),
+
Colors.black.withValues(alpha: 0),
+
],
+
),
+
),
+
child: Column(
+
mainAxisSize: MainAxisSize.min,
+
children: [
+
// Scrubber slider
+
SliderTheme(
+
data: SliderThemeData(
+
trackHeight: 3,
+
thumbShape:
+
const RoundSliderThumbShape(enabledThumbRadius: 6),
+
overlayShape:
+
const RoundSliderOverlayShape(overlayRadius: 12),
+
activeTrackColor: AppColors.primary,
+
inactiveTrackColor: Colors.white.withValues(alpha: 0.3),
+
thumbColor: AppColors.primary,
+
overlayColor: AppColors.primary.withValues(alpha: 0.3),
+
),
+
child: Slider(
+
value: _sliderValue.clamp(0, 1.0),
+
onChanged: _onSliderChanged,
+
onChangeStart: _onSliderChangeStart,
+
onChangeEnd: _onSliderChangeEnd,
+
),
+
),
+
// Time labels
+
Padding(
+
padding: const EdgeInsets.symmetric(horizontal: 8),
+
child: Row(
+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+
children: [
+
Text(
+
_formatDuration(position),
+
style: const TextStyle(
+
color: Colors.white,
+
fontSize: 12,
+
),
+
),
+
Text(
+
_formatDuration(duration),
+
style: TextStyle(
+
color: Colors.white.withValues(alpha: 0.7),
+
fontSize: 12,
+
),
+
),
+
],
+
),
+
),
+
],
+
),
+
);
+
}
+
}
+121 -6
lib/widgets/post_card.dart
···
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+
import 'package:provider/provider.dart';
import '../constants/app_colors.dart';
import '../models/post.dart';
+
import '../services/streamable_service.dart';
import '../utils/community_handle_utils.dart';
import '../utils/date_time_utils.dart';
import 'external_link_bar.dart';
+
import 'fullscreen_video_player.dart';
import 'post_card_actions.dart';
/// Post card widget for displaying feed posts
···
// Embed (link preview)
if (post.post.embed?.external != null) ...[
-
_EmbedCard(embed: post.post.embed!.external!),
+
_EmbedCard(
+
embed: post.post.embed!.external!,
+
streamableService: context.read<StreamableService>(),
+
),
const SizedBox(height: 8),
],
···
/// Embed card widget for displaying link previews
///
/// Shows a thumbnail image for external embeds with loading and error states.
-
class _EmbedCard extends StatelessWidget {
-
const _EmbedCard({required this.embed});
+
/// For video embeds (Streamable), displays a play button overlay and opens
+
/// a video player dialog when tapped.
+
class _EmbedCard extends StatefulWidget {
+
const _EmbedCard({required this.embed, required this.streamableService});
final ExternalEmbed embed;
+
final StreamableService streamableService;
+
+
@override
+
State<_EmbedCard> createState() => _EmbedCardState();
+
}
+
+
class _EmbedCardState extends State<_EmbedCard> {
+
bool _isLoadingVideo = false;
+
+
/// Checks if this embed is a video
+
bool get _isVideo {
+
final embedType = widget.embed.embedType;
+
return embedType == 'video' || embedType == 'video-stream';
+
}
+
+
/// Checks if this is a Streamable video
+
bool get _isStreamableVideo {
+
return _isVideo && widget.embed.provider?.toLowerCase() == 'streamable';
+
}
+
+
/// Shows the video player in fullscreen with swipe-to-dismiss
+
Future<void> _showVideoPlayer(BuildContext context) async {
+
// Capture context-dependent objects before async gap
+
final messenger = ScaffoldMessenger.of(context);
+
final navigator = Navigator.of(context);
+
+
setState(() {
+
_isLoadingVideo = true;
+
});
+
+
try {
+
// Fetch the MP4 URL from Streamable using the injected service
+
final videoUrl = await widget.streamableService.getVideoUrl(
+
widget.embed.uri,
+
);
+
+
if (!mounted) {
+
return;
+
}
+
+
if (videoUrl == null) {
+
// Show error if we couldn't get the video URL
+
messenger.showSnackBar(
+
SnackBar(
+
content: Text(
+
'Failed to load video',
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.9),
+
),
+
),
+
backgroundColor: AppColors.backgroundSecondary,
+
),
+
);
+
return;
+
}
+
+
// Navigate to fullscreen video player
+
await navigator.push<void>(
+
MaterialPageRoute(
+
builder: (context) => FullscreenVideoPlayer(videoUrl: videoUrl),
+
fullscreenDialog: true,
+
),
+
);
+
} finally {
+
if (mounted) {
+
setState(() {
+
_isLoadingVideo = false;
+
});
+
}
+
}
+
}
@override
Widget build(BuildContext context) {
// Only show image if thumbnail exists
-
if (embed.thumb == null) {
+
if (widget.embed.thumb == null) {
return const SizedBox.shrink();
}
-
return Container(
+
// Build the thumbnail image
+
final thumbnailWidget = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
clipBehavior: Clip.antiAlias,
child: CachedNetworkImage(
-
imageUrl: embed.thumb!,
+
imageUrl: widget.embed.thumb!,
width: double.infinity,
height: 180,
fit: BoxFit.cover,
···
},
),
);
+
+
// If this is a Streamable video, add play button overlay and tap handler
+
if (_isStreamableVideo) {
+
return GestureDetector(
+
onTap: _isLoadingVideo ? null : () => _showVideoPlayer(context),
+
child: Stack(
+
alignment: Alignment.center,
+
children: [
+
thumbnailWidget,
+
// Semi-transparent play button or loading indicator overlay
+
Container(
+
width: 64,
+
height: 64,
+
decoration: BoxDecoration(
+
color: AppColors.background.withValues(alpha: 0.7),
+
shape: BoxShape.circle,
+
),
+
child:
+
_isLoadingVideo
+
? const CircularProgressIndicator(
+
color: AppColors.loadingIndicator,
+
)
+
: const Icon(
+
Icons.play_arrow,
+
color: AppColors.textPrimary,
+
size: 48,
+
),
+
),
+
],
+
),
+
);
+
}
+
+
// For non-video embeds, just return the thumbnail
+
return thumbnailWidget;
}
}
+2
macos/Flutter/GeneratedPluginRegistrant.swift
···
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
+
import video_player_avfoundation
import window_to_front
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
···
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
+
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin"))
}
+72
pubspec.lock
···
url: "https://pub.dev"
source: hosted
version: "3.0.6"
+
csslib:
+
dependency: transitive
+
description:
+
name: csslib
+
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
+
url: "https://pub.dev"
+
source: hosted
+
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
···
url: "https://pub.dev"
source: hosted
version: "2.3.2"
+
html:
+
dependency: transitive
+
description:
+
name: html
+
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
+
url: "https://pub.dev"
+
source: hosted
+
version: "0.15.6"
http:
dependency: "direct dev"
description:
···
url: "https://pub.dev"
source: hosted
version: "1.5.0"
+
http_mock_adapter:
+
dependency: "direct dev"
+
description:
+
name: http_mock_adapter
+
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
+
url: "https://pub.dev"
+
source: hosted
+
version: "0.6.1"
http_multi_server:
dependency: transitive
description:
···
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+
logger:
+
dependency: transitive
+
description:
+
name: logger
+
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
+
url: "https://pub.dev"
+
source: hosted
+
version: "2.6.2"
logging:
dependency: transitive
description:
···
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+
video_player:
+
dependency: "direct main"
+
description:
+
name: video_player
+
sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a"
+
url: "https://pub.dev"
+
source: hosted
+
version: "2.10.0"
+
video_player_android:
+
dependency: transitive
+
description:
+
name: video_player_android
+
sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340
+
url: "https://pub.dev"
+
source: hosted
+
version: "2.8.17"
+
video_player_avfoundation:
+
dependency: transitive
+
description:
+
name: video_player_avfoundation
+
sha256: "19ed1162a7a5520e7d7791e0b7b73ba03161b6a69428b82e4689e435b325432d"
+
url: "https://pub.dev"
+
source: hosted
+
version: "2.8.5"
+
video_player_platform_interface:
+
dependency: transitive
+
description:
+
name: video_player_platform_interface
+
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
+
url: "https://pub.dev"
+
source: hosted
+
version: "6.6.0"
+
video_player_web:
+
dependency: transitive
+
description:
+
name: video_player_web
+
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
+
url: "https://pub.dev"
+
source: hosted
+
version: "2.4.0"
vm_service:
dependency: transitive
description:
+3 -1
pubspec.yaml
···
dio: ^5.9.0
cached_network_image: ^3.4.1
url_launcher: ^6.3.1
+
video_player: ^2.8.7 # Pinned for stability
dev_dependencies:
flutter_test:
···
# Testing dependencies
mockito: ^5.4.4
build_runner: ^2.4.13
+
http: any
+
http_mock_adapter: ^0.6.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
-
http: any
flutter:
# The following line ensures that the Material Icons font is
+280
test/services/streamable_service_test.dart
···
+
import 'package:coves_flutter/services/streamable_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:http_mock_adapter/http_mock_adapter.dart';
+
+
void main() {
+
group('StreamableService', () {
+
late Dio dio;
+
late DioAdapter dioAdapter;
+
late StreamableService service;
+
+
setUp(() {
+
dio = Dio();
+
dioAdapter = DioAdapter(dio: dio);
+
service = StreamableService(dio: dio);
+
});
+
+
group('extractShortcode', () {
+
test('extracts shortcode from standard URL', () {
+
expect(
+
StreamableService.extractShortcode('https://streamable.com/abc123'),
+
'abc123',
+
);
+
});
+
+
test('extracts shortcode from /e/ URL', () {
+
expect(
+
StreamableService.extractShortcode('https://streamable.com/e/abc123'),
+
'abc123',
+
);
+
});
+
+
test('extracts shortcode from URL without scheme', () {
+
expect(
+
StreamableService.extractShortcode('streamable.com/xyz789'),
+
'xyz789',
+
);
+
});
+
+
test('extracts shortcode from /e/ URL without scheme', () {
+
expect(
+
StreamableService.extractShortcode('streamable.com/e/xyz789'),
+
'xyz789',
+
);
+
});
+
+
test('returns null for empty path', () {
+
expect(
+
StreamableService.extractShortcode('https://streamable.com/'),
+
null,
+
);
+
});
+
+
test('returns null for invalid URL', () {
+
expect(StreamableService.extractShortcode('not a url'), null);
+
});
+
+
test('handles URL with query parameters', () {
+
expect(
+
StreamableService.extractShortcode(
+
'https://streamable.com/abc123?autoplay=1',
+
),
+
'abc123',
+
);
+
});
+
+
test('handles /e/ URL with query parameters', () {
+
expect(
+
StreamableService.extractShortcode(
+
'https://streamable.com/e/abc123?autoplay=1',
+
),
+
'abc123',
+
);
+
});
+
});
+
+
group('getVideoUrl', () {
+
test('fetches and returns MP4 URL successfully', () async {
+
const shortcode = 'abc123';
+
const videoUrl = '//cdn.streamable.com/video/mp4/abc123.mp4';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': videoUrl},
+
},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, 'https:$videoUrl');
+
});
+
+
test('handles /e/ URL format', () async {
+
const shortcode = 'xyz789';
+
const videoUrl = '//cdn.streamable.com/video/mp4/xyz789.mp4';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': videoUrl},
+
},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/e/$shortcode',
+
);
+
+
expect(result, 'https:$videoUrl');
+
});
+
+
test('caches video URLs', () async {
+
const shortcode = 'cached123';
+
const videoUrl = '//cdn.streamable.com/video/mp4/cached123.mp4';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': videoUrl},
+
},
+
}),
+
);
+
+
// First call - should hit the API
+
final result1 = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
expect(result1, 'https:$videoUrl');
+
+
// Second call - should use cache (no additional network request)
+
final result2 = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
expect(result2, 'https:$videoUrl');
+
});
+
+
test('returns null for invalid shortcode', () async {
+
const shortcode = 'invalid';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(404, {'error': 'Not found'}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, null);
+
});
+
+
test('returns null when files field is missing', () async {
+
const shortcode = 'nofiles123';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {'status': 'ok'}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, null);
+
});
+
+
test('returns null when mp4 field is missing', () async {
+
const shortcode = 'nomp4123';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {'webm': {}},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, null);
+
});
+
+
test('returns null when URL field is missing', () async {
+
const shortcode = 'nourl123';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'status': 'processing'},
+
},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, null);
+
});
+
+
test('returns null on network error', () async {
+
const shortcode = 'error500';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.throws(
+
500,
+
DioException(
+
requestOptions: RequestOptions(
+
path: 'https://api.streamable.com/videos/$shortcode',
+
),
+
),
+
),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, null);
+
});
+
+
test('returns null when shortcode extraction fails', () async {
+
final result = await service.getVideoUrl('invalid-url');
+
expect(result, null);
+
});
+
+
test('prepends https to protocol-relative URLs', () async {
+
const shortcode = 'protocol123';
+
const videoUrl = '//cdn.streamable.com/video/mp4/protocol123.mp4';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': videoUrl},
+
},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, startsWith('https://'));
+
expect(result, 'https:$videoUrl');
+
});
+
+
test('does not modify URLs that already have protocol', () async {
+
const shortcode = 'hasprotocol123';
+
const videoUrl =
+
'https://cdn.streamable.com/video/mp4/hasprotocol123.mp4';
+
+
dioAdapter.onGet(
+
'https://api.streamable.com/videos/$shortcode',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': videoUrl},
+
},
+
}),
+
);
+
+
final result = await service.getVideoUrl(
+
'https://streamable.com/$shortcode',
+
);
+
+
expect(result, videoUrl);
+
});
+
});
+
});
+
}
+368 -180
test/widgets/post_card_test.dart
···
import 'package:coves_flutter/models/post.dart';
+
import 'package:coves_flutter/services/streamable_service.dart';
import 'package:coves_flutter/widgets/post_card.dart';
+
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
+
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:provider/provider.dart';
import '../test_helpers/mock_providers.dart';
···
mockVoteProvider = MockVoteProvider();
});
-
Widget createTestWidget(FeedViewPost post) {
+
Widget createTestWidget(
+
FeedViewPost post, {
+
StreamableService? streamableService,
+
}) {
return MultiProvider(
providers: [
// ignore: argument_type_not_assignable
ChangeNotifierProvider<MockAuthProvider>.value(value: mockAuthProvider),
// ignore: argument_type_not_assignable
ChangeNotifierProvider<MockVoteProvider>.value(value: mockVoteProvider),
+
Provider<StreamableService>.value(
+
value: streamableService ?? StreamableService(),
+
),
],
child: MaterialApp(home: Scaffold(body: PostCard(post: post))),
);
}
-
group('PostCard', skip: 'Provider type compatibility issues - needs mock refactoring', () {
-
testWidgets('renders all basic components', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
-
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: 'Test post content',
-
title: 'Test Post Title',
-
stats: PostStats(
-
upvotes: 10,
-
downvotes: 2,
-
score: 8,
-
commentCount: 5,
+
group(
+
'PostCard',
+
skip: 'Provider type compatibility issues - needs mock refactoring',
+
() {
+
testWidgets('renders all basic components', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: 'Test post content',
+
title: 'Test Post Title',
+
stats: PostStats(
+
upvotes: 10,
+
downvotes: 2,
+
score: 8,
+
commentCount: 5,
+
),
),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
+
await tester.pumpWidget(createTestWidget(post));
-
// Verify title is displayed
-
expect(find.text('Test Post Title'), findsOneWidget);
+
// Verify title is displayed
+
expect(find.text('Test Post Title'), findsOneWidget);
-
// Verify community name is displayed
-
expect(find.text('c/test-community'), findsOneWidget);
+
// Verify community name is displayed
+
expect(find.text('c/test-community'), findsOneWidget);
-
// Verify author handle is displayed
-
expect(find.text('@author.test'), findsOneWidget);
+
// Verify author handle is displayed
+
expect(find.text('@author.test'), findsOneWidget);
-
// Verify text content is displayed
-
expect(find.text('Test post content'), findsOneWidget);
+
// Verify text content is displayed
+
expect(find.text('Test post content'), findsOneWidget);
-
// Verify stats are displayed
-
expect(find.text('8'), findsOneWidget); // score
-
expect(find.text('5'), findsOneWidget); // comment count
-
});
+
// Verify stats are displayed
+
expect(find.text('8'), findsOneWidget); // score
+
expect(find.text('5'), findsOneWidget); // comment count
+
});
-
testWidgets('displays community avatar when available', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
-
avatar: 'https://example.com/avatar.jpg',
+
testWidgets('displays community avatar when available', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
avatar: 'https://example.com/avatar.jpg',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: '',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
-
await tester.pumpAndSettle();
+
await tester.pumpWidget(createTestWidget(post));
+
await tester.pumpAndSettle();
-
// Avatar image should be present
-
expect(find.byType(Image), findsWidgets);
-
});
+
// Avatar image should be present
+
expect(find.byType(Image), findsWidgets);
+
});
-
testWidgets('shows fallback avatar when no avatar URL', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'TestCommunity',
+
testWidgets('shows fallback avatar when no avatar URL', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'TestCommunity',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: '',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
+
await tester.pumpWidget(createTestWidget(post));
-
// Verify fallback shows first letter
-
expect(find.text('T'), findsOneWidget);
-
});
+
// Verify fallback shows first letter
+
expect(find.text('T'), findsOneWidget);
+
});
-
testWidgets('displays external link bar when embed present', (
-
tester,
-
) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
-
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: '',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
embed: PostEmbed(
-
type: 'social.coves.embed.external',
-
external: ExternalEmbed(
-
uri: 'https://example.com/article',
-
domain: 'example.com',
-
title: 'Example Article',
+
testWidgets('displays external link bar when embed present', (
+
tester,
+
) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: 'https://example.com/article',
+
domain: 'example.com',
+
title: 'Example Article',
+
),
+
data: const {},
),
-
data: const {},
),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
+
await tester.pumpWidget(createTestWidget(post));
-
// Verify external link bar is present
-
expect(find.text('example.com'), findsOneWidget);
-
expect(find.byIcon(Icons.open_in_new), findsOneWidget);
-
});
+
// Verify external link bar is present
+
expect(find.text('example.com'), findsOneWidget);
+
expect(find.byIcon(Icons.open_in_new), findsOneWidget);
+
});
-
testWidgets('displays embed image when available', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
+
testWidgets('displays embed image when available', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: 'https://example.com/article',
+
thumb: 'https://example.com/thumb.jpg',
+
),
+
data: const {},
+
),
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: '',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
embed: PostEmbed(
-
type: 'social.coves.embed.external',
-
external: ExternalEmbed(
-
uri: 'https://example.com/article',
-
thumb: 'https://example.com/thumb.jpg',
+
);
+
+
await tester.pumpWidget(createTestWidget(post));
+
await tester.pump();
+
+
// Embed image should be loading/present
+
expect(find.byType(Image), findsWidgets);
+
});
+
+
testWidgets('renders without title', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: 'Just body text',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
),
-
data: const {},
),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
-
await tester.pump();
+
await tester.pumpWidget(createTestWidget(post));
-
// Embed image should be loading/present
-
expect(find.byType(Image), findsWidgets);
-
});
+
// Should render without errors
+
expect(find.text('Just body text'), findsOneWidget);
+
expect(find.text('c/test-community'), findsOneWidget);
+
});
-
testWidgets('renders without title', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
+
testWidgets('has action buttons', (tester) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: 'Just body text',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
),
-
);
+
);
-
await tester.pumpWidget(createTestWidget(post));
+
await tester.pumpWidget(createTestWidget(post));
-
// Should render without errors
-
expect(find.text('Just body text'), findsOneWidget);
-
expect(find.text('c/test-community'), findsOneWidget);
-
});
+
// Verify action buttons are present
+
expect(find.byIcon(Icons.more_horiz), findsOneWidget); // menu
+
// Share, comment, and heart icons are custom widgets, verify by count
+
expect(find.byType(InkWell), findsWidgets);
+
});
-
testWidgets('has action buttons', (tester) async {
-
final post = FeedViewPost(
-
post: PostView(
-
uri: 'at://did:example/post/123',
-
cid: 'cid123',
-
rkey: '123',
-
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
-
community: CommunityRef(
-
did: 'did:plc:community',
-
name: 'test-community',
+
testWidgets('displays play button overlay for Streamable videos', (
+
tester,
+
) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: 'https://streamable.com/abc123',
+
thumb: 'https://example.com/thumb.jpg',
+
embedType: 'video',
+
provider: 'streamable',
+
),
+
data: const {},
+
),
),
-
createdAt: DateTime(2024, 1, 1),
-
indexedAt: DateTime(2024, 1, 1),
-
text: '',
-
stats: PostStats(upvotes: 0, downvotes: 0, score: 0, commentCount: 0),
-
),
+
);
+
+
await tester.pumpWidget(createTestWidget(post));
+
await tester.pump();
+
+
// Verify play button is displayed
+
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
+
});
+
+
testWidgets(
+
'shows loading indicator when fetching video URL for Streamable',
+
(tester) async {
+
final dio = Dio(BaseOptions(baseUrl: 'https://api.streamable.com'));
+
final dioAdapter = DioAdapter(dio: dio);
+
final streamableService = StreamableService(dio: dio);
+
+
// Delay the response to test loading state
+
dioAdapter.onGet(
+
'/videos/abc123',
+
(server) => server.reply(200, {
+
'files': {
+
'mp4': {'url': '//cdn.streamable.com/video.mp4'},
+
},
+
}, delay: const Duration(milliseconds: 500)),
+
);
+
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: 'https://streamable.com/abc123',
+
thumb: 'https://example.com/thumb.jpg',
+
embedType: 'video',
+
provider: 'streamable',
+
),
+
data: const {},
+
),
+
),
+
);
+
+
await tester.pumpWidget(
+
createTestWidget(post, streamableService: streamableService),
+
);
+
await tester.pump();
+
+
// Tap the play button
+
await tester.tap(find.byIcon(Icons.play_arrow));
+
await tester.pump();
+
+
// Verify loading indicator is displayed
+
expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
},
);
-
await tester.pumpWidget(createTestWidget(post));
+
testWidgets('does not show play button for non-video embeds', (
+
tester,
+
) async {
+
final post = FeedViewPost(
+
post: PostView(
+
uri: 'at://did:example/post/123',
+
cid: 'cid123',
+
rkey: '123',
+
author: AuthorView(did: 'did:plc:author', handle: 'author.test'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
),
+
createdAt: DateTime(2024, 1, 1),
+
indexedAt: DateTime(2024, 1, 1),
+
text: '',
+
stats: PostStats(
+
upvotes: 0,
+
downvotes: 0,
+
score: 0,
+
commentCount: 0,
+
),
+
embed: PostEmbed(
+
type: 'social.coves.embed.external',
+
external: ExternalEmbed(
+
uri: 'https://example.com/article',
+
thumb: 'https://example.com/thumb.jpg',
+
),
+
data: const {},
+
),
+
),
+
);
-
// Verify action buttons are present
-
expect(find.byIcon(Icons.more_horiz), findsOneWidget); // menu
-
// Share, comment, and heart icons are custom widgets, verify by count
-
expect(find.byType(InkWell), findsWidgets);
-
});
-
});
+
await tester.pumpWidget(createTestWidget(post));
+
await tester.pump();
+
+
// Verify play button is NOT displayed
+
expect(find.byIcon(Icons.play_arrow), findsNothing);
+
});
+
},
+
);
}