// 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, }); @override State createState() => _VideoScreenState(); } class _VideoScreenState extends State { Future? _captionFuture; final _websocketService = WebSocketApiService(); bool _isConnected = false; late VideoResult _videoData; final _searchController = TextEditingController(); bool _isSearching = false; // Subscription for limit statuses StreamSubscription? _anonLimitSubscription; StreamSubscription? _deviceLimitSubscription; @override 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 _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( 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( 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 _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.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 _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')), ); } } } @override 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(left: 0, top: 16, right: 16, bottom: 4), child: ChatWidget(videoId: widget.video.id), ), ], ], ) : Column( children: [ Expanded( child: _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 Container( color: Theme.of(context).scaffoldBackgroundColor, child: SingleChildScrollView( padding: const EdgeInsets.all(16), physics: const AlwaysScrollableScrollPhysics(), 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, fontSize: 18 ), ), ), IconButton( icon: const Icon(Icons.share, color: AiTubeColors.primary), onPressed: _shareVideo, tooltip: 'Share this creation', ), ], ), iconColor: AiTubeColors.primary, collapsedIconColor: AiTubeColors.primary, backgroundColor: Colors.transparent, collapsedBackgroundColor: Colors.transparent, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Description Section const SizedBox(height: 8), Text( _videoData.description, style: const TextStyle( color: AiTubeColors.onSurface, height: 1.5, ), ), const SizedBox(height: 8), ], ), ], ); } @override 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(); } }