Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
// lib/screens/video_screen.dart | |
import 'dart:async'; | |
import 'package:aitube2/screens/home_screen.dart'; | |
import 'package:aitube2/widgets/chat_widget.dart'; | |
import 'package:aitube2/widgets/search_box.dart'; | |
import 'package:aitube2/widgets/web_utils.dart'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:universal_html/html.dart' if (dart.library.io) 'package:aitube2/services/html_stub.dart' as html; | |
import '../config/config.dart'; | |
import '../models/video_result.dart'; | |
import '../services/websocket_api_service.dart'; | |
import '../services/settings_service.dart'; | |
import '../theme/colors.dart'; | |
import '../widgets/video_player_widget.dart'; | |
class VideoScreen extends StatefulWidget { | |
final VideoResult video; | |
const VideoScreen({ | |
super.key, | |
required this.video, | |
}); | |
State<VideoScreen> createState() => _VideoScreenState(); | |
} | |
class _VideoScreenState extends State<VideoScreen> { | |
Future<String>? _captionFuture; | |
final _websocketService = WebSocketApiService(); | |
bool _isConnected = false; | |
late VideoResult _videoData; | |
final _searchController = TextEditingController(); | |
bool _isSearching = false; | |
// Subscription for limit statuses | |
StreamSubscription? _anonLimitSubscription; | |
StreamSubscription? _deviceLimitSubscription; | |
void initState() { | |
super.initState(); | |
_videoData = widget.video; | |
_searchController.text = _videoData.title; | |
_websocketService.addSubscriber(widget.video.id); | |
// Listen for changes to anonymous limit status | |
_anonLimitSubscription = _websocketService.anonLimitStream.listen((exceeded) { | |
if (exceeded && mounted) { | |
_showAnonLimitExceededDialog(); | |
} | |
}); | |
// Listen for changes to device limit status (for VIP users on web) | |
_deviceLimitSubscription = _websocketService.deviceLimitStream.listen((exceeded) { | |
if (exceeded && mounted) { | |
_showDeviceLimitExceededDialog(); | |
} | |
}); | |
_initializeConnection(); | |
} | |
Future<void> _initializeConnection() async { | |
try { | |
await _websocketService.connect(); | |
// Check if anonymous limit is exceeded | |
if (_websocketService.isAnonLimitExceeded) { | |
if (mounted) { | |
_showAnonLimitExceededDialog(); | |
} | |
return; | |
} | |
if (mounted) { | |
setState(() { | |
_isConnected = true; | |
_captionFuture = _generateCaption(); | |
}); | |
} | |
} catch (e) { | |
if (mounted) { | |
setState(() => _isConnected = false); | |
ScaffoldMessenger.of(context).showSnackBar( | |
SnackBar( | |
content: Text('Failed to connect to server: $e'), | |
action: SnackBarAction( | |
label: 'Retry', | |
onPressed: _initializeConnection, | |
), | |
), | |
); | |
} | |
} | |
} | |
void _showAnonLimitExceededDialog() async { | |
// Create a controller outside the dialog for easier access | |
final TextEditingController controller = TextEditingController(); | |
final settings = await showDialog<String>( | |
context: context, | |
barrierDismissible: false, | |
builder: (BuildContext dialogContext) { | |
bool obscureText = true; | |
return StatefulBuilder( | |
builder: (context, setState) { | |
return AlertDialog( | |
title: const Text( | |
'Connection Limit Reached', | |
style: TextStyle( | |
color: AiTubeColors.onBackground, | |
fontSize: 20, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
content: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
_websocketService.anonLimitMessage.isNotEmpty | |
? _websocketService.anonLimitMessage | |
: 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!', | |
style: const TextStyle(color: AiTubeColors.onSurface), | |
), | |
const SizedBox(height: 16), | |
const Text( | |
'Enter your HuggingFace API token to continue:', | |
style: TextStyle(color: AiTubeColors.onSurface), | |
), | |
const SizedBox(height: 8), | |
TextField( | |
controller: controller, | |
obscureText: obscureText, | |
decoration: InputDecoration( | |
labelText: 'API Key', | |
labelStyle: const TextStyle(color: AiTubeColors.onSurfaceVariant), | |
suffixIcon: IconButton( | |
icon: Icon( | |
obscureText ? Icons.visibility : Icons.visibility_off, | |
color: AiTubeColors.onSurfaceVariant, | |
), | |
onPressed: () => setState(() => obscureText = !obscureText), | |
), | |
), | |
onSubmitted: (value) { | |
Navigator.pop(dialogContext, value); | |
}, | |
), | |
], | |
), | |
backgroundColor: AiTubeColors.surface, | |
actions: [ | |
TextButton( | |
onPressed: () => Navigator.pop(dialogContext), | |
child: const Text( | |
'Cancel', | |
style: TextStyle(color: AiTubeColors.onSurfaceVariant), | |
), | |
), | |
FilledButton( | |
onPressed: () => Navigator.pop(dialogContext, controller.text), | |
style: FilledButton.styleFrom( | |
backgroundColor: AiTubeColors.primary, | |
), | |
child: const Text('Save'), | |
), | |
], | |
); | |
} | |
); | |
}, | |
); | |
// Clean up the controller | |
controller.dispose(); | |
// If user provided an API key, save it and retry connection | |
if (settings != null && settings.isNotEmpty) { | |
// Save the API key | |
final settingsService = SettingsService(); | |
await settingsService.setHuggingfaceApiKey(settings); | |
// Retry connection | |
if (mounted) { | |
_initializeConnection(); | |
} | |
} | |
} | |
void _showDeviceLimitExceededDialog() async { | |
await showDialog<void>( | |
context: context, | |
barrierDismissible: false, | |
builder: (BuildContext dialogContext) { | |
return AlertDialog( | |
title: const Text( | |
'Too Many Connections', | |
style: TextStyle( | |
color: AiTubeColors.onBackground, | |
fontSize: 20, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
content: Column( | |
mainAxisSize: MainAxisSize.min, | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Text( | |
_websocketService.deviceLimitMessage, | |
style: const TextStyle(color: AiTubeColors.onSurface), | |
), | |
const SizedBox(height: 16), | |
const Text( | |
'Please close some of your other browser tabs running AiTube to continue.', | |
style: TextStyle(color: AiTubeColors.onSurface), | |
), | |
], | |
), | |
backgroundColor: AiTubeColors.surface, | |
actions: [ | |
FilledButton( | |
onPressed: () { | |
Navigator.pop(dialogContext); | |
// Try to reconnect after dialog is closed | |
if (mounted) { | |
Future.delayed(const Duration(seconds: 1), () { | |
_initializeConnection(); | |
}); | |
} | |
}, | |
style: FilledButton.styleFrom( | |
backgroundColor: AiTubeColors.primary, | |
), | |
child: const Text('Try Again'), | |
), | |
], | |
); | |
}, | |
); | |
} | |
Future<String> _generateCaption() async { | |
if (!_isConnected) { | |
return 'Error: Not connected to server'; | |
} | |
try { | |
return await _websocketService.generateCaption( | |
_videoData.title, | |
_videoData.description, | |
); | |
} catch (e) { | |
return 'Error generating caption: $e'; | |
} | |
} | |
void _shareVideo() async { | |
// For non-web platforms | |
final uri = Uri.parse("https://aitube.at"); | |
final params = Map<String, String>.from(uri.queryParameters); | |
// Ensure title and description are in the URL parameters | |
params['title'] = _videoData.title; | |
params['description'] = _videoData.description; | |
// Create a new URL with updated parameters | |
final shareUri = uri.replace(queryParameters: params); | |
final shareUrl = shareUri.toString(); | |
try { | |
// this is a text to share on social media | |
// final textToCopy = 'Messing around with #aitube2 👀 $shareUrl'; | |
// but for now let's just use the url | |
final textToCopy = shareUrl; | |
// Copy to clipboard | |
await Clipboard.setData(ClipboardData(text: textToCopy)); | |
// Show a temporary "Copied!" message | |
if (mounted) { | |
ScaffoldMessenger.of(context).showSnackBar( | |
const SnackBar( | |
content: Text('Copied to clipboard!'), | |
backgroundColor: Colors.green, | |
duration: Duration(seconds: 3), | |
), | |
); | |
} | |
} catch (e) { | |
if (mounted) { | |
ScaffoldMessenger.of(context).showSnackBar( | |
SnackBar(content: Text('Error copying to clipboard: $e')), | |
); | |
} | |
} | |
} | |
// Reference to the current VideoPlayerWidget to force reset when needed | |
Key _videoPlayerKey = UniqueKey(); | |
Future<void> _onVideoSearch(String query) async { | |
if (!_isConnected) { | |
ScaffoldMessenger.of(context).showSnackBar( | |
const SnackBar(content: Text('Not connected to server')), | |
); | |
return; | |
} | |
setState(() => _isSearching = true); | |
// Update URL parameter on web platform | |
if (kIsWeb) { | |
// We'll update title and description parameters after we get the search result | |
// removeUrlParameter('search') will happen after we get the result | |
} | |
try { | |
// First, cancel any requests for the current video | |
_websocketService.cancelRequestsForVideo(_videoData.id); | |
// Get the search result | |
final result = await _websocketService.search(query); | |
if (mounted) { | |
setState(() { | |
// Generate a new key to force recreation of the VideoPlayerWidget | |
_videoPlayerKey = UniqueKey(); | |
_videoData = result; | |
_isSearching = false; | |
}); | |
// Now that we have the result, update the URL parameter on web platform | |
if (kIsWeb) { | |
// Update title and description parameters | |
updateUrlParameter('title', result.title); | |
updateUrlParameter('description', result.description); | |
removeUrlParameter('search'); | |
} | |
} | |
} catch (e) { | |
if (mounted) { | |
setState(() => _isSearching = false); | |
ScaffoldMessenger.of(context).showSnackBar( | |
SnackBar(content: Text('Error: $e')), | |
); | |
} | |
} | |
} | |
Widget build(BuildContext context) { | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
final isWideScreen = constraints.maxWidth >= 900; | |
return Scaffold( | |
appBar: PreferredSize( | |
preferredSize: const Size.fromHeight(kToolbarHeight + 16), | |
child: Padding( | |
padding: const EdgeInsets.only(top: 16), | |
child: AppBar( | |
leading: IconButton( | |
icon: Navigator.canPop(context) | |
? const Icon(Icons.arrow_back, color: AiTubeColors.onBackground) | |
: const Icon(Icons.home, color: AiTubeColors.onBackground), | |
onPressed: () { | |
// Restore the search parameter in URL when navigating back | |
if (kIsWeb) { | |
// Remove the title and description parameters | |
removeUrlParameter('title'); | |
removeUrlParameter('description'); | |
// Get the search query from the video description | |
final searchQuery = _videoData.description.trim(); | |
if (searchQuery.isNotEmpty) { | |
// Update URL to show search parameter again | |
updateUrlParameter('search', searchQuery); | |
} | |
} | |
if (Navigator.canPop(context)) { | |
Navigator.pop(context); | |
} else { | |
// Navigate to home screen if we can't go back | |
Navigator.pushReplacement( | |
context, | |
MaterialPageRoute(builder: (context) => const HomeScreen()), | |
); | |
} | |
}, | |
), | |
titleSpacing: 0, | |
title: Padding( | |
padding: const EdgeInsets.all(8), | |
child: SearchBox( | |
controller: _searchController, | |
isSearching: _isSearching, | |
enabled: _isConnected, | |
onSearch: _onVideoSearch, | |
onCancel: () { | |
setState(() => _isSearching = false); | |
}, | |
), | |
), | |
actions: [ | |
IconButton( | |
icon: Icon( | |
_isConnected ? Icons.cloud_done : Icons.cloud_off, | |
color: _isConnected ? Colors.green : Colors.red, | |
), | |
onPressed: _isConnected ? null : _initializeConnection, | |
), | |
], | |
), | |
), | |
), | |
body: SafeArea( | |
child: isWideScreen | |
? Row( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
Expanded( | |
child: _buildMainContent(), | |
), | |
if (Configuration.instance.showChatInVideoView) ...[ | |
const SizedBox(width: 16), | |
Padding( | |
padding: const EdgeInsets.only(right: 16), | |
child: ChatWidget(videoId: widget.video.id), | |
), | |
], | |
], | |
) | |
: Column( | |
children: [ | |
_buildMainContent(), | |
if (Configuration.instance.showChatInVideoView) ...[ | |
const SizedBox(height: 16), | |
Expanded( | |
child: Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 16), | |
child: ChatWidget( | |
videoId: widget.video.id, | |
isCompact: true, | |
), | |
), | |
), | |
], | |
], | |
), | |
), | |
); | |
}, | |
); | |
} | |
Widget _buildMainContent() { | |
return SingleChildScrollView( | |
padding: const EdgeInsets.all(16), | |
child: Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
// Video Player with unique key to force recreation when needed | |
VideoPlayerWidget( | |
key: _videoPlayerKey, | |
video: _videoData, | |
initialThumbnailUrl: _videoData.thumbnailUrl, | |
autoPlay: true, | |
), | |
const SizedBox(height: 16), | |
// Collapsible Title and Description Section | |
_buildCollapsibleInfoSection(), | |
], | |
), | |
); | |
} | |
Widget _buildCollapsibleInfoSection() { | |
return ExpansionTile( | |
initiallyExpanded: false, | |
tilePadding: EdgeInsets.zero, | |
title: Row( | |
children: [ | |
Expanded( | |
child: Text( | |
_videoData.title, | |
style: Theme.of(context).textTheme.headlineSmall?.copyWith( | |
color: AiTubeColors.onBackground, | |
fontWeight: FontWeight.bold, | |
), | |
), | |
), | |
IconButton( | |
icon: const Icon(Icons.share, color: AiTubeColors.primary), | |
onPressed: _shareVideo, | |
tooltip: 'Share video', | |
), | |
], | |
), | |
iconColor: AiTubeColors.primary, | |
collapsedIconColor: AiTubeColors.primary, | |
backgroundColor: Colors.transparent, | |
collapsedBackgroundColor: Colors.transparent, | |
children: [ | |
Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
// Tags | |
if (_videoData.tags.isNotEmpty) ...[ | |
Wrap( | |
spacing: 8, | |
runSpacing: 8, | |
children: _videoData.tags.map((tag) => Chip( | |
label: Text(tag), | |
backgroundColor: AiTubeColors.surface, | |
labelStyle: const TextStyle(color: AiTubeColors.onSurface), | |
)).toList(), | |
), | |
const SizedBox(height: 16), | |
], | |
// Description Section | |
const Text( | |
'Description', | |
style: TextStyle( | |
color: AiTubeColors.onBackground, | |
fontWeight: FontWeight.bold, | |
fontSize: 18, | |
), | |
), | |
const SizedBox(height: 8), | |
Text( | |
_videoData.description, | |
style: const TextStyle( | |
color: AiTubeColors.onSurface, | |
height: 1.5, | |
), | |
), | |
], | |
), | |
], | |
); | |
} | |
void dispose() { | |
// Cancel any pending video-related requests | |
_websocketService.cancelRequestsForVideo(widget.video.id); | |
_websocketService.removeSubscriber(widget.video.id); | |
// Cleanup other resources | |
_searchController.dispose(); | |
_anonLimitSubscription?.cancel(); | |
_deviceLimitSubscription?.cancel(); | |
super.dispose(); | |
} | |
} |