zhabby_shorts 0.0.1
zhabby_shorts: ^0.0.1 copied to clipboard
Shorts for Flutter.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:zhabby_shorts/zhabby_shorts.dart';
import 'dart:async';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Zhabby Shorts Demo',
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: Colors.black,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
),
home: const VideoFeedScreen(),
debugShowCheckedModeBanner: false,
);
}
}
class VideoFeedScreen extends StatefulWidget {
const VideoFeedScreen({super.key});
@override
State<VideoFeedScreen> createState() => _VideoFeedScreenState();
}
class _VideoFeedScreenState extends State<VideoFeedScreen>
with TickerProviderStateMixin {
late final ShortsPlayerController _player;
late PageController _pageController;
// Test video URLs from Cloudflare Stream
final List<String> _baseVideoUrls = [
'https://p2.proxy.zhabby.com/d26e36b13e2ab198ca02e7ab60f61257/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/6c625457da995cf6c21b2e6fabe13d46/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/762062a33279396c6e6b70a0ff58e210/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/d26e36b13e2ab198ca02e7ab60f61257/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/762062a33279396c6e6b70a0ff58e210/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/2c261e5a18cd5d6688f1843a04c388aa/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/6c625457da995cf6c21b2e6fabe13d46/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/f26c4926b597081bd1da62d6fcb233fe/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/44b149efa348797aba46d1b50f5a3a11/manifest/video.m3u8',
'https://p2.proxy.zhabby.com/e2ae47624cd692b85b65493ecd4b82b2/manifest/video.m3u8',
];
// Dynamic video list that grows with pagination
List<String> _videoUrls = [];
int _currentIndex = 0;
bool _isInitialized = false;
final Map<int, int> _textureIds = {};
late AnimationController _fadeController;
late Animation<double> _fadeAnimation;
bool _isLoadingMoreVideos = false;
// Video fitting mode for demonstration
VideoFit _currentVideoFit = VideoFit.cover;
// Debug information storage
final List<String> _debugMessages = [];
final List<String> _errorMessages = [];
String? _lastDebugMessage;
String? _lastErrorMessage;
DateTime? _lastDebugTime;
DateTime? _lastErrorTime;
@override
void initState() {
super.initState();
_player = ShortsPlayerController();
_pageController = PageController();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(_fadeController);
// Initialize with the base video URLs
_videoUrls = List.from(_baseVideoUrls);
_initializePlayer();
}
Future<void> _initializePlayer() async {
try {
// Initialize the player
await _player.init();
// Set up debug callbacks to capture all debug information
_setupDebugCallbacks();
// Set up all video URLs
await _player.appendUrls(0, _videoUrls);
// Configure player settings
await _player.setLooping(true);
await _player.setProgressTracking(enabled: true, intervalMs: 500);
// Prime the first few videos
for (int i = 0; i < 3 && i < _videoUrls.length; i++) {
_textureIds[i] = await _player.attach(i);
await _player.prime(i);
}
// Start playback immediately - let the native side handle loading states
if (_videoUrls.isNotEmpty) {
_addDebugMessage('Starting first video playback');
await _player.switchTo(0);
_fadeController.forward();
}
setState(() {
_isInitialized = true;
});
} catch (e) {
debugPrint('Error initializing player: $e');
_addErrorMessage('Initialization Error: $e');
}
}
void _setupDebugCallbacks() {
// Access the method channel directly to set up additional debug callbacks
final methodChannel = _player.methodChannel;
// Store original callbacks and wrap them with debug logging
final originalOnVideoLoaded = methodChannel.onVideoLoaded;
final originalOnBufferingUpdate = methodChannel.onBufferingUpdate;
final originalOnPlaybackStateChanged = methodChannel.onPlaybackStateChanged;
final originalOnVideoDimensionsChanged =
methodChannel.onVideoDimensionsChanged;
final originalOnProgress = methodChannel.onProgress;
final originalOnWatched = methodChannel.onWatched;
final originalOnError = methodChannel.onError;
final originalOnDebugInfo = methodChannel.onDebugInfo;
methodChannel.onVideoLoaded = (int index, String url) {
originalOnVideoLoaded?.call(index, url);
_addDebugMessage('Video Loaded: Index $index, URL: ${_truncateUrl(url)}');
};
methodChannel.onBufferingUpdate = (int index, String url, int percent) {
originalOnBufferingUpdate?.call(index, url, percent);
_addDebugMessage('Buffering: Index $index, ${percent}%');
};
methodChannel.onPlaybackStateChanged =
(int index, String url, String state) {
originalOnPlaybackStateChanged?.call(index, url, state);
_addDebugMessage('Playback State: Index $index, State: $state');
};
methodChannel.onVideoDimensionsChanged =
(int index, String url, double width, double height) {
originalOnVideoDimensionsChanged?.call(index, url, width, height);
_addDebugMessage(
'Dimensions: Index $index, ${width.toInt()}x${height.toInt()}',
);
};
methodChannel.onProgress =
(
int index,
String url,
int positionMs,
int durationMs,
int bufferedMs,
) {
originalOnProgress?.call(
index,
url,
positionMs,
durationMs,
bufferedMs,
);
// Don't log progress too frequently to avoid spam
if (positionMs % 5000 < 500) {
// Log every ~5 seconds
_addDebugMessage(
'Progress: Index $index, ${(positionMs / 1000).toStringAsFixed(1)}s/${(durationMs / 1000).toStringAsFixed(1)}s',
);
}
};
methodChannel.onWatched = (int index, String url) {
originalOnWatched?.call(index, url);
_addDebugMessage('Video Watched: Index $index');
};
methodChannel.onError =
(int index, String url, String errorMessage, String errorCode) {
originalOnError?.call(index, url, errorMessage, errorCode);
_addErrorMessage(
'Error: Index $index, Code: $errorCode, Message: $errorMessage',
);
};
methodChannel.onDebugInfo = (int index, String message) {
originalOnDebugInfo?.call(index, message);
_addDebugMessage('Native Debug: Index $index, $message');
};
}
void _addDebugMessage(String message) {
if (mounted) {
setState(() {
_debugMessages.insert(0, message);
if (_debugMessages.length > 50) {
_debugMessages.removeLast();
}
_lastDebugMessage = message;
_lastDebugTime = DateTime.now();
});
}
}
void _addErrorMessage(String message) {
if (mounted) {
setState(() {
_errorMessages.insert(0, message);
if (_errorMessages.length > 20) {
_errorMessages.removeLast();
}
_lastErrorMessage = message;
_lastErrorTime = DateTime.now();
});
}
}
String _truncateUrl(String url) {
if (url.length <= 50) return url;
return '${url.substring(0, 25)}...${url.substring(url.length - 20)}';
}
Future<void> _loadMoreVideos() async {
if (_isLoadingMoreVideos) return;
_isLoadingMoreVideos = true;
try {
final currentCount = _videoUrls.length;
final newUrls = List<String>.from(_baseVideoUrls);
// Add the same videos again for pagination demo
_videoUrls.addAll(newUrls);
// Append the new URLs to the player starting from the current count
await _player.appendUrls(currentCount, newUrls);
setState(() {}); // Trigger rebuild to update itemCount
} catch (e) {
debugPrint('Error loading more videos: $e');
} finally {
_isLoadingMoreVideos = false;
}
}
Future<void> _onPageChanged(int index) async {
if (!_isInitialized) return;
_currentIndex = index;
// Check if we need to load more videos (when reaching second-to-last page)
if (index >= _videoUrls.length - 2 && !_isLoadingMoreVideos) {
_loadMoreVideos();
}
try {
// Ensure texture is attached for current video
if (!_textureIds.containsKey(index)) {
_textureIds[index] = await _player.attach(index);
await _player.prime(index);
}
// Switch to the new video
await _player.switchTo(index);
// Prewarm adjacent videos
int? nextIndex = index + 1 < _videoUrls.length ? index + 1 : null;
int? prevIndex = index - 1 >= 0 ? index - 1 : null;
// Attach textures for adjacent videos if needed
if (nextIndex != null && !_textureIds.containsKey(nextIndex)) {
_textureIds[nextIndex] = await _player.attach(nextIndex);
}
if (prevIndex != null && !_textureIds.containsKey(prevIndex)) {
_textureIds[prevIndex] = await _player.attach(prevIndex);
}
await _player.prewarm(next: nextIndex, prev: prevIndex);
} catch (e) {
debugPrint('Error changing page: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: AnimatedBuilder(
animation: _player,
builder: (context, child) {
return Stack(
children: [
// Video feed
PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
onPageChanged: _onPageChanged,
itemCount: _videoUrls.length,
itemBuilder: (context, index) {
return AdaptiveVideoFeedPlayer(
key: ValueKey('video_${index}_${_currentVideoFit.name}'),
index: index,
controller: _player,
isActive: _currentIndex == index,
fit: _currentVideoFit, // Use dynamic fit mode
showDebugInfo:
true, // Show debug info to demonstrate aspect ratio adaptation
onTap: () {
// Optional: Add custom tap handling here
},
);
},
),
// Top overlay with app info and fit mode controls
Positioned(
top: MediaQuery.of(context).padding.top + 10,
left: 20,
right: 20,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// App title and video counter
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Zhabby Shorts',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${_currentIndex + 1} / ${_videoUrls.length}',
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
if (_isLoadingMoreVideos) ...[
const SizedBox(width: 8),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white70,
),
),
],
],
),
],
),
const SizedBox(height: 10),
// Video fit mode controls
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Fit: ',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
...VideoFit.values.map((fit) {
final isSelected = _currentVideoFit == fit;
return GestureDetector(
onTap: () {
setState(() {
_currentVideoFit = fit;
});
},
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: isSelected
? Colors.blue
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(
fit.name.toUpperCase(),
style: TextStyle(
color: isSelected
? Colors.white
: Colors.white70,
fontSize: 10,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
);
}).toList(),
],
),
),
],
),
),
// Debug overlay with video status
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 10,
left: 10,
right: 10,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: _buildDebugInfo(),
),
),
],
);
},
),
);
}
Widget _buildDebugInfo() {
final currentStatus = _player.getVideoStatus(_currentIndex);
final activeIndex = _player.activeIndex;
final playbackInfo = _player.currentPlaybackInfo;
final currentTextureId = _textureIds[_currentIndex];
return DefaultTabController(
length: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header with tabs
Container(
height: 30,
child: TabBar(
isScrollable: true,
labelColor: Colors.white,
unselectedLabelColor: Colors.white54,
indicatorColor: Colors.blue,
labelStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
tabs: [
Tab(text: 'PLAYER'),
Tab(text: 'DEBUG (${_debugMessages.length})'),
Tab(text: 'ERRORS (${_errorMessages.length})'),
Tab(text: 'TECHNICAL'),
],
),
),
// Tab content
Container(
height: 200,
child: TabBarView(
children: [
// Player Status Tab
_buildPlayerStatusTab(
currentStatus,
activeIndex,
playbackInfo,
currentTextureId,
),
// Debug Messages Tab
_buildDebugMessagesTab(),
// Error Messages Tab
_buildErrorMessagesTab(),
// Technical Info Tab
_buildTechnicalInfoTab(playbackInfo),
],
),
),
],
),
);
}
Widget _buildPlayerStatusTab(
VideoStatus? currentStatus,
int? activeIndex,
Map<String, dynamic>? playbackInfo,
int? textureId,
) {
final playerState = _player.getPlayerState(_currentIndex);
return SingleChildScrollView(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow('Active Index', '$activeIndex'),
_buildInfoRow('Current Index', '$_currentIndex'),
_buildInfoRow('Texture ID', '$textureId'),
_buildInfoRow('Initialized', '$_isInitialized'),
_buildInfoRow('Loading More', '$_isLoadingMoreVideos'),
const SizedBox(height: 8),
if (playerState != null) ...[
const Text(
'DETAILED PLAYER STATE',
style: TextStyle(
color: Colors.cyan,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
_buildInfoRow('Player Status', playerState.playerStatus),
_buildInfoRow('Time Control', playerState.timeControlStatus),
_buildInfoRow('Item Status', playerState.itemStatus),
_buildInfoRow(
'Player Rate',
playerState.playerRate.toStringAsFixed(2),
),
_buildInfoRow(
'Current Time',
'${playerState.currentTimeSeconds.toStringAsFixed(2)}s',
),
_buildInfoRow(
'Duration',
'${playerState.itemDurationSeconds.toStringAsFixed(2)}s',
),
_buildInfoRow('Video Tracks', '${playerState.videoTracksCount}'),
_buildInfoRow('Has New Frame', '${playerState.hasNewFrame}'),
_buildInfoRow('Is Playing', '${playerState.isPlaying}'),
_buildInfoRow('Is Ready', '${playerState.isReady}'),
if (playerState.hasErrors)
_buildInfoRow('Errors', playerState.errorMessage ?? 'Unknown'),
_buildInfoRow(
'Updated',
'${playerState.timestamp.hour.toString().padLeft(2, '0')}:${playerState.timestamp.minute.toString().padLeft(2, '0')}:${playerState.timestamp.second.toString().padLeft(2, '0')}',
),
const SizedBox(height: 8),
],
if (currentStatus != null) ...[
const Text(
'BASIC VIDEO STATUS',
style: TextStyle(
color: Colors.yellow,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
_buildInfoRow('URL', _truncateUrl(currentStatus.url)),
_buildInfoRow('State', currentStatus.playbackState),
_buildInfoRow('Loaded', '${currentStatus.isLoaded}'),
_buildInfoRow('Buffer %', '${currentStatus.bufferingPercent}%'),
_buildInfoRow(
'Position',
'${(currentStatus.positionMs / 1000).toStringAsFixed(1)}s',
),
_buildInfoRow(
'Duration',
'${(currentStatus.durationMs / 1000).toStringAsFixed(1)}s',
),
_buildInfoRow(
'Buffered',
'${(currentStatus.bufferedMs / 1000).toStringAsFixed(1)}s',
),
if (currentStatus.videoWidth != null &&
currentStatus.videoHeight != null) ...[
_buildInfoRow(
'Dimensions',
'${currentStatus.videoWidth!.toInt()}x${currentStatus.videoHeight!.toInt()}',
),
_buildInfoRow(
'Aspect Ratio',
currentStatus.aspectRatio?.toStringAsFixed(2) ?? 'N/A',
),
],
] else ...[
const Text(
'No basic video status available',
style: TextStyle(color: Colors.orange, fontSize: 10),
),
],
],
),
);
}
Widget _buildDebugMessagesTab() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_lastDebugMessage != null)
Container(
padding: const EdgeInsets.all(6),
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LATEST:',
style: TextStyle(
color: Colors.blue,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
Text(
_lastDebugMessage!,
style: const TextStyle(color: Colors.white, fontSize: 9),
),
if (_lastDebugTime != null)
Text(
'${_lastDebugTime!.hour.toString().padLeft(2, '0')}:${_lastDebugTime!.minute.toString().padLeft(2, '0')}:${_lastDebugTime!.second.toString().padLeft(2, '0')}',
style: const TextStyle(color: Colors.white54, fontSize: 8),
),
],
),
),
Expanded(
child: _debugMessages.isEmpty
? const Center(
child: Text(
'No debug messages yet',
style: TextStyle(color: Colors.white54, fontSize: 10),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 4),
itemCount: _debugMessages.length,
itemBuilder: (context, index) {
final message = _debugMessages[index];
return Container(
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 4,
),
child: Text(
'• $message',
style: const TextStyle(
color: Colors.white70,
fontSize: 9,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
),
),
// Add button to trigger diagnose
Container(
padding: const EdgeInsets.all(4),
child: ElevatedButton(
onPressed: () => _player.diagnoseVideoState(_currentIndex),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
minimumSize: const Size(double.infinity, 30),
),
child: const Text(
'Diagnose Current Video',
style: TextStyle(fontSize: 10),
),
),
),
],
);
}
Widget _buildErrorMessagesTab() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_lastErrorMessage != null)
Container(
padding: const EdgeInsets.all(6),
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LATEST ERROR:',
style: TextStyle(
color: Colors.red,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
Text(
_lastErrorMessage!,
style: const TextStyle(color: Colors.white, fontSize: 9),
),
if (_lastErrorTime != null)
Text(
'${_lastErrorTime!.hour.toString().padLeft(2, '0')}:${_lastErrorTime!.minute.toString().padLeft(2, '0')}:${_lastErrorTime!.second.toString().padLeft(2, '0')}',
style: const TextStyle(color: Colors.white54, fontSize: 8),
),
],
),
),
Expanded(
child: _errorMessages.isEmpty
? const Center(
child: Text(
'No errors (Good!)',
style: TextStyle(color: Colors.green, fontSize: 10),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 4),
itemCount: _errorMessages.length,
itemBuilder: (context, index) {
final message = _errorMessages[index];
return Container(
padding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 4,
),
child: Text(
'• $message',
style: const TextStyle(
color: Colors.redAccent,
fontSize: 9,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
);
},
),
),
],
);
}
Widget _buildTechnicalInfoTab(Map<String, dynamic>? playbackInfo) {
final currentStatus = _player.getVideoStatus(_currentIndex);
return SingleChildScrollView(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ADAPTIVE VIDEO INFO',
style: TextStyle(
color: Colors.cyan,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
_buildInfoRow(
'Current Fit Mode',
_currentVideoFit.name.toUpperCase(),
),
if (currentStatus != null) ...[
if (currentStatus.videoWidth != null &&
currentStatus.videoHeight != null) ...[
_buildInfoRow(
'Video Dimensions',
'${currentStatus.videoWidth!.toInt()}x${currentStatus.videoHeight!.toInt()}',
),
_buildInfoRow(
'Aspect Ratio',
currentStatus.aspectRatio?.toStringAsFixed(3) ?? 'Unknown',
),
_buildInfoRow(
'Is Portrait',
'${(currentStatus.aspectRatio ?? 1.0) < 1.0}',
),
_buildInfoRow(
'Is Landscape',
'${(currentStatus.aspectRatio ?? 1.0) > 1.0}',
),
_buildInfoRow(
'Is Square',
'${((currentStatus.aspectRatio ?? 1.0) - 1.0).abs() < 0.01}',
),
] else ...[
_buildInfoRow('Video Dimensions', 'Not yet available'),
_buildInfoRow('Aspect Ratio', 'Calculating...'),
],
] else ...[
_buildInfoRow('Video Status', 'Not available'),
],
const SizedBox(height: 12),
const Text(
'TEXTURE IDS',
style: TextStyle(
color: Colors.yellow,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
..._textureIds.entries.map(
(entry) =>
_buildInfoRow('Index ${entry.key}', 'Texture ${entry.value}'),
),
const SizedBox(height: 12),
const Text(
'VIDEO URLS',
style: TextStyle(
color: Colors.yellow,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Total Videos: ${_videoUrls.length}',
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
if (playbackInfo != null) ...[
const SizedBox(height: 12),
const Text(
'PLAYBACK INFO',
style: TextStyle(
color: Colors.yellow,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
...playbackInfo.entries.map((entry) {
String value = entry.value.toString();
if (entry.value is double) {
value = (entry.value as double).toStringAsFixed(2);
}
return _buildInfoRow(entry.key, value);
}),
],
const SizedBox(height: 12),
const Text(
'APP INFO',
style: TextStyle(
color: Colors.yellow,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
_buildInfoRow('Flutter', 'Debug Mode'),
_buildInfoRow('Plugin', 'Zhabby Shorts with Adaptive Video'),
_buildInfoRow(
'Build Time',
DateTime.now().toString().split('.').first,
),
],
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'$label:',
style: const TextStyle(color: Colors.white54, fontSize: 9),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(color: Colors.white70, fontSize: 9),
),
),
],
),
);
}
@override
void dispose() {
_pageController.dispose();
_fadeController.dispose();
_player.release();
super.dispose();
}
}
// VideoPlayerWidget has been replaced with AdaptiveVideoFeedPlayer