jbilcke-hf HF Staff commited on
Commit
2e813e6
·
1 Parent(s): d5e94b5

fixing small bugs here and there

Browse files
Files changed (40) hide show
  1. .gitignore +2 -1
  2. PROMPT_CONTEXT.md +3 -1
  3. WEBSOCKET_FIXES.md +84 -0
  4. api.py +96 -304
  5. api_config.py +10 -10
  6. api_core.py +173 -97
  7. api_metrics.py +185 -0
  8. api_session.py +488 -0
  9. build/web/assets/assets/config/custom.yaml +3 -3
  10. build/web/assets/fonts/MaterialIcons-Regular.otf +0 -0
  11. build/web/flutter_bootstrap.js +1 -1
  12. build/web/flutter_service_worker.js +4 -4
  13. build/web/main.dart.js +0 -0
  14. lib/main.dart +0 -2
  15. lib/screens/home_screen.dart +20 -58
  16. lib/screens/settings_screen.dart +120 -181
  17. lib/screens/video_screen.dart +25 -15
  18. lib/services/cache_service.dart +0 -340
  19. lib/services/clip_queue/clip_generation_handler.dart +182 -0
  20. lib/services/clip_queue/clip_queue_manager.dart +422 -0
  21. lib/services/clip_queue/clip_states.dart +56 -0
  22. lib/services/clip_queue/index.dart +7 -0
  23. lib/services/clip_queue/queue_stats_logger.dart +196 -0
  24. lib/services/clip_queue/video_clip.dart +118 -0
  25. lib/services/clip_queue_manager.dart +3 -717
  26. lib/services/html_stub.dart +1 -3
  27. lib/services/websocket_api_service.dart +59 -53
  28. lib/services/websocket_core_interface.dart +40 -0
  29. lib/widgets/ai_content_disclaimer.dart +54 -4
  30. lib/widgets/search_box.dart +38 -155
  31. lib/widgets/video_card.dart +32 -11
  32. lib/widgets/video_player/buffer_manager.dart +158 -0
  33. lib/widgets/video_player/index.dart +9 -0
  34. lib/widgets/video_player/lifecycle_manager.dart +79 -0
  35. lib/widgets/video_player/nano_clip_manager.dart +208 -0
  36. lib/widgets/video_player/nano_video_player.dart +332 -0
  37. lib/widgets/video_player/playback_controller.dart +159 -0
  38. lib/widgets/video_player/ui_components.dart +162 -0
  39. lib/widgets/video_player/video_player_widget.dart +397 -0
  40. lib/widgets/video_player_widget.dart +3 -716
.gitignore CHANGED
@@ -50,4 +50,5 @@ app.*.map.json
50
  /android/app/profile
51
  /android/app/release
52
 
53
- /assets/config/
 
 
50
  /android/app/profile
51
  /android/app/release
52
 
53
+ /assets/config/
54
+ **/.claude/settings.local.json
PROMPT_CONTEXT.md CHANGED
@@ -7,4 +7,6 @@ Note that this uses a custom API written in Python, with a WebSockets communicat
7
  To go back to the Flutter app, when the user open a thumbnail card after doing such generative AI search, it opens a full view for a video (with a player, title, description, chat section etc and a special search bar in the top header that allows to edit the current video's theme).
8
 
9
 
10
- Task to perform: <fill your demand here>
 
 
 
7
  To go back to the Flutter app, when the user open a thumbnail card after doing such generative AI search, it opens a full view for a video (with a player, title, description, chat section etc and a special search bar in the top header that allows to edit the current video's theme).
8
 
9
 
10
+ Task to perform: <fill your demand here>
11
+
12
+ Note: For the task to be validated, running the shell command "flutter build web" must succeeed.
WEBSOCKET_FIXES.md ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WebSocket Services Fix Guide
2
+
3
+ This document provides guidance on how to fix the WebSocket services implementation in the codebase to resolve the compilation errors.
4
+
5
+ ## Issues Identified
6
+
7
+ 1. The mixin classes (`WebSocketChatService`, `WebSocketSearchService`, `WebSocketContentGenerationService`, `WebSocketConnectionService`) access fields and methods from the `WebSocketCoreService` base class that are not actually available through the mixin mechanism.
8
+
9
+ 2. The `ClipQueueManager` had a duplicate `activeGenerations` property.
10
+
11
+ 3. The `VideoPlaybackController` was using a private field `_activeGenerations` from `ClipQueueManager`.
12
+
13
+ ## Changes Made
14
+
15
+ 1. Fixed duplicate `activeGenerations` in `ClipQueueManager`:
16
+ - Renamed the int getter to `activeGenerationsCount`
17
+ - Added a `Set<String> get activeGenerations` getter to expose the private field
18
+
19
+ 2. Updated `printQueueState` in `ClipQueueStats` to accept dynamic type for the `activeGenerations` parameter.
20
+
21
+ 3. Fixed imports for WebSocketCoreService in all mixin files.
22
+
23
+ 4. Updated VideoPlaybackController to use the public getter for activeGenerations.
24
+
25
+ ## Remaining Issues
26
+
27
+ The main issue is with the mixin inheritance. Mixins in Dart can only access methods and fields they declare themselves or that are available in the class they are applied to. Mixins don't have visibility into private fields of the class they're "on".
28
+
29
+ ### Option 1: Refactor to use composition instead of mixins
30
+
31
+ Instead of using mixins, refactor to use composition:
32
+
33
+ ```dart
34
+ class WebSocketApiService {
35
+ final ChatService _chatService;
36
+ final SearchService _searchService;
37
+ final ConnectionService _connectionService;
38
+ final ContentGenerationService _contentGenerationService;
39
+
40
+ WebSocketApiService() :
41
+ _chatService = ChatService(),
42
+ _searchService = SearchService(),
43
+ _connectionService = ConnectionService(),
44
+ _contentGenerationService = ContentGenerationService();
45
+
46
+ // Forward methods to the appropriate service
47
+ }
48
+ ```
49
+
50
+ ### Option 2: Make private fields protected
51
+
52
+ Make the necessary fields and methods protected (rename from `_fieldName` to `fieldName` or create protected getters/setters).
53
+
54
+ ### Option 3: Implement the WebSocketCore interface in each mixin
55
+
56
+ Define an interface that all the mixins implement, rather than using "on WebSocketCoreService":
57
+
58
+ ```dart
59
+ abstract class WebSocketCoreInterface {
60
+ bool get isConnected;
61
+ bool get isInMaintenance;
62
+ ConnectionStatus get status;
63
+ // Add all methods and properties needed by the mixins
64
+ }
65
+
66
+ class WebSocketCoreService implements WebSocketCoreInterface {
67
+ // Implementation
68
+ }
69
+
70
+ mixin WebSocketChatService implements WebSocketCoreInterface {
71
+ // Implementation that uses the interface methods
72
+ }
73
+ ```
74
+
75
+ ## Steps to Fix
76
+
77
+ 1. Define a shared interface/abstract class that includes all the methods and properties needed by the mixins
78
+ 2. Update WebSocketCoreService to implement this interface
79
+ 3. Update all mixins to implement this interface rather than using "on WebSocketCoreService"
80
+ 4. In the final WebSocketApiService class, implement the interface and have it delegate to the core service
81
+
82
+ ## For Now
83
+
84
+ As a temporary solution, a simplified version of main.dart has been created that forces the app into maintenance mode, bypassing the WebSocket initialization and connection issues.
api.py CHANGED
@@ -4,10 +4,13 @@ import logging
4
  import os
5
  import pathlib
6
  import time
 
7
  from aiohttp import web, WSMsgType
8
  from typing import Dict, Any
9
- from api_core import VideoGenerationAPI
10
 
 
 
 
11
  from api_config import *
12
 
13
  # Configure logging
@@ -17,263 +20,17 @@ logging.basicConfig(
17
  )
18
  logger = logging.getLogger(__name__)
19
 
20
- async def process_generic_request(data: dict, ws: web.WebSocketResponse, api) -> None:
21
- """Handle general requests that don't fit into specialized queues"""
22
- try:
23
- request_id = data.get('requestId')
24
- action = data.get('action')
25
-
26
- def error_response(message: str):
27
- return {
28
- 'action': action,
29
- 'requestId': request_id,
30
- 'success': False,
31
- 'error': message
32
- }
33
-
34
- if action == 'heartbeat':
35
- # Include user role info in heartbeat response
36
- user_role = getattr(ws, 'user_role', 'anon')
37
- await ws.send_json({
38
- 'action': 'heartbeat',
39
- 'requestId': request_id,
40
- 'success': True,
41
- 'user_role': user_role
42
- })
43
-
44
- elif action == 'get_user_role':
45
- # Return the user role information
46
- user_role = getattr(ws, 'user_role', 'anon')
47
- await ws.send_json({
48
- 'action': 'get_user_role',
49
- 'requestId': request_id,
50
- 'success': True,
51
- 'user_role': user_role
52
- })
53
-
54
- elif action == 'generate_caption':
55
- title = data.get('params', {}).get('title')
56
- description = data.get('params', {}).get('description')
57
-
58
- if not title or not description:
59
- await ws.send_json(error_response('Missing title or description'))
60
- return
61
-
62
- caption = await api.generate_caption(title, description)
63
- await ws.send_json({
64
- 'action': action,
65
- 'requestId': request_id,
66
- 'success': True,
67
- 'caption': caption
68
- })
69
-
70
- elif action == 'generate_thumbnail':
71
- title = data.get('params', {}).get('title')
72
- description = data.get('params', {}).get('description')
73
-
74
- if not title or not description:
75
- await ws.send_json(error_response('Missing title or description'))
76
- return
77
-
78
- thumbnail = await api.generate_thumbnail(title, description)
79
- await ws.send_json({
80
- 'action': action,
81
- 'requestId': request_id,
82
- 'success': True,
83
- 'thumbnailUrl': thumbnail
84
- })
85
-
86
- else:
87
- await ws.send_json(error_response(f'Unknown action: {action}'))
88
-
89
- except Exception as e:
90
- logger.error(f"Error processing generic request: {str(e)}")
91
- try:
92
- await ws.send_json({
93
- 'action': data.get('action'),
94
- 'requestId': data.get('requestId'),
95
- 'success': False,
96
- 'error': f'Internal server error: {str(e)}'
97
- })
98
- except Exception as send_error:
99
- logger.error(f"Error sending error response: {send_error}")
100
-
101
- async def process_search_queue(queue: asyncio.Queue, ws: web.WebSocketResponse, api):
102
- """Medium priority queue for search operations"""
103
- while True:
104
- try:
105
- data = await queue.get()
106
- request_id = data.get('requestId')
107
- query = data.get('query', '').strip()
108
- search_count = data.get('searchCount', 0)
109
- attempt_count = data.get('attemptCount', 0)
110
-
111
- logger.info(f"Processing search request: query='{query}', search_count={search_count}, attempt={attempt_count}")
112
-
113
- if not query:
114
- logger.warning(f"Empty query received in request: {data}")
115
- result = {
116
- 'action': 'search',
117
- 'requestId': request_id,
118
- 'success': False,
119
- 'error': 'No search query provided'
120
- }
121
- else:
122
- try:
123
- search_result = await api.search_video(
124
- query,
125
- search_count=search_count,
126
- attempt_count=attempt_count
127
- )
128
-
129
- if search_result:
130
- logger.info(f"Search successful for query '{query}' (#{search_count})")
131
- result = {
132
- 'action': 'search',
133
- 'requestId': request_id,
134
- 'success': True,
135
- 'result': search_result
136
- }
137
- else:
138
- logger.warning(f"No results found for query '{query}' (#{search_count})")
139
- result = {
140
- 'action': 'search',
141
- 'requestId': request_id,
142
- 'success': False,
143
- 'error': 'No results found'
144
- }
145
- except Exception as e:
146
- logger.error(f"Search error for query '{query}' (#{search_count}, attempt {attempt_count}): {str(e)}")
147
- result = {
148
- 'action': 'search',
149
- 'requestId': request_id,
150
- 'success': False,
151
- 'error': f'Search error: {str(e)}'
152
- }
153
-
154
- await ws.send_json(result)
155
-
156
- except Exception as e:
157
- logger.error(f"Error in search queue processor: {str(e)}")
158
- try:
159
- error_response = {
160
- 'action': 'search',
161
- 'requestId': data.get('requestId') if 'data' in locals() else None,
162
- 'success': False,
163
- 'error': f'Internal server error: {str(e)}'
164
- }
165
- await ws.send_json(error_response)
166
- except Exception as send_error:
167
- logger.error(f"Error sending error response: {send_error}")
168
- finally:
169
- if 'queue' in locals():
170
- queue.task_done()
171
-
172
- async def process_chat_queue(queue: asyncio.Queue, ws: web.WebSocketResponse):
173
- """High priority queue for chat operations"""
174
- while True:
175
- data = await queue.get()
176
- try:
177
- api = ws.app['api']
178
- if data['action'] == 'join_chat':
179
- result = await api.handle_join_chat(data, ws)
180
- elif data['action'] == 'chat_message':
181
- result = await api.handle_chat_message(data, ws)
182
- elif data['action'] == 'leave_chat':
183
- result = await api.handle_leave_chat(data, ws)
184
- await ws.send_json(result)
185
- except Exception as e:
186
- logger.error(f"Error processing chat request: {e}")
187
- try:
188
- await ws.send_json({
189
- 'action': data['action'],
190
- 'requestId': data.get('requestId'),
191
- 'success': False,
192
- 'error': f'Chat error: {str(e)}'
193
- })
194
- except Exception as send_error:
195
- logger.error(f"Error sending error response: {send_error}")
196
- finally:
197
- queue.task_done()
198
 
199
- async def process_video_queue(queue: asyncio.Queue, ws: web.WebSocketResponse):
200
- """Process multiple video generation requests in parallel"""
201
- active_tasks = set()
202
- MAX_CONCURRENT = len(VIDEO_ROUND_ROBIN_ENDPOINT_URLS) # Match client's max concurrent generations
203
-
204
- async def process_single_request(data):
205
- try:
206
- api = ws.app['api']
207
- title = data.get('title', '')
208
- description = data.get('description', '')
209
- video_prompt_prefix = data.get('video_prompt_prefix', '')
210
- options = data.get('options', {})
211
-
212
- # Get the user role from the websocket
213
- user_role = getattr(ws, 'user_role', 'anon')
214
-
215
- # Pass the user role to generate_video
216
- video_data = await api.generate_video(title, description, video_prompt_prefix, options, user_role)
217
-
218
- result = {
219
- 'action': 'generate_video',
220
- 'requestId': data.get('requestId'),
221
- 'success': True,
222
- 'video': video_data,
223
- }
224
-
225
- await ws.send_json(result)
226
-
227
- except Exception as e:
228
- logger.error(f"Error processing video request: {e}")
229
- try:
230
- await ws.send_json({
231
- 'action': 'generate_video',
232
- 'requestId': data.get('requestId'),
233
- 'success': False,
234
- 'error': f'Video generation error: {str(e)}'
235
- })
236
- except Exception as send_error:
237
- logger.error(f"Error sending error response: {send_error}")
238
- finally:
239
- active_tasks.discard(asyncio.current_task())
240
-
241
- while True:
242
- # Clean up completed tasks
243
- active_tasks = {task for task in active_tasks if not task.done()}
244
-
245
- # Start new tasks if we have capacity
246
- while len(active_tasks) < MAX_CONCURRENT:
247
- try:
248
- # Use try_get to avoid blocking if queue is empty
249
- data = await asyncio.wait_for(queue.get(), timeout=0.1)
250
-
251
- # Create and start new task
252
- task = asyncio.create_task(process_single_request(data))
253
- active_tasks.add(task)
254
-
255
- except asyncio.TimeoutError:
256
- # No items in queue, break inner loop
257
- break
258
- except Exception as e:
259
- logger.error(f"Error creating video generation task: {e}")
260
- break
261
-
262
- # Wait a short time before checking queue again
263
- await asyncio.sleep(0.1)
264
-
265
- # Handle any completed tasks' errors
266
- for task in list(active_tasks):
267
- if task.done():
268
- try:
269
- await task
270
- except Exception as e:
271
- logger.error(f"Task failed with error: {e}")
272
- active_tasks.discard(task)
273
 
274
  async def status_handler(request: web.Request) -> web.Response:
275
  """Handler for API status endpoint"""
276
- api = request.app['api']
277
 
278
  # Get current busy status of all endpoints
279
  endpoint_statuses = []
@@ -287,18 +44,44 @@ async def status_handler(request: web.Request) -> web.Response:
287
  'error_until': ep.error_until
288
  })
289
 
 
 
 
 
 
 
290
  return web.json_response({
291
  'product': PRODUCT_NAME,
292
  'version': PRODUCT_VERSION,
293
  'maintenance_mode': MAINTENANCE_MODE,
294
  'available_endpoints': len(VIDEO_ROUND_ROBIN_ENDPOINT_URLS),
295
  'endpoint_status': endpoint_statuses,
296
- 'active_endpoints': sum(1 for ep in endpoint_statuses if not ep['busy'] and ('error_until' not in ep or ep['error_until'] < time.time()))
 
 
297
  })
298
 
299
- # Dictionary to track connected anonymous clients by IP address
300
- anon_connections = {}
301
- anon_connection_lock = asyncio.Lock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
  async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
304
  # Check if maintenance mode is enabled
@@ -314,16 +97,17 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
314
  timeout=30.0 # we want to keep things tight and short
315
  )
316
 
317
- ws.app = request.app
318
  await ws.prepare(request)
319
- api = request.app['api']
320
 
321
  # Get the Hugging Face token from query parameters
322
  hf_token = request.query.get('hf_token', '')
323
 
 
 
 
324
  # Validate the token and determine the user role
325
- user_role = await api.validate_user_token(hf_token)
326
- logger.info(f"User connected with role: {user_role}")
327
 
328
  # Get client IP address
329
  peername = request.transport.get_extra_info('peername')
@@ -332,39 +116,29 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
332
  else:
333
  client_ip = request.headers.get('X-Forwarded-For', 'unknown').split(',')[0].strip()
334
 
335
- logger.info(f"Client connecting from IP: {client_ip} with role: {user_role}")
336
 
337
  # Check for anonymous user connection limits
338
  if user_role == 'anon':
339
  async with anon_connection_lock:
340
- # Check if this IP already has a connection
341
- if client_ip in anon_connections and anon_connections[client_ip] > 0:
342
- # Return an error for anonymous users with multiple connections
343
- return web.json_response({
344
- 'error': 'Anonymous user limit exceeded',
345
- 'message': 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!',
346
- 'errorType': 'anon_limit_exceeded'
347
- }, status=429) # 429 Too Many Requests
348
-
349
  # Track this connection
350
  anon_connections[client_ip] = anon_connections.get(client_ip, 0) + 1
351
  # Store the IP so we can clean up later
352
  ws.client_ip = client_ip
 
 
 
 
353
 
354
- # Store the user role in the websocket
355
  ws.user_role = user_role
356
-
357
- # Create separate queues for different request types
358
- chat_queue = asyncio.Queue()
359
- video_queue = asyncio.Queue()
360
- search_queue = asyncio.Queue()
361
 
362
- # Start background tasks for handling different request types
363
- background_tasks = [
364
- asyncio.create_task(process_chat_queue(chat_queue, ws)),
365
- asyncio.create_task(process_video_queue(video_queue, ws)),
366
- asyncio.create_task(process_search_queue(search_queue, ws, api))
367
- ]
368
 
369
  try:
370
  async for msg in ws:
@@ -373,18 +147,40 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
373
  data = json.loads(msg.data)
374
  action = data.get('action')
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  # Route requests to appropriate queues
377
  if action in ['join_chat', 'leave_chat', 'chat_message']:
378
- await chat_queue.put(data)
379
  elif action in ['generate_video']:
380
- await video_queue.put(data)
381
  elif action == 'search':
382
- await search_queue.put(data)
383
  else:
384
- await process_generic_request(data, ws, api)
385
 
386
  except Exception as e:
387
- logger.error(f"Error processing WebSocket message: {str(e)}")
388
  await ws.send_json({
389
  'action': data.get('action') if 'data' in locals() else 'unknown',
390
  'success': False,
@@ -395,13 +191,8 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
395
  break
396
 
397
  finally:
398
- # Cleanup
399
- for task in background_tasks:
400
- task.cancel()
401
- try:
402
- await asyncio.gather(*background_tasks, return_exceptions=True)
403
- except asyncio.CancelledError:
404
- pass
405
 
406
  # Cleanup anonymous connection tracking
407
  if getattr(ws, 'user_role', None) == 'anon' and hasattr(ws, 'client_ip'):
@@ -412,6 +203,10 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
412
  if anon_connections[client_ip] == 0:
413
  del anon_connections[client_ip]
414
  logger.info(f"Anonymous connection from {client_ip} closed. Remaining: {anon_connections.get(client_ip, 0)}")
 
 
 
 
415
 
416
  return ws
417
 
@@ -420,20 +215,17 @@ async def init_app() -> web.Application:
420
  client_max_size=1024**2*20 # 20MB max size
421
  )
422
 
423
- # Create API instance
424
- api = VideoGenerationAPI()
425
- app['api'] = api
426
-
427
  # Add cleanup logic
428
- async def cleanup_api(app):
429
- # Add any necessary cleanup for the API
430
- pass
431
 
432
- app.on_shutdown.append(cleanup_api)
433
 
434
  # Add routes
435
  app.router.add_get('/ws', websocket_handler)
436
  app.router.add_get('/api/status', status_handler)
 
437
 
438
  # Set up static file serving
439
  # Define the path to the public directory
 
4
  import os
5
  import pathlib
6
  import time
7
+ import uuid
8
  from aiohttp import web, WSMsgType
9
  from typing import Dict, Any
 
10
 
11
+ from api_core import VideoGenerationAPI
12
+ from api_session import SessionManager
13
+ from api_metrics import MetricsTracker
14
  from api_config import *
15
 
16
  # Configure logging
 
20
  )
21
  logger = logging.getLogger(__name__)
22
 
23
+ # Create global session and metrics managers
24
+ session_manager = SessionManager()
25
+ metrics_tracker = MetricsTracker()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ # Dictionary to track connected anonymous clients by IP address
28
+ anon_connections = {}
29
+ anon_connection_lock = asyncio.Lock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  async def status_handler(request: web.Request) -> web.Response:
32
  """Handler for API status endpoint"""
33
+ api = session_manager.shared_api
34
 
35
  # Get current busy status of all endpoints
36
  endpoint_statuses = []
 
44
  'error_until': ep.error_until
45
  })
46
 
47
+ # Get session statistics
48
+ session_stats = session_manager.get_session_stats()
49
+
50
+ # Get metrics
51
+ api_metrics = metrics_tracker.get_metrics()
52
+
53
  return web.json_response({
54
  'product': PRODUCT_NAME,
55
  'version': PRODUCT_VERSION,
56
  'maintenance_mode': MAINTENANCE_MODE,
57
  'available_endpoints': len(VIDEO_ROUND_ROBIN_ENDPOINT_URLS),
58
  'endpoint_status': endpoint_statuses,
59
+ 'active_endpoints': sum(1 for ep in endpoint_statuses if not ep['busy'] and ('error_until' not in ep or ep['error_until'] < time.time())),
60
+ 'active_sessions': session_stats,
61
+ 'metrics': api_metrics
62
  })
63
 
64
+ async def metrics_handler(request: web.Request) -> web.Response:
65
+ """Handler for detailed metrics endpoint (protected)"""
66
+ # Check for API key in header or query param
67
+ auth_header = request.headers.get('Authorization', '')
68
+ api_key = None
69
+
70
+ if auth_header.startswith('Bearer '):
71
+ api_key = auth_header[7:]
72
+ else:
73
+ api_key = request.query.get('key')
74
+
75
+ # Validate API key (using SECRET_TOKEN as the API key)
76
+ if not api_key or api_key != SECRET_TOKEN:
77
+ return web.json_response({
78
+ 'error': 'Unauthorized'
79
+ }, status=401)
80
+
81
+ # Get detailed metrics
82
+ detailed_metrics = metrics_tracker.get_detailed_metrics()
83
+
84
+ return web.json_response(detailed_metrics)
85
 
86
  async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
87
  # Check if maintenance mode is enabled
 
97
  timeout=30.0 # we want to keep things tight and short
98
  )
99
 
 
100
  await ws.prepare(request)
 
101
 
102
  # Get the Hugging Face token from query parameters
103
  hf_token = request.query.get('hf_token', '')
104
 
105
+ # Generate a unique user ID for this connection
106
+ user_id = str(uuid.uuid4())
107
+
108
  # Validate the token and determine the user role
109
+ user_role = await session_manager.shared_api.validate_user_token(hf_token)
110
+ logger.info(f"User {user_id} connected with role: {user_role}")
111
 
112
  # Get client IP address
113
  peername = request.transport.get_extra_info('peername')
 
116
  else:
117
  client_ip = request.headers.get('X-Forwarded-For', 'unknown').split(',')[0].strip()
118
 
119
+ logger.info(f"Client {user_id} connecting from IP: {client_ip} with role: {user_role}")
120
 
121
  # Check for anonymous user connection limits
122
  if user_role == 'anon':
123
  async with anon_connection_lock:
 
 
 
 
 
 
 
 
 
124
  # Track this connection
125
  anon_connections[client_ip] = anon_connections.get(client_ip, 0) + 1
126
  # Store the IP so we can clean up later
127
  ws.client_ip = client_ip
128
+
129
+ # Log multiple connections from same IP but don't restrict them
130
+ if anon_connections[client_ip] > 1:
131
+ logger.info(f"Multiple anonymous connections from IP {client_ip}: {anon_connections[client_ip]} connections")
132
 
133
+ # Store the user role in the websocket for easy access
134
  ws.user_role = user_role
135
+ ws.user_id = user_id
 
 
 
 
136
 
137
+ # Register with metrics
138
+ metrics_tracker.register_session(user_id, client_ip)
139
+
140
+ # Create a new session for this user
141
+ user_session = await session_manager.create_session(user_id, user_role, ws)
 
142
 
143
  try:
144
  async for msg in ws:
 
147
  data = json.loads(msg.data)
148
  action = data.get('action')
149
 
150
+ # Check for rate limiting
151
+ request_type = 'other'
152
+ if action in ['join_chat', 'leave_chat', 'chat_message']:
153
+ request_type = 'chat'
154
+ elif action in ['generate_video']:
155
+ request_type = 'video'
156
+ elif action == 'search':
157
+ request_type = 'search'
158
+
159
+ # Record the request for metrics
160
+ await metrics_tracker.record_request(user_id, client_ip, request_type, user_role)
161
+
162
+ # Check rate limits (except for admins)
163
+ if user_role != 'admin' and await metrics_tracker.is_rate_limited(user_id, request_type, user_role):
164
+ await ws.send_json({
165
+ 'action': action,
166
+ 'requestId': data.get('requestId'),
167
+ 'success': False,
168
+ 'error': f'Rate limit exceeded for {request_type} requests. Please try again later.'
169
+ })
170
+ continue
171
+
172
  # Route requests to appropriate queues
173
  if action in ['join_chat', 'leave_chat', 'chat_message']:
174
+ await user_session.chat_queue.put(data)
175
  elif action in ['generate_video']:
176
+ await user_session.video_queue.put(data)
177
  elif action == 'search':
178
+ await user_session.search_queue.put(data)
179
  else:
180
+ await user_session.process_generic_request(data)
181
 
182
  except Exception as e:
183
+ logger.error(f"Error processing WebSocket message for user {user_id}: {str(e)}")
184
  await ws.send_json({
185
  'action': data.get('action') if 'data' in locals() else 'unknown',
186
  'success': False,
 
191
  break
192
 
193
  finally:
194
+ # Cleanup session
195
+ await session_manager.delete_session(user_id)
 
 
 
 
 
196
 
197
  # Cleanup anonymous connection tracking
198
  if getattr(ws, 'user_role', None) == 'anon' and hasattr(ws, 'client_ip'):
 
203
  if anon_connections[client_ip] == 0:
204
  del anon_connections[client_ip]
205
  logger.info(f"Anonymous connection from {client_ip} closed. Remaining: {anon_connections.get(client_ip, 0)}")
206
+
207
+ # Unregister from metrics
208
+ metrics_tracker.unregister_session(user_id, client_ip)
209
+ logger.info(f"Connection closed for user {user_id}")
210
 
211
  return ws
212
 
 
215
  client_max_size=1024**2*20 # 20MB max size
216
  )
217
 
 
 
 
 
218
  # Add cleanup logic
219
+ async def cleanup(app):
220
+ logger.info("Shutting down server, closing all sessions...")
221
+ await session_manager.close_all_sessions()
222
 
223
+ app.on_shutdown.append(cleanup)
224
 
225
  # Add routes
226
  app.router.add_get('/ws', websocket_handler)
227
  app.router.add_get('/api/status', status_handler)
228
+ app.router.add_get('/api/metrics', metrics_handler)
229
 
230
  # Set up static file serving
231
  # Define the path to the public directory
api_config.py CHANGED
@@ -8,8 +8,6 @@ TEXT_MODEL = os.environ.get('HF_TEXT_MODEL',
8
  'HuggingFaceTB/SmolLM2-1.7B-Instruct'
9
  )
10
 
11
- IMAGE_MODEL = os.environ.get('HF_IMAGE_MODEL', '')
12
-
13
  # Environment variable to control maintenance mode
14
  MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', 'false').lower() in ('true', 'yes', '1', 't')
15
 
@@ -49,6 +47,8 @@ POSITIVE_PROMPT_SUFFIX = "high quality, cinematic, 4K, intricate details"
49
 
50
  GUIDANCE_SCALE = 1.0
51
 
 
 
52
  # anonymous users are people browing AiTube2 without being connected
53
  # this category suffers from regular abuse so we need to enforce strict limitations
54
  CONFIG_FOR_ANONYMOUS_USERS = {
@@ -110,12 +110,12 @@ CONFIG_FOR_STANDARD_HF_USERS = {
110
  "max_clip_framerate": 25,
111
 
112
  "min_clip_width": 544,
113
- "default_clip_width": 1216, # 768, # 640,
114
- "max_clip_width": 1216, # 768, # 640,
115
 
116
  "min_clip_height": 320,
117
- "default_clip_height": 640, # 448, # 416,
118
- "max_clip_height": 640, # 448, # 416,
119
  }
120
 
121
  # Hugging Face users with a Pro may enjoy an improved experience
@@ -143,12 +143,12 @@ CONFIG_FOR_PRO_HF_USERS = {
143
  "max_clip_framerate": 25,
144
 
145
  "min_clip_width": 544,
146
- "default_clip_width": 768, # 640,
147
- "max_clip_width": 768, # 640,
148
 
149
  "min_clip_height": 320,
150
- "default_clip_height": 448, # 416,
151
- "max_clip_height": 448, # 416,
152
  }
153
 
154
  CONFIG_FOR_ADMIN_HF_USERS = {
 
8
  'HuggingFaceTB/SmolLM2-1.7B-Instruct'
9
  )
10
 
 
 
11
  # Environment variable to control maintenance mode
12
  MAINTENANCE_MODE = os.environ.get('MAINTENANCE_MODE', 'false').lower() in ('true', 'yes', '1', 't')
13
 
 
47
 
48
  GUIDANCE_SCALE = 1.0
49
 
50
+ THUMBNAIL_FRAMES = 65
51
+
52
  # anonymous users are people browing AiTube2 without being connected
53
  # this category suffers from regular abuse so we need to enforce strict limitations
54
  CONFIG_FOR_ANONYMOUS_USERS = {
 
110
  "max_clip_framerate": 25,
111
 
112
  "min_clip_width": 544,
113
+ "default_clip_width": 928, # 1216, # 768, # 640,
114
+ "max_clip_width": 928, # 1216, # 768, # 640,
115
 
116
  "min_clip_height": 320,
117
+ "default_clip_height": 512, # 448, # 416,
118
+ "max_clip_height": 512, # 448, # 416,
119
  }
120
 
121
  # Hugging Face users with a Pro may enjoy an improved experience
 
143
  "max_clip_framerate": 25,
144
 
145
  "min_clip_width": 544,
146
+ "default_clip_width": 928, # 1216, # 768, # 640,
147
+ "max_clip_width": 928, # 1216, # 768, # 640,
148
 
149
  "min_clip_height": 320,
150
+ "default_clip_height": 512, # 448, # 416,
151
+ "max_clip_height": 512, # 448, # 416,
152
  }
153
 
154
  CONFIG_FOR_ADMIN_HF_USERS = {
api_core.py CHANGED
@@ -299,7 +299,7 @@ class VideoGenerationAPI:
299
  # Maximum number of attempts to generate a description without placeholder tags
300
  max_attempts = 2
301
  current_attempt = attempt_count
302
- temperature = 0.75 # Initial temperature
303
 
304
  while current_attempt <= max_attempts:
305
  prompt = f"""# Instruction
@@ -321,7 +321,7 @@ Describe the first scene/shot for: "{query}".
321
  title: \""""
322
 
323
  try:
324
- print(f"search_video(): calling self.inference_client.text_generation({prompt}, model={TEXT_MODEL}, max_new_tokens=150, temperature={temperature})")
325
  response = await asyncio.get_event_loop().run_in_executor(
326
  None,
327
  lambda: self.inference_client.text_generation(
@@ -344,7 +344,7 @@ title: \""""
344
  if not result or not isinstance(result, dict):
345
  logger.error(f"Invalid result format: {result}")
346
  current_attempt += 1
347
- temperature = 0.7 # Try with different temperature on next attempt
348
  continue
349
 
350
  # Extract fields with defaults
@@ -357,7 +357,7 @@ title: \""""
357
  if current_attempt < max_attempts:
358
  # Try again with a higher temperature
359
  current_attempt += 1
360
- temperature = 0.7
361
  continue
362
  else:
363
  # If we've reached max attempts, use the title as description
@@ -371,15 +371,10 @@ title: \""""
371
  tags = []
372
  tags = [str(t).strip() for t in tags if t and isinstance(t, (str, int, float))]
373
 
374
- # Generate thumbnail
375
- try:
376
- #thumbnail = await self.generate_thumbnail(title, description)
377
- raise ValueError("thumbnail generation is too buggy and slow right now")
378
- except Exception as e:
379
- logger.error(f"Thumbnail generation failed: {str(e)}")
380
- thumbnail = ""
381
 
382
- print("got response thumbnail")
383
  # Return valid result with all required fields
384
  return {
385
  'id': str(uuid.uuid4()),
@@ -417,28 +412,9 @@ title: \""""
417
  'tags': []
418
  }
419
 
420
- async def generate_thumbnail(self, title: str, description: str) -> str:
421
- """Generate thumbnail using HF image generation"""
422
- try:
423
- image_prompt = f"Thumbnail for video titled '{title}': {description}"
424
-
425
- image = await asyncio.get_event_loop().run_in_executor(
426
- None,
427
- lambda: self.inference_client.text_to_image(
428
- prompt=image_prompt,
429
- model=IMAGE_MODEL,
430
- width=768,
431
- height=512
432
- )
433
- )
434
-
435
- buffered = io.BytesIO()
436
- image.save(buffered, format="JPEG")
437
- img_str = base64.b64encode(buffered.getvalue()).decode()
438
- return f"data:image/jpeg;base64,{img_str}"
439
- except Exception as e:
440
- logger.error(f"Error generating thumbnail: {str(e)}")
441
- return ""
442
 
443
  async def generate_caption(self, title: str, description: str) -> str:
444
  """Generate detailed caption using HF text generation"""
@@ -589,6 +565,97 @@ Your caption:"""
589
  # Fallback to original description if prompt generation fails
590
  return description
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  async def generate_video(self, title: str, description: str, video_prompt_prefix: str, options: dict, user_role: UserRole = 'anon') -> str:
593
  """Generate video using available space from pool"""
594
  video_id = options.get('video_id', str(uuid.uuid4()))
@@ -617,114 +684,121 @@ Your caption:"""
617
  # Log the user role and config values being used
618
  logger.info(f"Using config values: width={width}, height={height}, num_frames={num_frames}, steps={num_inference_steps}, fps={frame_rate} | role: {user_role}")
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  json_payload = {
621
  "inputs": {
622
  "prompt": prompt,
623
  },
624
  "parameters": {
625
-
626
  # ------------------- settings for LTX-Video -----------------------
627
-
628
- # this param doesn't exist
629
- #"enhance_prompt_toggle": options.get('enhance_prompt', False),
630
-
631
- "negative_prompt": options.get('negative_prompt', NEGATIVE_PROMPT),
632
-
633
- # note about resolution:
634
- # we cannot use 720 since it cannot be divided by 32
635
  "width": width,
636
  "height": height,
637
-
638
- # this is a hack to fool LTX-Video into believing our input image is an actual video frame with poor encoding quality
639
- #"input_image_quality": 70,
640
-
641
- # LTX-Video requires a frame number divisible by 8, plus one frame
642
- # note: glitches might appear if you use more than 168 frames
643
  "num_frames": num_frames,
644
-
645
- # using 30 steps seems to be enough for most cases, otherwise use 50 for best quality
646
- # I think using a large number of steps (> 30) might create some overexposure and saturation
647
  "num_inference_steps": num_inference_steps,
648
-
649
- # values between 3.0 and 4.0 are nice
650
  "guidance_scale": options.get('guidance_scale', GUIDANCE_SCALE),
651
-
652
- "seed": options.get('seed', 42),
653
 
654
- # ----------------------------------------------------------------
655
-
656
  # ------------------- settings for Varnish -----------------------
657
- # This will double the number of frames.
658
- # You can activate this if you want:
659
- # - a slow motion effect (in that case use double_num_frames=True and fps=24, 25 or 30)
660
- # - a HD soap / video game effect (in that case use double_num_frames=True and fps=60)
661
- "double_num_frames": False, # <- False as we want real-time generation
662
-
663
- # controls the number of frames per second
664
- # use this in combination with the num_frames and double_num_frames settings to control the duration and "feel" of your video
665
- # typical values are: 24, 25, 30, 60
666
  "fps": frame_rate,
667
-
668
- # upscale the video using Real-ESRGAN.
669
- # This upscaling algorithm is relatively fast,
670
- # but might create an uncanny "3D render" or "drawing" effect.
671
- "super_resolution": False, # <- False as we want real-time generation
672
-
673
- # for cosmetic purposes and get a "cinematic" feel, you can optionally add some film grain.
674
- # it is not recommended to add film grain if your theme doesn't match (film grain is great for black & white, retro looks)
675
- # and if you do, adding more than 12% will start to negatively impact file size (video codecs aren't great are compressing film grain)
676
- # 0% = no grain
677
- # 10% = a bit of grain
678
- "grain_amount": 0, # value between 0-100
679
-
680
-
681
- # The range of the CRF scale is 0–51, where:
682
- # 0 is lossless (for 8 bit only, for 10 bit use -qp 0)
683
- # 23 is the default
684
- # 51 is worst quality possible
685
- # A lower value generally leads to higher quality, and a subjectively sane range is 17–28.
686
- # Consider 17 or 18 to be visually lossless or nearly so;
687
- # it should look the same or nearly the same as the input but it isn't technically lossless.
688
- # The range is exponential, so increasing the CRF value +6 results in roughly half the bitrate / file size, while -6 leads to roughly twice the bitrate.
689
- #"quality": 23,
690
-
691
  }
692
  }
 
 
 
 
 
 
 
 
693
 
 
694
  async with self.endpoint_manager.get_endpoint() as endpoint:
695
- #logger.info(f"Using endpoint {endpoint.id} for video generation")
696
 
697
  try:
698
  async with ClientSession() as session:
 
 
 
 
699
  async with session.post(
700
  endpoint.url,
701
  headers={
702
  "Accept": "application/json",
703
  "Authorization": f"Bearer {HF_TOKEN}",
704
- "Content-Type": "application/json"
 
705
  },
706
  json=json_payload,
707
- timeout=8 # Fast generation should complete within 8 seconds
708
  ) as response:
 
 
 
709
  if response.status != 200:
710
  error_text = await response.text()
 
711
  # Mark endpoint as in error state
712
  await self._mark_endpoint_error(endpoint)
 
 
 
713
  raise Exception(f"Video generation failed: HTTP {response.status} - {error_text}")
714
 
715
  result = await response.json()
 
716
 
717
  if "error" in result:
 
 
718
  # Mark endpoint as in error state
719
  await self._mark_endpoint_error(endpoint)
720
- raise Exception(f"Video generation failed: {result['error']}")
 
 
 
721
 
722
  video_data_uri = result.get("video")
723
  if not video_data_uri:
 
724
  # Mark endpoint as in error state
725
  await self._mark_endpoint_error(endpoint)
726
  raise Exception("No video data in response")
727
 
 
 
 
 
728
  # Reset error count on successful call
729
  endpoint.error_count = 0
730
  endpoint.error_until = 0
@@ -733,13 +807,15 @@ Your caption:"""
733
 
734
  except asyncio.TimeoutError:
735
  # Handle timeout specifically
 
736
  await self._mark_endpoint_error(endpoint, is_timeout=True)
737
- raise Exception(f"Endpoint {endpoint.id} timed out")
738
  except Exception as e:
739
  # Handle all other exceptions
 
740
  if not isinstance(e, asyncio.TimeoutError): # Already handled above
741
  await self._mark_endpoint_error(endpoint)
742
- raise e
743
 
744
  async def _mark_endpoint_error(self, endpoint: Endpoint, is_timeout: bool = False):
745
  """Mark an endpoint as being in error state with exponential backoff"""
 
299
  # Maximum number of attempts to generate a description without placeholder tags
300
  max_attempts = 2
301
  current_attempt = attempt_count
302
+ temperature = 0.7 # Initial temperature
303
 
304
  while current_attempt <= max_attempts:
305
  prompt = f"""# Instruction
 
321
  title: \""""
322
 
323
  try:
324
+ #print(f"search_video(): calling self.inference_client.text_generation({prompt}, model={TEXT_MODEL}, max_new_tokens=150, temperature={temperature})")
325
  response = await asyncio.get_event_loop().run_in_executor(
326
  None,
327
  lambda: self.inference_client.text_generation(
 
344
  if not result or not isinstance(result, dict):
345
  logger.error(f"Invalid result format: {result}")
346
  current_attempt += 1
347
+ temperature = 0.65 # Try with different temperature on next attempt
348
  continue
349
 
350
  # Extract fields with defaults
 
357
  if current_attempt < max_attempts:
358
  # Try again with a higher temperature
359
  current_attempt += 1
360
+ temperature = 0.6
361
  continue
362
  else:
363
  # If we've reached max attempts, use the title as description
 
371
  tags = []
372
  tags = [str(t).strip() for t in tags if t and isinstance(t, (str, int, float))]
373
 
374
+ # Don't generate thumbnails upfront - let the frontend generate them on demand
375
+ # This makes search results load faster
376
+ thumbnail = ""
 
 
 
 
377
 
 
378
  # Return valid result with all required fields
379
  return {
380
  'id': str(uuid.uuid4()),
 
412
  'tags': []
413
  }
414
 
415
+ # The generate_thumbnail function has been removed because we now use
416
+ # generate_video_thumbnail for all thumbnails, which generates a video clip
417
+ # instead of a static image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
  async def generate_caption(self, title: str, description: str) -> str:
420
  """Generate detailed caption using HF text generation"""
 
565
  # Fallback to original description if prompt generation fails
566
  return description
567
 
568
+ async def generate_video_thumbnail(self, title: str, description: str, video_prompt_prefix: str, options: dict, user_role: UserRole = 'anon') -> str:
569
+ """
570
+ Generate a short, low-resolution video thumbnail for search results and previews.
571
+ Optimized for quick generation and low resource usage.
572
+ """
573
+ video_id = options.get('video_id', str(uuid.uuid4()))
574
+ seed = options.get('seed', generate_seed())
575
+ request_id = str(uuid.uuid4())[:8] # Generate a short ID for logging
576
+
577
+ logger.info(f"[{request_id}] Starting video thumbnail generation for video_id: {video_id}")
578
+ logger.info(f"[{request_id}] Title: '{title}', User role: {user_role}")
579
+
580
+ # Create a more concise prompt for the thumbnail
581
+ clip_caption = f"{video_prompt_prefix} - {title.strip()}"
582
+
583
+ # Add the thumbnail generation to event history
584
+ self._add_event(video_id, {
585
+ "time": datetime.datetime.utcnow().isoformat() + "Z",
586
+ "event": "thumbnail_generation",
587
+ "caption": clip_caption,
588
+ "seed": seed,
589
+ "request_id": request_id
590
+ })
591
+
592
+ # Use a shorter prompt for thumbnails
593
+ prompt = f"{clip_caption}, {POSITIVE_PROMPT_SUFFIX}"
594
+ logger.info(f"[{request_id}] Using prompt: '{prompt}'")
595
+
596
+ # Specialized configuration for thumbnails - smaller size, single frame
597
+ width = 512 # Reduced size for thumbnails
598
+ height = 288 # 16:9 aspect ratio
599
+ num_frames = THUMBNAIL_FRAMES # Just one frame for static thumbnail
600
+ num_inference_steps = 4 # Fewer steps for faster generation
601
+ frame_rate = 25 # Standard frame rate
602
+
603
+ # Optionally override with options if specified
604
+ width = options.get('width', width)
605
+ height = options.get('height', height)
606
+ num_frames = options.get('num_frames', num_frames)
607
+ num_inference_steps = options.get('num_inference_steps', num_inference_steps)
608
+ frame_rate = options.get('frame_rate', frame_rate)
609
+
610
+ logger.info(f"[{request_id}] Configuration: width={width}, height={height}, frames={num_frames}, steps={num_inference_steps}, fps={frame_rate}")
611
+
612
+ # Add thumbnail-specific tag to help debugging and metrics
613
+ options['thumbnail'] = True
614
+
615
+ # Check for available endpoints before attempting generation
616
+ available_endpoints = sum(1 for ep in self.endpoint_manager.endpoints
617
+ if not ep.busy and time.time() > ep.error_until)
618
+ logger.info(f"[{request_id}] Available endpoints: {available_endpoints}/{len(self.endpoint_manager.endpoints)}")
619
+
620
+ if available_endpoints == 0:
621
+ logger.error(f"[{request_id}] No available endpoints for thumbnail generation")
622
+ return ""
623
+
624
+ # Use the same logic as regular video generation but with thumbnail settings
625
+ try:
626
+ logger.info(f"[{request_id}] Generating thumbnail for video {video_id} with seed {seed}")
627
+
628
+ start_time = time.time()
629
+ # Rest of thumbnail generation logic same as regular video but with optimized settings
630
+ result = await self._generate_video_content(
631
+ prompt=prompt,
632
+ negative_prompt=options.get('negative_prompt', NEGATIVE_PROMPT),
633
+ width=width,
634
+ height=height,
635
+ num_frames=num_frames,
636
+ num_inference_steps=num_inference_steps,
637
+ frame_rate=frame_rate,
638
+ seed=seed,
639
+ options=options,
640
+ user_role=user_role
641
+ )
642
+ duration = time.time() - start_time
643
+
644
+ if result:
645
+ data_length = len(result)
646
+ logger.info(f"[{request_id}] Successfully generated thumbnail in {duration:.2f}s, data length: {data_length} chars")
647
+ return result
648
+ else:
649
+ logger.error(f"[{request_id}] Empty result returned from video generation")
650
+ return ""
651
+
652
+ except Exception as e:
653
+ logger.error(f"[{request_id}] Error generating thumbnail: {e}")
654
+ if hasattr(e, "__traceback__"):
655
+ import traceback
656
+ logger.error(f"[{request_id}] Traceback: {traceback.format_exc()}")
657
+ return "" # Return empty string instead of raising to avoid crashes
658
+
659
  async def generate_video(self, title: str, description: str, video_prompt_prefix: str, options: dict, user_role: UserRole = 'anon') -> str:
660
  """Generate video using available space from pool"""
661
  video_id = options.get('video_id', str(uuid.uuid4()))
 
684
  # Log the user role and config values being used
685
  logger.info(f"Using config values: width={width}, height={height}, num_frames={num_frames}, steps={num_inference_steps}, fps={frame_rate} | role: {user_role}")
686
 
687
+ # Generate the video with standard settings
688
+ return await self._generate_video_content(
689
+ prompt=prompt,
690
+ negative_prompt=options.get('negative_prompt', NEGATIVE_PROMPT),
691
+ width=width,
692
+ height=height,
693
+ num_frames=num_frames,
694
+ num_inference_steps=num_inference_steps,
695
+ frame_rate=frame_rate,
696
+ seed=options.get('seed', 42),
697
+ options=options,
698
+ user_role=user_role
699
+ )
700
+
701
+ async def _generate_video_content(self, prompt: str, negative_prompt: str, width: int,
702
+ height: int, num_frames: int, num_inference_steps: int,
703
+ frame_rate: int, seed: int, options: dict, user_role: UserRole) -> str:
704
+ """
705
+ Internal method to generate video content with specific parameters.
706
+ Used by both regular video generation and thumbnail generation.
707
+ """
708
+ is_thumbnail = options.get('thumbnail', False)
709
+ request_id = options.get('request_id', str(uuid.uuid4())[:8]) # Get or generate request ID
710
+ video_id = options.get('video_id', 'unknown')
711
+
712
+ logger.info(f"[{request_id}] Generating {'thumbnail' if is_thumbnail else 'video'} for video {video_id} with seed {seed}")
713
+
714
  json_payload = {
715
  "inputs": {
716
  "prompt": prompt,
717
  },
718
  "parameters": {
 
719
  # ------------------- settings for LTX-Video -----------------------
720
+ "negative_prompt": negative_prompt,
 
 
 
 
 
 
 
721
  "width": width,
722
  "height": height,
 
 
 
 
 
 
723
  "num_frames": num_frames,
 
 
 
724
  "num_inference_steps": num_inference_steps,
 
 
725
  "guidance_scale": options.get('guidance_scale', GUIDANCE_SCALE),
726
+ "seed": seed,
 
727
 
 
 
728
  # ------------------- settings for Varnish -----------------------
729
+ "double_num_frames": False, # <- False for real-time generation
 
 
 
 
 
 
 
 
730
  "fps": frame_rate,
731
+ "super_resolution": False, # <- False for real-time generation
732
+ "grain_amount": 0, # No film grain
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  }
734
  }
735
+
736
+ # Add thumbnail flag to help with metrics and debugging
737
+ if is_thumbnail:
738
+ json_payload["metadata"] = {
739
+ "is_thumbnail": True,
740
+ "thumbnail_version": "1.0",
741
+ "request_id": request_id
742
+ }
743
 
744
+ # logger.info(f"[{request_id}] Waiting for an available endpoint...")
745
  async with self.endpoint_manager.get_endpoint() as endpoint:
746
+ # logger.info(f"[{request_id}] Using endpoint {endpoint.id} for generation")
747
 
748
  try:
749
  async with ClientSession() as session:
750
+ #logger.info(f"[{request_id}] Sending request to endpoint {endpoint.id}: {endpoint.url}")
751
+ start_time = time.time()
752
+
753
+ # Proceed with actual request
754
  async with session.post(
755
  endpoint.url,
756
  headers={
757
  "Accept": "application/json",
758
  "Authorization": f"Bearer {HF_TOKEN}",
759
+ "Content-Type": "application/json",
760
+ "X-Request-ID": request_id # Add request ID to headers
761
  },
762
  json=json_payload,
763
+ timeout=12 # Extended timeout for thumbnails (was 8s)
764
  ) as response:
765
+ request_duration = time.time() - start_time
766
+ #logger.info(f"[{request_id}] Received response from endpoint {endpoint.id} in {request_duration:.2f}s: HTTP {response.status}")
767
+
768
  if response.status != 200:
769
  error_text = await response.text()
770
+ logger.error(f"[{request_id}] Failed response: {error_text}")
771
  # Mark endpoint as in error state
772
  await self._mark_endpoint_error(endpoint)
773
+ if "paused" in error_text:
774
+ logger.error(f"[{request_id}] Endpoint is paused")
775
+ return ""
776
  raise Exception(f"Video generation failed: HTTP {response.status} - {error_text}")
777
 
778
  result = await response.json()
779
+ logger.info(f"[{request_id}] Successfully parsed JSON response")
780
 
781
  if "error" in result:
782
+ error_msg = result['error']
783
+ logger.error(f"[{request_id}] Error in response: {error_msg}")
784
  # Mark endpoint as in error state
785
  await self._mark_endpoint_error(endpoint)
786
+ if "paused" in str(error_msg).lower():
787
+ logger.error(f"[{request_id}] Endpoint is paused")
788
+ return ""
789
+ raise Exception(f"Video generation failed: {error_msg}")
790
 
791
  video_data_uri = result.get("video")
792
  if not video_data_uri:
793
+ logger.error(f"[{request_id}] No video data in response")
794
  # Mark endpoint as in error state
795
  await self._mark_endpoint_error(endpoint)
796
  raise Exception("No video data in response")
797
 
798
+ # Get data size
799
+ data_size = len(video_data_uri)
800
+ logger.info(f"[{request_id}] Received video data: {data_size} chars")
801
+
802
  # Reset error count on successful call
803
  endpoint.error_count = 0
804
  endpoint.error_until = 0
 
807
 
808
  except asyncio.TimeoutError:
809
  # Handle timeout specifically
810
+ logger.error(f"[{request_id}] Timeout occurred after {time.time() - start_time:.2f}s")
811
  await self._mark_endpoint_error(endpoint, is_timeout=True)
812
+ return ""
813
  except Exception as e:
814
  # Handle all other exceptions
815
+ logger.error(f"[{request_id}] Exception during video generation: {str(e)}")
816
  if not isinstance(e, asyncio.TimeoutError): # Already handled above
817
  await self._mark_endpoint_error(endpoint)
818
+ return ""
819
 
820
  async def _mark_endpoint_error(self, endpoint: Endpoint, is_timeout: bool = False):
821
  """Mark an endpoint as being in error state with exponential backoff"""
api_metrics.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ import asyncio
4
+ from collections import defaultdict
5
+ from typing import Dict, List, Set, Optional
6
+ import datetime
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class MetricsTracker:
11
+ """
12
+ Tracks usage metrics across the API server.
13
+ """
14
+ def __init__(self):
15
+ # Total metrics since server start
16
+ self.total_requests = {
17
+ 'chat': 0,
18
+ 'video': 0,
19
+ 'search': 0,
20
+ 'other': 0,
21
+ }
22
+
23
+ # Per-user metrics
24
+ self.user_metrics = defaultdict(lambda: {
25
+ 'requests': {
26
+ 'chat': 0,
27
+ 'video': 0,
28
+ 'search': 0,
29
+ 'other': 0,
30
+ },
31
+ 'first_seen': time.time(),
32
+ 'last_active': time.time(),
33
+ 'role': 'anon'
34
+ })
35
+
36
+ # Rate limiting buckets (per minute)
37
+ self.rate_limits = {
38
+ 'anon': {
39
+ 'video': 30,
40
+ 'search': 45,
41
+ 'chat': 90,
42
+ 'other': 45
43
+ },
44
+ 'normal': {
45
+ 'video': 60,
46
+ 'search': 90,
47
+ 'chat': 180,
48
+ 'other': 90
49
+ },
50
+ 'pro': {
51
+ 'video': 120,
52
+ 'search': 180,
53
+ 'chat': 300,
54
+ 'other': 180
55
+ },
56
+ 'admin': {
57
+ 'video': 240,
58
+ 'search': 360,
59
+ 'chat': 450,
60
+ 'other': 360
61
+ }
62
+ }
63
+
64
+ # Minute-based rate limiting buckets
65
+ self.time_buckets = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
66
+
67
+ # Lock for thread safety
68
+ self.lock = asyncio.Lock()
69
+
70
+ # Track concurrent sessions by IP
71
+ self.ip_sessions = defaultdict(set)
72
+
73
+ # Server start time
74
+ self.start_time = time.time()
75
+
76
+ async def record_request(self, user_id: str, ip: str, request_type: str, role: str):
77
+ """Record a request for metrics and rate limiting"""
78
+ async with self.lock:
79
+ # Update total metrics
80
+ if request_type in self.total_requests:
81
+ self.total_requests[request_type] += 1
82
+ else:
83
+ self.total_requests['other'] += 1
84
+
85
+ # Update user metrics
86
+ user_data = self.user_metrics[user_id]
87
+ user_data['last_active'] = time.time()
88
+ user_data['role'] = role
89
+
90
+ if request_type in user_data['requests']:
91
+ user_data['requests'][request_type] += 1
92
+ else:
93
+ user_data['requests']['other'] += 1
94
+
95
+ # Update time bucket for rate limiting
96
+ current_minute = int(time.time() / 60)
97
+ self.time_buckets[user_id][current_minute][request_type] += 1
98
+
99
+ # Clean up old time buckets (keep only last 10 minutes)
100
+ cutoff = current_minute - 10
101
+ for minute in list(self.time_buckets[user_id].keys()):
102
+ if minute < cutoff:
103
+ del self.time_buckets[user_id][minute]
104
+
105
+ def register_session(self, user_id: str, ip: str):
106
+ """Register a new session for an IP address"""
107
+ self.ip_sessions[ip].add(user_id)
108
+
109
+ def unregister_session(self, user_id: str, ip: str):
110
+ """Unregister a session when it disconnects"""
111
+ if user_id in self.ip_sessions[ip]:
112
+ self.ip_sessions[ip].remove(user_id)
113
+ if not self.ip_sessions[ip]:
114
+ del self.ip_sessions[ip]
115
+
116
+ def get_session_count_for_ip(self, ip: str) -> int:
117
+ """Get the number of active sessions for an IP address"""
118
+ return len(self.ip_sessions.get(ip, set()))
119
+
120
+ async def is_rate_limited(self, user_id: str, request_type: str, role: str) -> bool:
121
+ """Check if a user is currently rate limited for a request type"""
122
+ async with self.lock:
123
+ current_minute = int(time.time() / 60)
124
+ prev_minute = current_minute - 1
125
+
126
+ # Count requests in current and previous minute
127
+ current_count = self.time_buckets[user_id][current_minute][request_type]
128
+ prev_count = self.time_buckets[user_id][prev_minute][request_type]
129
+
130
+ # Calculate requests per minute rate (weighted average)
131
+ # Weight current minute more as it's more recent
132
+ rate = (current_count * 0.7) + (prev_count * 0.3)
133
+
134
+ # Get rate limit based on user role
135
+ limit = self.rate_limits.get(role, self.rate_limits['anon']).get(
136
+ request_type, self.rate_limits['anon']['other'])
137
+
138
+ # Check if rate exceeds limit
139
+ return rate >= limit
140
+
141
+ def get_metrics(self) -> Dict:
142
+ """Get a snapshot of current metrics"""
143
+ active_users = {
144
+ 'total': len(self.user_metrics),
145
+ 'anon': 0,
146
+ 'normal': 0,
147
+ 'pro': 0,
148
+ 'admin': 0,
149
+ }
150
+
151
+ # Count active users in the last 5 minutes
152
+ active_cutoff = time.time() - (5 * 60)
153
+ for user_data in self.user_metrics.values():
154
+ if user_data['last_active'] >= active_cutoff:
155
+ active_users[user_data['role']] += 1
156
+
157
+ return {
158
+ 'uptime_seconds': int(time.time() - self.start_time),
159
+ 'total_requests': dict(self.total_requests),
160
+ 'active_users': active_users,
161
+ 'active_ips': len(self.ip_sessions),
162
+ 'timestamp': datetime.datetime.now().isoformat()
163
+ }
164
+
165
+ def get_detailed_metrics(self) -> Dict:
166
+ """Get detailed metrics including per-user data"""
167
+ metrics = self.get_metrics()
168
+
169
+ # Add anonymized user metrics
170
+ user_list = []
171
+ for user_id, data in self.user_metrics.items():
172
+ # Skip users inactive for more than 1 hour
173
+ if time.time() - data['last_active'] > 3600:
174
+ continue
175
+
176
+ user_list.append({
177
+ 'id': user_id[:8] + '...', # Anonymize ID
178
+ 'role': data['role'],
179
+ 'requests': data['requests'],
180
+ 'active_ago': int(time.time() - data['last_active']),
181
+ 'session_duration': int(time.time() - data['first_seen'])
182
+ })
183
+
184
+ metrics['users'] = user_list
185
+ return metrics
api_session.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ from typing import Dict, Set
4
+ from aiohttp import web, WSMsgType
5
+ import json
6
+ import time
7
+ import datetime
8
+ from api_core import VideoGenerationAPI
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class UserSession:
13
+ """
14
+ Represents a user's session with the API.
15
+ Each WebSocket connection gets its own session with separate queues and rate limits.
16
+ """
17
+ def __init__(self, user_id: str, user_role: str, ws: web.WebSocketResponse, shared_api):
18
+ self.user_id = user_id
19
+ self.user_role = user_role
20
+ self.ws = ws
21
+ self.shared_api = shared_api # For shared resources like endpoint manager
22
+
23
+ # Create separate queues for this user session
24
+ self.chat_queue = asyncio.Queue()
25
+ self.video_queue = asyncio.Queue()
26
+ self.search_queue = asyncio.Queue()
27
+
28
+ # Track request counts and rate limits
29
+ self.request_counts = {
30
+ 'chat': 0,
31
+ 'video': 0,
32
+ 'search': 0
33
+ }
34
+
35
+ # Last request timestamps for rate limiting
36
+ self.last_request_times = {
37
+ 'chat': time.time(),
38
+ 'video': time.time(),
39
+ 'search': time.time()
40
+ }
41
+
42
+ # Session creation time
43
+ self.created_at = time.time()
44
+
45
+ self.background_tasks = []
46
+
47
+ async def start(self):
48
+ """Start all the queue processors for this session"""
49
+ # Start background tasks for handling different request types
50
+ self.background_tasks = [
51
+ asyncio.create_task(self._process_chat_queue()),
52
+ asyncio.create_task(self._process_video_queue()),
53
+ asyncio.create_task(self._process_search_queue())
54
+ ]
55
+ logger.info(f"Started session for user {self.user_id} with role {self.user_role}")
56
+
57
+ async def stop(self):
58
+ """Stop all background tasks for this session"""
59
+ for task in self.background_tasks:
60
+ task.cancel()
61
+
62
+ try:
63
+ # Wait for tasks to complete cancellation
64
+ await asyncio.gather(*self.background_tasks, return_exceptions=True)
65
+ except asyncio.CancelledError:
66
+ pass
67
+
68
+ logger.info(f"Stopped session for user {self.user_id}")
69
+
70
+ async def _process_chat_queue(self):
71
+ """High priority queue for chat operations"""
72
+ while True:
73
+ data = await self.chat_queue.get()
74
+ try:
75
+ if data['action'] == 'join_chat':
76
+ result = await self.shared_api.handle_join_chat(data, self.ws)
77
+ elif data['action'] == 'chat_message':
78
+ result = await self.shared_api.handle_chat_message(data, self.ws)
79
+ elif data['action'] == 'leave_chat':
80
+ result = await self.shared_api.handle_leave_chat(data, self.ws)
81
+ # Handle thumbnail requests as chat requests for immediate processing
82
+ elif data['action'] == 'generate_video_thumbnail':
83
+ result = await self._handle_thumbnail_request(data)
84
+ else:
85
+ raise ValueError(f"Unknown chat action: {data['action']}")
86
+
87
+ await self.ws.send_json(result)
88
+
89
+ # Update metrics
90
+ self.request_counts['chat'] += 1
91
+ self.last_request_times['chat'] = time.time()
92
+
93
+ except Exception as e:
94
+ logger.error(f"Error processing chat request for user {self.user_id}: {e}")
95
+ try:
96
+ await self.ws.send_json({
97
+ 'action': data['action'],
98
+ 'requestId': data.get('requestId'),
99
+ 'success': False,
100
+ 'error': f'Chat error: {str(e)}'
101
+ })
102
+ except Exception as send_error:
103
+ logger.error(f"Error sending error response: {send_error}")
104
+ finally:
105
+ self.chat_queue.task_done()
106
+
107
+ async def _process_video_queue(self):
108
+ """Process multiple video generation requests in parallel for this user"""
109
+ from api_config import VIDEO_ROUND_ROBIN_ENDPOINT_URLS
110
+
111
+ active_tasks = set()
112
+ # Set a per-user concurrent limit based on role
113
+ max_concurrent = len(VIDEO_ROUND_ROBIN_ENDPOINT_URLS)
114
+ if self.user_role == 'anon':
115
+ max_concurrent = min(2, max_concurrent) # Limit anonymous users
116
+ elif self.user_role == 'normal':
117
+ max_concurrent = min(4, max_concurrent) # Standard users
118
+ # Pro and admin can use all endpoints
119
+
120
+ async def process_single_request(data):
121
+ try:
122
+ title = data.get('title', '')
123
+ description = data.get('description', '')
124
+ video_prompt_prefix = data.get('video_prompt_prefix', '')
125
+ options = data.get('options', {})
126
+
127
+ # Pass the user role to generate_video
128
+ video_data = await self.shared_api.generate_video(
129
+ title, description, video_prompt_prefix, options, self.user_role
130
+ )
131
+
132
+ result = {
133
+ 'action': 'generate_video',
134
+ 'requestId': data.get('requestId'),
135
+ 'success': True,
136
+ 'video': video_data,
137
+ }
138
+
139
+ await self.ws.send_json(result)
140
+
141
+ # Update metrics
142
+ self.request_counts['video'] += 1
143
+ self.last_request_times['video'] = time.time()
144
+
145
+ except Exception as e:
146
+ logger.error(f"Error processing video request for user {self.user_id}: {e}")
147
+ try:
148
+ await self.ws.send_json({
149
+ 'action': 'generate_video',
150
+ 'requestId': data.get('requestId'),
151
+ 'success': False,
152
+ 'error': f'Video generation error: {str(e)}'
153
+ })
154
+ except Exception as send_error:
155
+ logger.error(f"Error sending error response: {send_error}")
156
+ finally:
157
+ active_tasks.discard(asyncio.current_task())
158
+
159
+ while True:
160
+ # Clean up completed tasks
161
+ active_tasks = {task for task in active_tasks if not task.done()}
162
+
163
+ # Start new tasks if we have capacity
164
+ while len(active_tasks) < max_concurrent:
165
+ try:
166
+ # Use try_get to avoid blocking if queue is empty
167
+ data = await asyncio.wait_for(self.video_queue.get(), timeout=0.1)
168
+
169
+ # Create and start new task
170
+ task = asyncio.create_task(process_single_request(data))
171
+ active_tasks.add(task)
172
+
173
+ except asyncio.TimeoutError:
174
+ # No items in queue, break inner loop
175
+ break
176
+ except Exception as e:
177
+ logger.error(f"Error creating video generation task for user {self.user_id}: {e}")
178
+ break
179
+
180
+ # Wait a short time before checking queue again
181
+ await asyncio.sleep(0.1)
182
+
183
+ # Handle any completed tasks' errors
184
+ for task in list(active_tasks):
185
+ if task.done():
186
+ try:
187
+ await task
188
+ except Exception as e:
189
+ logger.error(f"Task failed with error for user {self.user_id}: {e}")
190
+ active_tasks.discard(task)
191
+
192
+ async def _process_search_queue(self):
193
+ """Medium priority queue for search operations"""
194
+ while True:
195
+ try:
196
+ data = await self.search_queue.get()
197
+ request_id = data.get('requestId')
198
+ query = data.get('query', '').strip()
199
+ search_count = data.get('searchCount', 0)
200
+ attempt_count = data.get('attemptCount', 0)
201
+
202
+ logger.info(f"Processing search request for user {self.user_id}: query='{query}', search_count={search_count}, attempt={attempt_count}")
203
+
204
+ if not query:
205
+ logger.warning(f"Empty query received in request from user {self.user_id}: {data}")
206
+ result = {
207
+ 'action': 'search',
208
+ 'requestId': request_id,
209
+ 'success': False,
210
+ 'error': 'No search query provided'
211
+ }
212
+ else:
213
+ try:
214
+ search_result = await self.shared_api.search_video(
215
+ query,
216
+ search_count=search_count,
217
+ attempt_count=attempt_count
218
+ )
219
+
220
+ if search_result:
221
+ logger.info(f"Search successful for user {self.user_id}, query '{query}' (#{search_count})")
222
+ result = {
223
+ 'action': 'search',
224
+ 'requestId': request_id,
225
+ 'success': True,
226
+ 'result': search_result
227
+ }
228
+ else:
229
+ logger.warning(f"No results found for user {self.user_id}, query '{query}' (#{search_count})")
230
+ result = {
231
+ 'action': 'search',
232
+ 'requestId': request_id,
233
+ 'success': False,
234
+ 'error': 'No results found'
235
+ }
236
+ except Exception as e:
237
+ logger.error(f"Search error for user {self.user_id}, query '{query}' (#{search_count}, attempt {attempt_count}): {str(e)}")
238
+ result = {
239
+ 'action': 'search',
240
+ 'requestId': request_id,
241
+ 'success': False,
242
+ 'error': f'Search error: {str(e)}'
243
+ }
244
+
245
+ await self.ws.send_json(result)
246
+
247
+ # Update metrics
248
+ self.request_counts['search'] += 1
249
+ self.last_request_times['search'] = time.time()
250
+
251
+ except Exception as e:
252
+ logger.error(f"Error in search queue processor for user {self.user_id}: {str(e)}")
253
+ try:
254
+ error_response = {
255
+ 'action': 'search',
256
+ 'requestId': data.get('requestId') if 'data' in locals() else None,
257
+ 'success': False,
258
+ 'error': f'Internal server error: {str(e)}'
259
+ }
260
+ await self.ws.send_json(error_response)
261
+ except Exception as send_error:
262
+ logger.error(f"Error sending error response: {send_error}")
263
+ finally:
264
+ if 'search_queue' in self.__dict__:
265
+ self.search_queue.task_done()
266
+
267
+ async def process_generic_request(self, data: dict) -> None:
268
+ """Handle general requests that don't fit into specialized queues"""
269
+ try:
270
+ request_id = data.get('requestId')
271
+ action = data.get('action')
272
+
273
+ def error_response(message: str):
274
+ return {
275
+ 'action': action,
276
+ 'requestId': request_id,
277
+ 'success': False,
278
+ 'error': message
279
+ }
280
+
281
+ if action == 'heartbeat':
282
+ # Include user role info in heartbeat response
283
+ await self.ws.send_json({
284
+ 'action': 'heartbeat',
285
+ 'requestId': request_id,
286
+ 'success': True,
287
+ 'user_role': self.user_role
288
+ })
289
+
290
+ elif action == 'get_user_role':
291
+ # Return the user role information
292
+ await self.ws.send_json({
293
+ 'action': 'get_user_role',
294
+ 'requestId': request_id,
295
+ 'success': True,
296
+ 'user_role': self.user_role
297
+ })
298
+
299
+ elif action == 'generate_caption':
300
+ title = data.get('params', {}).get('title')
301
+ description = data.get('params', {}).get('description')
302
+
303
+ if not title or not description:
304
+ await self.ws.send_json(error_response('Missing title or description'))
305
+ return
306
+
307
+ caption = await self.shared_api.generate_caption(title, description)
308
+ await self.ws.send_json({
309
+ 'action': action,
310
+ 'requestId': request_id,
311
+ 'success': True,
312
+ 'caption': caption
313
+ })
314
+
315
+ elif action == 'generate_thumbnail' or action == 'generate_video_thumbnail':
316
+ title = data.get('title', '') or data.get('params', {}).get('title', '')
317
+ description = data.get('description', '') or data.get('params', {}).get('description', '')
318
+ video_prompt_prefix = data.get('video_prompt_prefix', '') or data.get('params', {}).get('video_prompt_prefix', '')
319
+ options = data.get('options', {}) or data.get('params', {}).get('options', {})
320
+
321
+ if not title:
322
+ await self.ws.send_json(error_response('Missing title for thumbnail generation'))
323
+ return
324
+
325
+ # Ensure the options include the thumbnail flag
326
+ options['thumbnail'] = True
327
+
328
+ # Prioritize thumbnail generation with higher priority
329
+ options['priority'] = 'high'
330
+
331
+ # Add small size settings if not already specified
332
+ if 'width' not in options:
333
+ options['width'] = 512 # Default thumbnail width
334
+ if 'height' not in options:
335
+ options['height'] = 288 # Default 16:9 aspect ratio
336
+ if 'num_frames' not in options:
337
+ options['num_frames'] = 25 # 1 second @ 25fps
338
+
339
+ # Let the API know this is a thumbnail for a specific video
340
+ options['video_id'] = data.get('video_id', f"thumbnail-{request_id}")
341
+
342
+ logger.info(f"Generating thumbnail for video {options['video_id']} for user {self.user_id}")
343
+
344
+ try:
345
+ # Generate the thumbnail
346
+ thumbnail_data = await self.shared_api.generate_video_thumbnail(
347
+ title, description, video_prompt_prefix, options, self.user_role
348
+ )
349
+
350
+ await self.ws.send_json({
351
+ 'action': action,
352
+ 'requestId': request_id,
353
+ 'success': True,
354
+ 'thumbnail': thumbnail_data,
355
+ })
356
+ except Exception as e:
357
+ logger.error(f"Error generating thumbnail: {str(e)}")
358
+ await self.ws.send_json(error_response(f"Thumbnail generation failed: {str(e)}"))
359
+
360
+ elif action == 'old_generate_thumbnail' or action == 'generate_thumbnail':
361
+ # Redirect to video thumbnail generation instead of static image
362
+ title = data.get('params', {}).get('title')
363
+ description = data.get('params', {}).get('description')
364
+
365
+ if not title or not description:
366
+ await self.ws.send_json(error_response('Missing title or description'))
367
+ return
368
+
369
+ # Use the video thumbnail function instead
370
+ options = {
371
+ 'width': 512,
372
+ 'height': 288,
373
+ 'thumbnail': True,
374
+ 'video_id': f"thumbnail-{request_id}"
375
+ }
376
+
377
+ try:
378
+ thumbnail = await self.shared_api.generate_video_thumbnail(
379
+ title, description, "", options, self.user_role
380
+ )
381
+
382
+ # Check thumbnail is not empty
383
+ if thumbnail is None or thumbnail == "":
384
+ await self.ws.send_json({
385
+ 'action': action,
386
+ 'requestId': request_id,
387
+ 'success': True,
388
+ 'thumbnailUrl': ""
389
+ })
390
+ else:
391
+ await self.ws.send_json({
392
+ 'action': action,
393
+ 'requestId': request_id,
394
+ 'success': True,
395
+ 'thumbnailUrl': thumbnail
396
+ })
397
+ except Exception as e:
398
+ logger.error(f"Error generating video thumbnail: {str(e)}")
399
+ await self.ws.send_json({
400
+ 'action': action,
401
+ 'requestId': request_id,
402
+ 'success': True, # Still return success to avoid client errors
403
+ 'thumbnailUrl': "" # But with empty thumbnail
404
+ })
405
+
406
+ else:
407
+ await self.ws.send_json(error_response(f'Unknown action: {action}'))
408
+
409
+ except Exception as e:
410
+ logger.error(f"Error processing generic request for user {self.user_id}: {str(e)}")
411
+ try:
412
+ await self.ws.send_json({
413
+ 'action': data.get('action'),
414
+ 'requestId': data.get('requestId'),
415
+ 'success': False,
416
+ 'error': f'Internal server error: {str(e)}'
417
+ })
418
+ except Exception as send_error:
419
+ logger.error(f"Error sending error response: {send_error}")
420
+
421
+ class SessionManager:
422
+ """
423
+ Manages all active user sessions and shared resources.
424
+ """
425
+ def __init__(self):
426
+ self.sessions = {}
427
+ self.shared_api = VideoGenerationAPI() # Single instance for shared resources
428
+ self.session_lock = asyncio.Lock()
429
+
430
+ async def create_session(self, user_id: str, user_role: str, ws: web.WebSocketResponse) -> UserSession:
431
+ """Create a new user session"""
432
+ async with self.session_lock:
433
+ # Create a new session for this user
434
+ session = UserSession(user_id, user_role, ws, self.shared_api)
435
+ await session.start()
436
+ self.sessions[user_id] = session
437
+ return session
438
+
439
+ async def delete_session(self, user_id: str) -> None:
440
+ """Delete a user session and clean up resources"""
441
+ async with self.session_lock:
442
+ if user_id in self.sessions:
443
+ session = self.sessions[user_id]
444
+ await session.stop()
445
+ del self.sessions[user_id]
446
+ logger.info(f"Deleted session for user {user_id}")
447
+
448
+ def get_session(self, user_id: str) -> UserSession:
449
+ """Get a user session if it exists"""
450
+ return self.sessions.get(user_id)
451
+
452
+ async def close_all_sessions(self) -> None:
453
+ """Close all active sessions (used during shutdown)"""
454
+ async with self.session_lock:
455
+ for user_id, session in list(self.sessions.items()):
456
+ await session.stop()
457
+ self.sessions.clear()
458
+ logger.info("Closed all active sessions")
459
+
460
+ @property
461
+ def session_count(self) -> int:
462
+ """Get the number of active sessions"""
463
+ return len(self.sessions)
464
+
465
+ def get_session_stats(self) -> Dict:
466
+ """Get statistics about active sessions"""
467
+ stats = {
468
+ 'total_sessions': len(self.sessions),
469
+ 'by_role': {
470
+ 'anon': 0,
471
+ 'normal': 0,
472
+ 'pro': 0,
473
+ 'admin': 0
474
+ },
475
+ 'requests': {
476
+ 'chat': 0,
477
+ 'video': 0,
478
+ 'search': 0
479
+ }
480
+ }
481
+
482
+ for session in self.sessions.values():
483
+ stats['by_role'][session.user_role] += 1
484
+ stats['requests']['chat'] += session.request_counts['chat']
485
+ stats['requests']['video'] += session.request_counts['video']
486
+ stats['requests']['search'] += session.request_counts['search']
487
+
488
+ return stats
build/web/assets/assets/config/custom.yaml CHANGED
@@ -1,15 +1,15 @@
1
- ui:
2
  product_name: Custom
3
  showChatInVideoView: false
4
 
5
- render_queue:
6
  # how many clips should be stored in advance
7
  buffer_size: 3
8
 
9
  # how many requests for clips can be run in parallel
10
  max_concurrent_generations: 3
11
 
12
- # start playback as soon as we have 1 video over 3 (25%)
13
  minimum_buffer_percent_to_start_playback: 5
14
 
15
  video:
 
1
+ interface:
2
  product_name: Custom
3
  showChatInVideoView: false
4
 
5
+ playback:
6
  # how many clips should be stored in advance
7
  buffer_size: 3
8
 
9
  # how many requests for clips can be run in parallel
10
  max_concurrent_generations: 3
11
 
12
+ # start playback as soon as we have 1 video over 3
13
  minimum_buffer_percent_to_start_playback: 5
14
 
15
  video:
build/web/assets/fonts/MaterialIcons-Regular.otf CHANGED
Binary files a/build/web/assets/fonts/MaterialIcons-Regular.otf and b/build/web/assets/fonts/MaterialIcons-Regular.otf differ
 
build/web/flutter_bootstrap.js CHANGED
@@ -39,6 +39,6 @@ _flutter.buildConfig = {"engineRevision":"382be0028d370607f76215a9be322e5514b263
39
 
40
  _flutter.loader.load({
41
  serviceWorkerSettings: {
42
- serviceWorkerVersion: "2953326891"
43
  }
44
  });
 
39
 
40
  _flutter.loader.load({
41
  serviceWorkerSettings: {
42
+ serviceWorkerVersion: "243753163"
43
  }
44
  });
build/web/flutter_service_worker.js CHANGED
@@ -3,11 +3,11 @@ const MANIFEST = 'flutter-app-manifest';
3
  const TEMP = 'flutter-temp-cache';
4
  const CACHE_NAME = 'flutter-app-cache';
5
 
6
- const RESOURCES = {"flutter_bootstrap.js": "473f7db41bcf45ab1477c0d952890dcd",
7
  "version.json": "b5eaae4fc120710a3c35125322173615",
8
  "index.html": "f34c56fffc6b38f62412a5db2315dec8",
9
  "/": "f34c56fffc6b38f62412a5db2315dec8",
10
- "main.dart.js": "e505129ff53a6863def8471d833dbb92",
11
  "flutter.js": "83d881c1dbb6d6bcd6b42e274605b69c",
12
  "favicon.png": "5dcef449791fa27946b3d35ad8803796",
13
  "icons/Icon-192.png": "ac9a721a12bbc803b44f645561ecb1e1",
@@ -22,9 +22,9 @@ const RESOURCES = {"flutter_bootstrap.js": "473f7db41bcf45ab1477c0d952890dcd",
22
  "assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "33b7d9392238c04c131b6ce224e13711",
23
  "assets/shaders/ink_sparkle.frag": "ecc85a2e95f5e9f53123dcaf8cb9b6ce",
24
  "assets/AssetManifest.bin": "6c597105edcadb9c676bdc998c88545a",
25
- "assets/fonts/MaterialIcons-Regular.otf": "f1343247a767efee2447b3f63c9498b4",
26
  "assets/assets/config/README.md": "07a87720dd00dd1ca98c9d6884440e31",
27
- "assets/assets/config/custom.yaml": "da18366f4b2f24a3bc911d86bd773a79",
28
  "assets/assets/config/aitube.yaml": "29ed15827ee8364e390a3b446535067a",
29
  "assets/assets/config/default.yaml": "ba11c9ae686f1317a29bce114f0f9fc9",
30
  "canvaskit/skwasm.js": "ea559890a088fe28b4ddf70e17e60052",
 
3
  const TEMP = 'flutter-temp-cache';
4
  const CACHE_NAME = 'flutter-app-cache';
5
 
6
+ const RESOURCES = {"flutter_bootstrap.js": "2e5d8c2674e92a260d84eeb3342bc39a",
7
  "version.json": "b5eaae4fc120710a3c35125322173615",
8
  "index.html": "f34c56fffc6b38f62412a5db2315dec8",
9
  "/": "f34c56fffc6b38f62412a5db2315dec8",
10
+ "main.dart.js": "f47ca1e506a05d335168c4b1777590e3",
11
  "flutter.js": "83d881c1dbb6d6bcd6b42e274605b69c",
12
  "favicon.png": "5dcef449791fa27946b3d35ad8803796",
13
  "icons/Icon-192.png": "ac9a721a12bbc803b44f645561ecb1e1",
 
22
  "assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "33b7d9392238c04c131b6ce224e13711",
23
  "assets/shaders/ink_sparkle.frag": "ecc85a2e95f5e9f53123dcaf8cb9b6ce",
24
  "assets/AssetManifest.bin": "6c597105edcadb9c676bdc998c88545a",
25
+ "assets/fonts/MaterialIcons-Regular.otf": "a9126745a3792756bbb88c84ed40e354",
26
  "assets/assets/config/README.md": "07a87720dd00dd1ca98c9d6884440e31",
27
+ "assets/assets/config/custom.yaml": "e5c0b238b6f217f1215fbc813f093656",
28
  "assets/assets/config/aitube.yaml": "29ed15827ee8364e390a3b446535067a",
29
  "assets/assets/config/default.yaml": "ba11c9ae686f1317a29bce114f0f9fc9",
30
  "canvaskit/skwasm.js": "ea559890a088fe28b4ddf70e17e60052",
build/web/main.dart.js CHANGED
The diff for this file is too large to render. See raw diff
 
lib/main.dart CHANGED
@@ -10,7 +10,6 @@ import 'package:aitube2/widgets/web_utils.dart';
10
  import 'package:flutter/foundation.dart';
11
  import 'package:flutter/material.dart';
12
  import 'screens/home_screen.dart';
13
- import 'services/cache_service.dart';
14
 
15
  void main() async {
16
  WidgetsFlutterBinding.ensureInitialized();
@@ -24,7 +23,6 @@ void main() async {
24
  try {
25
  // Initialize services in sequence to ensure proper dependencies
26
  await SettingsService().initialize();
27
- await CacheService().initialize();
28
 
29
  // Initialize the WebSocket service
30
  await wsService.initialize();
 
10
  import 'package:flutter/foundation.dart';
11
  import 'package:flutter/material.dart';
12
  import 'screens/home_screen.dart';
 
13
 
14
  void main() async {
15
  WidgetsFlutterBinding.ensureInitialized();
 
23
  try {
24
  // Initialize services in sequence to ensure proper dependencies
25
  await SettingsService().initialize();
 
26
 
27
  // Initialize the WebSocket service
28
  await wsService.initialize();
lib/screens/home_screen.dart CHANGED
@@ -9,7 +9,6 @@ import 'package:aitube2/screens/video_screen.dart';
9
  import 'package:aitube2/screens/settings_screen.dart';
10
  import 'package:aitube2/models/video_result.dart';
11
  import 'package:aitube2/services/websocket_api_service.dart';
12
- import 'package:aitube2/services/cache_service.dart';
13
  import 'package:aitube2/services/settings_service.dart';
14
  import 'package:aitube2/widgets/video_card.dart';
15
  import 'package:aitube2/widgets/search_box.dart';
@@ -30,7 +29,6 @@ class HomeScreen extends StatefulWidget {
30
  class _HomeScreenState extends State<HomeScreen> {
31
  final _searchController = TextEditingController();
32
  final _websocketService = WebSocketApiService();
33
- final _cacheService = CacheService();
34
  List<VideoResult> _results = [];
35
  bool _isSearching = false;
36
  String? _currentSearchQuery;
@@ -62,6 +60,13 @@ class _HomeScreenState extends State<HomeScreen> {
62
  _initializeWebSocket();
63
  _setupSearchListener();
64
 
 
 
 
 
 
 
 
65
  // Check if we have an initial search query from URL parameters
66
  if (widget.initialSearchQuery != null && widget.initialSearchQuery!.isNotEmpty) {
67
  _searchController.text = widget.initialSearchQuery!;
@@ -71,22 +76,6 @@ class _HomeScreenState extends State<HomeScreen> {
71
  _search(widget.initialSearchQuery!);
72
  }
73
  });
74
- } else {
75
- _loadLastResults();
76
- }
77
- }
78
-
79
- Future<void> _loadLastResults() async {
80
- try {
81
- // Load most recent search results from cache
82
- final cachedResults = await _cacheService.getCachedSearchResults('');
83
- if (cachedResults.isNotEmpty && mounted) {
84
- setState(() {
85
- _results = cachedResults.take(maxResults).toList();
86
- });
87
- }
88
- } catch (e) {
89
- debugPrint('Error loading cached results: $e');
90
  }
91
  }
92
 
@@ -96,14 +85,6 @@ class _HomeScreenState extends State<HomeScreen> {
96
  setState(() {
97
  if (_results.length < maxResults) {
98
  _results.add(result);
99
- // Cache each result as it comes in
100
- if (_currentSearchQuery != null) {
101
- _cacheService.cacheSearchResult(
102
- _currentSearchQuery!,
103
- result,
104
- _results.length,
105
- );
106
- }
107
  // Stop search if we've reached max results
108
  if (_results.length >= maxResults) {
109
  _stopSearch();
@@ -301,44 +282,40 @@ class _HomeScreenState extends State<HomeScreen> {
301
  }
302
 
303
  Widget _buildConnectionStatus() {
 
304
  return StreamBuilder<ConnectionStatus>(
305
  stream: _websocketService.statusStream,
 
306
  builder: (context, connectionSnapshot) {
 
 
 
307
  return StreamBuilder<String>(
308
  stream: _websocketService.userRoleStream,
 
309
  builder: (context, roleSnapshot) {
310
- final status = connectionSnapshot.data ?? ConnectionStatus.disconnected;
311
  final userRole = roleSnapshot.data ?? 'anon';
312
 
313
- final backgroundColor = status == ConnectionStatus.connected
314
  ? Colors.green.withOpacity(0.1)
315
  : status == ConnectionStatus.error
316
  ? Colors.red.withOpacity(0.1)
317
  : Colors.orange.withOpacity(0.1);
318
 
319
- final textAndIconColor = status == ConnectionStatus.connected
320
  ? Colors.green
321
  : status == ConnectionStatus.error
322
  ? Colors.red
323
  : Colors.orange;
324
 
325
- final icon = status == ConnectionStatus.connected
326
  ? Icons.cloud_done
327
  : status == ConnectionStatus.error
328
  ? Icons.cloud_off
329
  : Icons.cloud_sync;
330
 
331
- // Modify the status message to include the user role
332
- String statusMessage;
333
- if (status == ConnectionStatus.connected) {
334
- statusMessage = userRole == 'anon'
335
- ? 'Connected as anon'
336
- : 'Connected as $userRole';
337
- } else if (status == ConnectionStatus.error) {
338
- statusMessage = 'Disconnected';
339
- } else {
340
- statusMessage = _websocketService.statusMessage;
341
- }
342
 
343
  return Container(
344
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -402,24 +379,8 @@ class _HomeScreenState extends State<HomeScreen> {
402
 
403
  _currentSearchQuery = trimmedQuery;
404
 
405
- // Check cache first
406
- final cachedResults = await _cacheService.getCachedSearchResults(trimmedQuery);
407
- if (cachedResults.isNotEmpty) {
408
- if (mounted) {
409
- setState(() {
410
- _results = cachedResults.take(maxResults).toList();
411
- });
412
- }
413
- // If we have max results cached, stop searching
414
- if (cachedResults.length >= maxResults) {
415
- setState(() => _isSearching = false);
416
- return;
417
- }
418
- }
419
-
420
  // Start continuous search
421
  _websocketService.startContinuousSearch(trimmedQuery);
422
-
423
  } catch (e) {
424
  if (mounted) {
425
  ScaffoldMessenger.of(context).showSnackBar(
@@ -508,7 +469,8 @@ class _HomeScreenState extends State<HomeScreen> {
508
  // Update URL parameter on web platform
509
  if (kIsWeb) {
510
  // Update view parameter and remove search parameter
511
- updateUrlParameter('view', _results[index].title);
 
512
  removeUrlParameter('search');
513
  }
514
 
 
9
  import 'package:aitube2/screens/settings_screen.dart';
10
  import 'package:aitube2/models/video_result.dart';
11
  import 'package:aitube2/services/websocket_api_service.dart';
 
12
  import 'package:aitube2/services/settings_service.dart';
13
  import 'package:aitube2/widgets/video_card.dart';
14
  import 'package:aitube2/widgets/search_box.dart';
 
29
  class _HomeScreenState extends State<HomeScreen> {
30
  final _searchController = TextEditingController();
31
  final _websocketService = WebSocketApiService();
 
32
  List<VideoResult> _results = [];
33
  bool _isSearching = false;
34
  String? _currentSearchQuery;
 
60
  _initializeWebSocket();
61
  _setupSearchListener();
62
 
63
+ // Force a UI refresh to ensure connection status is displayed correctly
64
+ Future.microtask(() {
65
+ if (mounted) {
66
+ setState(() {}); // Trigger a rebuild to refresh the connection status
67
+ }
68
+ });
69
+
70
  // Check if we have an initial search query from URL parameters
71
  if (widget.initialSearchQuery != null && widget.initialSearchQuery!.isNotEmpty) {
72
  _searchController.text = widget.initialSearchQuery!;
 
76
  _search(widget.initialSearchQuery!);
77
  }
78
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
  }
81
 
 
85
  setState(() {
86
  if (_results.length < maxResults) {
87
  _results.add(result);
 
 
 
 
 
 
 
 
88
  // Stop search if we've reached max results
89
  if (_results.length >= maxResults) {
90
  _stopSearch();
 
282
  }
283
 
284
  Widget _buildConnectionStatus() {
285
+
286
  return StreamBuilder<ConnectionStatus>(
287
  stream: _websocketService.statusStream,
288
+ initialData: _websocketService.status, // Add initial data to avoid null status
289
  builder: (context, connectionSnapshot) {
290
+ // Immediately extract and use the connection status
291
+ final status = connectionSnapshot.data ?? ConnectionStatus.disconnected;
292
+
293
  return StreamBuilder<String>(
294
  stream: _websocketService.userRoleStream,
295
+ initialData: _websocketService.userRole, // Add initial data
296
  builder: (context, roleSnapshot) {
 
297
  final userRole = roleSnapshot.data ?? 'anon';
298
 
299
+ final backgroundColor = status == ConnectionStatus.connected || status == ConnectionStatus.connecting
300
  ? Colors.green.withOpacity(0.1)
301
  : status == ConnectionStatus.error
302
  ? Colors.red.withOpacity(0.1)
303
  : Colors.orange.withOpacity(0.1);
304
 
305
+ final textAndIconColor = status == ConnectionStatus.connected || status == ConnectionStatus.connecting
306
  ? Colors.green
307
  : status == ConnectionStatus.error
308
  ? Colors.red
309
  : Colors.orange;
310
 
311
+ final icon = status == ConnectionStatus.connected || status == ConnectionStatus.connecting
312
  ? Icons.cloud_done
313
  : status == ConnectionStatus.error
314
  ? Icons.cloud_off
315
  : Icons.cloud_sync;
316
 
317
+ // Get the status message (with user role info for connected state)
318
+ String statusMessage = _websocketService.statusMessage;
 
 
 
 
 
 
 
 
 
319
 
320
  return Container(
321
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
 
379
 
380
  _currentSearchQuery = trimmedQuery;
381
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
  // Start continuous search
383
  _websocketService.startContinuousSearch(trimmedQuery);
 
384
  } catch (e) {
385
  if (mounted) {
386
  ScaffoldMessenger.of(context).showSnackBar(
 
469
  // Update URL parameter on web platform
470
  if (kIsWeb) {
471
  // Update view parameter and remove search parameter
472
+ // Use description instead of title for the URL parameter
473
+ updateUrlParameter('view', _results[index].description);
474
  removeUrlParameter('search');
475
  }
476
 
lib/screens/settings_screen.dart CHANGED
@@ -1,5 +1,4 @@
1
  import 'package:flutter/material.dart';
2
- import '../services/cache_service.dart';
3
  import '../services/settings_service.dart';
4
  import '../services/websocket_api_service.dart';
5
  import '../theme/colors.dart';
@@ -39,196 +38,136 @@ class _SettingsScreenState extends State<SettingsScreen> {
39
  appBar: AppBar(
40
  title: const Text('Settings'),
41
  ),
42
- body: StreamBuilder<CacheStats>(
43
- stream: CacheService().statsStream,
44
- builder: (context, snapshot) {
45
- final stats = snapshot.data;
46
-
47
- return ListView(
48
- padding: const EdgeInsets.all(16),
49
- children: [
50
- // API Configuration Card
51
- Card(
52
- child: Padding(
53
- padding: const EdgeInsets.all(16),
54
- child: Column(
55
- crossAxisAlignment: CrossAxisAlignment.start,
56
- children: [
57
- const Text(
58
- 'API Configuration',
59
- style: TextStyle(
60
- color: AiTubeColors.onBackground,
61
- fontSize: 20,
62
- fontWeight: FontWeight.bold,
63
- ),
64
- ),
65
- const SizedBox(height: 16),
66
- TextField(
67
- controller: _hfApiKeyController,
68
- decoration: const InputDecoration(
69
- labelText: 'Connect using your Hugging Face API Key (optional)',
70
- helperText: 'Hugging Face members enjoy longer-lasting streaming sessions and higher quality.',
71
- helperMaxLines: 2,
72
- ),
73
- obscureText: true,
74
- onChanged: (value) async {
75
- await _settingsService.setHuggingfaceApiKey(value);
76
- // Reinitialize the websocket connection when the API key changes
77
- final websocket = WebSocketApiService();
78
- if (websocket.isConnected) {
79
- await websocket.dispose();
80
- await websocket.connect();
81
- }
82
- },
83
- ),
84
- ],
85
  ),
86
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ),
88
- const SizedBox(height: 16),
89
- // Video Prompt Prefix Card
90
- Card(
91
- child: Padding(
92
- padding: const EdgeInsets.all(16),
93
- child: Column(
94
- crossAxisAlignment: CrossAxisAlignment.start,
95
- children: [
96
- const Text(
97
- 'Video Generation',
98
- style: TextStyle(
99
- color: AiTubeColors.onBackground,
100
- fontSize: 20,
101
- fontWeight: FontWeight.bold,
102
- ),
103
- ),
104
- const SizedBox(height: 16),
105
- TextField(
106
- controller: _promptController,
107
- decoration: const InputDecoration(
108
- labelText: 'Video Prompt Prefix',
109
- helperText: 'Text to prepend to all video generation prompts',
110
- helperMaxLines: 2,
111
- ),
112
- onChanged: (value) {
113
- _settingsService.setVideoPromptPrefix(value);
114
- },
115
- ),
116
- const SizedBox(height: 16),
117
- TextField(
118
- controller: _negativePromptController,
119
- decoration: const InputDecoration(
120
- labelText: 'Negative Prompt',
121
- helperText: 'Content to avoid in the output generation',
122
- helperMaxLines: 2,
123
- ),
124
- onChanged: (value) {
125
- _settingsService.setNegativeVideoPrompt(value);
126
- },
127
- ),
128
- ],
129
  ),
130
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  ),
132
- const SizedBox(height: 16),
133
- // Cache Card (existing code)
134
- Card(
135
- child: Padding(
136
- padding: const EdgeInsets.all(16),
137
- child: Column(
138
- crossAxisAlignment: CrossAxisAlignment.start,
139
- children: [
140
- const Text(
141
- 'Cache',
142
- style: TextStyle(
143
- color: AiTubeColors.onBackground,
144
- fontSize: 20,
145
- fontWeight: FontWeight.bold,
146
- ),
147
- ),
148
- const SizedBox(height: 16),
149
- _buildStatRow(
150
- 'Items in cache',
151
- '${stats?.totalItems ?? 0}',
152
- ),
153
- const SizedBox(height: 8),
154
- _buildStatRow(
155
- 'Total size',
156
- '${(stats?.totalSizeMB ?? 0).toStringAsFixed(2)} MB',
157
- ),
158
- const SizedBox(height: 16),
159
- FilledButton.icon(
160
- onPressed: () async {
161
- final confirmed = await showDialog<bool>(
162
- context: context,
163
- builder: (context) => AlertDialog(
164
- title: const Text(
165
- 'Clear Cache',
166
- style: TextStyle(
167
- color: AiTubeColors.onBackground,
168
- fontSize: 20,
169
- fontWeight: FontWeight.bold,
170
- ),
171
- ),
172
- content: const Text(
173
- 'Are you sure you want to clear all cached data? '
174
- 'This will remove all saved search results and videos.',
175
- ),
176
- actions: [
177
- TextButton(
178
- onPressed: () => Navigator.pop(context, false),
179
- child: const Text('Cancel'),
180
- ),
181
- FilledButton(
182
- onPressed: () => Navigator.pop(context, true),
183
- child: const Text('Clear'),
184
- ),
185
- ],
186
- ),
187
- );
188
-
189
- if (confirmed == true) {
190
- await CacheService().clearCache();
191
- if (context.mounted) {
192
- ScaffoldMessenger.of(context).showSnackBar(
193
- const SnackBar(
194
- content: Text('Cache cleared'),
195
- ),
196
- );
197
- }
198
- }
199
- },
200
- icon: const Icon(Icons.delete_outline),
201
- label: const Text('Clear Cache'),
202
  ),
203
  ],
204
  ),
205
- ),
 
 
 
 
 
 
 
 
206
  ),
207
- ],
208
- );
209
- },
210
  ),
211
  );
212
  }
213
 
214
- Widget _buildStatRow(String label, String value) {
215
- return Row(
216
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
217
- children: [
218
- Text(
219
- label,
220
- style: const TextStyle(
221
- color: AiTubeColors.onSurfaceVariant,
222
- ),
223
- ),
224
- Text(
225
- value,
226
- style: const TextStyle(
227
- color: AiTubeColors.onBackground,
228
- fontWeight: FontWeight.w500,
229
- ),
230
- ),
231
- ],
232
- );
233
- }
234
  }
 
1
  import 'package:flutter/material.dart';
 
2
  import '../services/settings_service.dart';
3
  import '../services/websocket_api_service.dart';
4
  import '../theme/colors.dart';
 
38
  appBar: AppBar(
39
  title: const Text('Settings'),
40
  ),
41
+ body: ListView(
42
+ padding: const EdgeInsets.all(16),
43
+ children: [
44
+ // API Configuration Card
45
+ Card(
46
+ child: Padding(
47
+ padding: const EdgeInsets.all(16),
48
+ child: Column(
49
+ crossAxisAlignment: CrossAxisAlignment.start,
50
+ children: [
51
+ const Text(
52
+ 'API Configuration',
53
+ style: TextStyle(
54
+ color: AiTubeColors.onBackground,
55
+ fontSize: 20,
56
+ fontWeight: FontWeight.bold,
57
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  ),
59
+ const SizedBox(height: 16),
60
+ TextField(
61
+ controller: _hfApiKeyController,
62
+ decoration: const InputDecoration(
63
+ labelText: 'Connect using your Hugging Face API Key (optional)',
64
+ helperText: 'Hugging Face members enjoy a higher-resolution rendering.',
65
+ helperMaxLines: 2,
66
+ ),
67
+ obscureText: true,
68
+ onChanged: (value) async {
69
+ await _settingsService.setHuggingfaceApiKey(value);
70
+ // Reinitialize the websocket connection when the API key changes
71
+ final websocket = WebSocketApiService();
72
+ if (websocket.isConnected) {
73
+ await websocket.dispose();
74
+ await websocket.connect();
75
+ }
76
+ },
77
+ ),
78
+ ],
79
  ),
80
+ ),
81
+ ),
82
+ const SizedBox(height: 16),
83
+ // Video Prompt Prefix Card
84
+ Card(
85
+ child: Padding(
86
+ padding: const EdgeInsets.all(16),
87
+ child: Column(
88
+ crossAxisAlignment: CrossAxisAlignment.start,
89
+ children: [
90
+ const Text(
91
+ 'Video Generation',
92
+ style: TextStyle(
93
+ color: AiTubeColors.onBackground,
94
+ fontSize: 20,
95
+ fontWeight: FontWeight.bold,
96
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  ),
98
+ const SizedBox(height: 16),
99
+ TextField(
100
+ controller: _promptController,
101
+ decoration: const InputDecoration(
102
+ labelText: 'Video Prompt Prefix',
103
+ helperText: 'Text to prepend to all video generation prompts',
104
+ helperMaxLines: 2,
105
+ ),
106
+ onChanged: (value) {
107
+ _settingsService.setVideoPromptPrefix(value);
108
+ },
109
+ ),
110
+ const SizedBox(height: 16),
111
+ TextField(
112
+ controller: _negativePromptController,
113
+ decoration: const InputDecoration(
114
+ labelText: 'Negative Prompt',
115
+ helperText: 'Content to avoid in the output generation',
116
+ helperMaxLines: 2,
117
+ ),
118
+ onChanged: (value) {
119
+ _settingsService.setNegativeVideoPrompt(value);
120
+ },
121
+ ),
122
+ ],
123
  ),
124
+ ),
125
+ ),
126
+ const SizedBox(height: 16),
127
+ // Custom Video Model Card
128
+ Card(
129
+ child: Padding(
130
+ padding: const EdgeInsets.all(16),
131
+ child: Column(
132
+ crossAxisAlignment: CrossAxisAlignment.start,
133
+ children: [
134
+ const Text(
135
+ 'Custom Video Model',
136
+ style: TextStyle(
137
+ color: AiTubeColors.onBackground,
138
+ fontSize: 20,
139
+ fontWeight: FontWeight.bold,
140
+ ),
141
+ ),
142
+ const SizedBox(height: 16),
143
+ DropdownButtonFormField<String>(
144
+ decoration: const InputDecoration(
145
+ labelText: 'Video Generation Model',
146
+ ),
147
+ value: 'ltx-video-0.9.6',
148
+ onChanged: null, // Disabled
149
+ items: const [
150
+ DropdownMenuItem(
151
+ value: 'ltx-video-0.9.6',
152
+ child: Text('LTX-Video 0.9.6 (base model)'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  ),
154
  ],
155
  ),
156
+ const SizedBox(height: 8),
157
+ const Text(
158
+ 'Interested in using custom Hugging Face models? If you trained a public and distilled LoRA model based on LTX-Video 0.9.6 (remember, it has to be distilled), it can be integrated into AiTube2. Please open a thread in the Community forum and I\'ll see for a way to allow for custom models.',
159
+ style: TextStyle(
160
+ fontSize: 12,
161
+ color: Colors.grey,
162
+ ),
163
+ ),
164
+ ],
165
  ),
166
+ ),
167
+ ),
168
+ ],
169
  ),
170
  );
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  }
lib/screens/video_screen.dart CHANGED
@@ -10,7 +10,6 @@ import 'package:flutter/material.dart';
10
  import '../config/config.dart';
11
  import '../models/video_result.dart';
12
  import '../services/websocket_api_service.dart';
13
- import '../services/cache_service.dart';
14
  import '../services/settings_service.dart';
15
  import '../theme/colors.dart';
16
  import '../widgets/video_player_widget.dart';
@@ -30,7 +29,6 @@ class VideoScreen extends StatefulWidget {
30
  class _VideoScreenState extends State<VideoScreen> {
31
  Future<String>? _captionFuture;
32
  final _websocketService = WebSocketApiService();
33
- final _cacheService = CacheService();
34
  bool _isConnected = false;
35
  late VideoResult _videoData;
36
  final _searchController = TextEditingController();
@@ -62,16 +60,6 @@ class _VideoScreenState extends State<VideoScreen> {
62
  });
63
 
64
  _initializeConnection();
65
- _loadCachedThumbnail();
66
- }
67
-
68
- Future<void> _loadCachedThumbnail() async {
69
- final cachedThumbnail = await _cacheService.getThumbnail(_videoData.id);
70
- if (cachedThumbnail != null && mounted) {
71
- setState(() {
72
- _videoData = _videoData.copyWith(thumbnailUrl: cachedThumbnail);
73
- });
74
- }
75
  }
76
 
77
  Future<void> _initializeConnection() async {
@@ -287,9 +275,9 @@ class _VideoScreenState extends State<VideoScreen> {
287
 
288
  // Update URL parameter on web platform
289
  if (kIsWeb) {
290
- // Update view parameter and remove search parameter if present
291
- updateUrlParameter('view', query);
292
- removeUrlParameter('search');
293
  }
294
 
295
  try {
@@ -306,6 +294,13 @@ class _VideoScreenState extends State<VideoScreen> {
306
  _videoData = result;
307
  _isSearching = false;
308
  });
 
 
 
 
 
 
 
309
  }
310
  } catch (e) {
311
  if (mounted) {
@@ -334,6 +329,21 @@ class _VideoScreenState extends State<VideoScreen> {
334
  ? const Icon(Icons.arrow_back, color: AiTubeColors.onBackground)
335
  : const Icon(Icons.home, color: AiTubeColors.onBackground),
336
  onPressed: () {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  if (Navigator.canPop(context)) {
338
  Navigator.pop(context);
339
  } else {
 
10
  import '../config/config.dart';
11
  import '../models/video_result.dart';
12
  import '../services/websocket_api_service.dart';
 
13
  import '../services/settings_service.dart';
14
  import '../theme/colors.dart';
15
  import '../widgets/video_player_widget.dart';
 
29
  class _VideoScreenState extends State<VideoScreen> {
30
  Future<String>? _captionFuture;
31
  final _websocketService = WebSocketApiService();
 
32
  bool _isConnected = false;
33
  late VideoResult _videoData;
34
  final _searchController = TextEditingController();
 
60
  });
61
 
62
  _initializeConnection();
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
  Future<void> _initializeConnection() async {
 
275
 
276
  // Update URL parameter on web platform
277
  if (kIsWeb) {
278
+ // Update view parameter with the description instead of the query
279
+ // We'll get the actual description from the search result
280
+ // removeUrlParameter('search') will happen after we get the result
281
  }
282
 
283
  try {
 
294
  _videoData = result;
295
  _isSearching = false;
296
  });
297
+
298
+ // Now that we have the result, update the URL parameter on web platform
299
+ if (kIsWeb) {
300
+ // Update view parameter with the description
301
+ updateUrlParameter('view', result.description);
302
+ removeUrlParameter('search');
303
+ }
304
  }
305
  } catch (e) {
306
  if (mounted) {
 
329
  ? const Icon(Icons.arrow_back, color: AiTubeColors.onBackground)
330
  : const Icon(Icons.home, color: AiTubeColors.onBackground),
331
  onPressed: () {
332
+ // Restore the search parameter in URL when navigating back
333
+ if (kIsWeb) {
334
+ // Remove the view parameter
335
+ removeUrlParameter('view');
336
+
337
+ // Get the search query from the video description
338
+ // This matches what we stored in the view parameter when
339
+ // navigating to this screen
340
+ final searchQuery = _videoData.description.trim();
341
+ if (searchQuery.isNotEmpty) {
342
+ // Update URL to show search parameter again
343
+ updateUrlParameter('search', searchQuery);
344
+ }
345
+ }
346
+
347
  if (Navigator.canPop(context)) {
348
  Navigator.pop(context);
349
  } else {
lib/services/cache_service.dart DELETED
@@ -1,340 +0,0 @@
1
- // lib/services/cache_service.dart
2
- import 'dart:async';
3
- import 'dart:convert';
4
- import 'package:flutter/foundation.dart';
5
- import 'package:shared_preferences/shared_preferences.dart' as prefs;
6
- import 'package:idb_shim/idb_browser.dart' if (dart.library.io) 'package:idb_shim/idb_io.dart';
7
- import '../models/video_result.dart';
8
-
9
- /// Storage provider to handle platform differences
10
- abstract class StorageProvider {
11
- Future<void> initialize();
12
- Future<void> write(String key, Uint8List data);
13
- Future<Uint8List?> read(String key);
14
- Future<void> delete(String key);
15
- Future<void> clear();
16
- Future<int> getTotalSize();
17
- }
18
-
19
- /// IndexedDB implementation for web platform
20
- class WebStorageProvider implements StorageProvider {
21
- late Database _db;
22
- static const String _storeName = 'binary_store';
23
-
24
- @override
25
- Future<void> initialize() async {
26
- final factory = getIdbFactory()!;
27
- _db = await factory.open(
28
- 'aitube_cache',
29
- version: 1,
30
- onUpgradeNeeded: (VersionChangeEvent event) {
31
- final db = event.database;
32
- db.createObjectStore(_storeName);
33
- },
34
- );
35
- }
36
-
37
- @override
38
- Future<void> write(String key, Uint8List data) async {
39
- final txn = _db.transaction(_storeName, 'readwrite');
40
- final store = txn.objectStore(_storeName);
41
- await store.put(data, key);
42
- }
43
-
44
- @override
45
- Future<Uint8List?> read(String key) async {
46
- final txn = _db.transaction(_storeName, 'readonly');
47
- final store = txn.objectStore(_storeName);
48
- final data = await store.getObject(key);
49
- if (data == null) return null;
50
- return data as Uint8List;
51
- }
52
-
53
- @override
54
- Future<void> delete(String key) async {
55
- final txn = _db.transaction(_storeName, 'readwrite');
56
- final store = txn.objectStore(_storeName);
57
- await store.delete(key);
58
- }
59
-
60
- @override
61
- Future<void> clear() async {
62
- final txn = _db.transaction(_storeName, 'readwrite');
63
- final store = txn.objectStore(_storeName);
64
- await store.clear();
65
- }
66
-
67
- @override
68
- Future<int> getTotalSize() async {
69
- final txn = _db.transaction(_storeName, 'readonly');
70
- final store = txn.objectStore(_storeName);
71
- final keys = await store.getAllKeys();
72
- int totalSize = 0;
73
-
74
- for (final key in keys) {
75
- final data = await store.getObject(key);
76
- if (data is Uint8List) {
77
- totalSize += data.length;
78
- }
79
- }
80
-
81
- return totalSize;
82
- }
83
- }
84
-
85
- /// Memory-based implementation for web platform
86
- class MemoryStorageProvider implements StorageProvider {
87
- final Map<String, Uint8List> _storage = {};
88
-
89
- @override
90
- Future<void> initialize() async {}
91
-
92
- @override
93
- Future<void> write(String key, Uint8List data) async {
94
- _storage[key] = data;
95
- }
96
-
97
- @override
98
- Future<Uint8List?> read(String key) async {
99
- return _storage[key];
100
- }
101
-
102
- @override
103
- Future<void> delete(String key) async {
104
- _storage.remove(key);
105
- }
106
-
107
- @override
108
- Future<void> clear() async {
109
- _storage.clear();
110
- }
111
-
112
- @override
113
- Future<int> getTotalSize() async {
114
- return _storage.values.fold<int>(0, (total, data) => total + data.length);
115
- }
116
- }
117
-
118
- class CacheService {
119
- static final CacheService _instance = CacheService._internal();
120
- factory CacheService() => _instance;
121
-
122
- late final prefs.SharedPreferences _prefs;
123
- late final StorageProvider _storage;
124
- final _statsController = StreamController<CacheStats>.broadcast();
125
-
126
- static const String _metadataPrefix = 'metadata_';
127
- static const String _videoPrefix = 'video_';
128
- static const String _thumbnailPrefix = 'thumb_';
129
- static const Duration _cacheExpiry = Duration(days: 7);
130
-
131
- Stream<CacheStats> get statsStream => _statsController.stream;
132
-
133
- CacheService._internal() {
134
- // Use IndexedDB for web, memory storage for testing/development
135
- _storage = kIsWeb ? WebStorageProvider() : MemoryStorageProvider();
136
- }
137
-
138
- Future<void> initialize() async {
139
- await _storage.initialize();
140
- _prefs = await prefs.SharedPreferences.getInstance();
141
- await _cleanExpiredEntries();
142
- await _updateStats();
143
- }
144
-
145
- Future<void> _cleanExpiredEntries() async {
146
- final now = DateTime.now();
147
- final keys = _prefs.getKeys().where((k) => k.startsWith(_metadataPrefix));
148
-
149
- for (final key in keys) {
150
- final metadata = _prefs.getString(key);
151
- if (metadata != null) {
152
- final data = json.decode(metadata);
153
- final timestamp = DateTime.parse(data['timestamp']);
154
- if (now.difference(timestamp) > _cacheExpiry) {
155
- await _removeEntry(key.substring(_metadataPrefix.length));
156
- }
157
- }
158
- }
159
- }
160
-
161
- Future<void> _removeEntry(String key) async {
162
- await _prefs.remove('$_metadataPrefix$key');
163
- await _storage.delete(key);
164
- await _updateStats();
165
- }
166
-
167
- Future<void> _updateStats() async {
168
- final totalSize = await _storage.getTotalSize();
169
- final totalItems = _prefs.getKeys()
170
- .where((k) => k.startsWith(_metadataPrefix))
171
- .length;
172
-
173
- _statsController.add(CacheStats(
174
- totalItems: totalItems,
175
- totalSizeMB: totalSize / (1024 * 1024),
176
- ));
177
- }
178
-
179
- Future<void> cacheSearchResults(String query, List<VideoResult> results) async {
180
- final key = 'search_$query';
181
- final data = Uint8List.fromList(utf8.encode(json.encode({
182
- 'query': query,
183
- 'results': results.map((r) => r.toJson()).toList(),
184
- })));
185
-
186
- await _storage.write(key, data);
187
- await _prefs.setString('$_metadataPrefix$key', json.encode({
188
- 'timestamp': DateTime.now().toIso8601String(),
189
- 'type': 'search',
190
- }));
191
-
192
- await _updateStats();
193
- }
194
-
195
- Future<List<VideoResult>?> getSearchResults(String query) async {
196
- final key = 'search_$query';
197
- final data = await _storage.read(key);
198
- if (data == null) return null;
199
-
200
- final decoded = json.decode(utf8.decode(data));
201
- return (decoded['results'] as List)
202
- .map((r) => VideoResult.fromJson(r as Map<String, dynamic>))
203
- .toList();
204
- }
205
-
206
- Future<void> cacheVideoData(String videoId, String videoData) async {
207
- final key = '$_videoPrefix$videoId';
208
- final data = _extractVideoData(videoData);
209
-
210
- await _storage.write(key, data);
211
- await _prefs.setString('$_metadataPrefix$key', json.encode({
212
- 'timestamp': DateTime.now().toIso8601String(),
213
- 'type': 'video',
214
- }));
215
-
216
- await _updateStats();
217
- }
218
-
219
- Future<String?> getVideoData(String videoId) async {
220
- final key = '$_videoPrefix$videoId';
221
- final data = await _storage.read(key);
222
- if (data == null) return null;
223
-
224
- return 'data:video/mp4;base64,${base64Encode(data)}';
225
- }
226
-
227
- Future<void> cacheThumbnail(String videoId, String thumbnailData) async {
228
- final key = '$_thumbnailPrefix$videoId';
229
- final data = _extractImageData(thumbnailData);
230
-
231
- await _storage.write(key, data);
232
- await _prefs.setString('$_metadataPrefix$key', json.encode({
233
- 'timestamp': DateTime.now().toIso8601String(),
234
- 'type': 'thumbnail',
235
- }));
236
-
237
- await _updateStats();
238
- }
239
-
240
- Future<String?> getThumbnail(String videoId) async {
241
- final key = '$_thumbnailPrefix$videoId';
242
- final data = await _storage.read(key);
243
- if (data == null) return null;
244
-
245
- return 'data:image/jpeg;base64,${base64Encode(data)}';
246
- }
247
-
248
- Uint8List _extractVideoData(String videoData) {
249
- final parts = videoData.split(',');
250
- if (parts.length != 2) throw Exception('Invalid video data format');
251
- return base64Decode(parts[1]);
252
- }
253
-
254
- Uint8List _extractImageData(String imageData) {
255
- final parts = imageData.split(',');
256
- if (parts.length != 2) throw Exception('Invalid image data format');
257
- return base64Decode(parts[1]);
258
- }
259
-
260
- Future<void> delete(String key) async {
261
- await _storage.delete('$_videoPrefix$key');
262
- await _prefs.remove('$_metadataPrefix$_videoPrefix$key');
263
- await _updateStats();
264
- }
265
-
266
- Future<void> clearCache() async {
267
- await _storage.clear();
268
- final keys = _prefs.getKeys().where((k) => k.startsWith(_metadataPrefix));
269
- for (final key in keys) {
270
- await _prefs.remove(key);
271
- }
272
- await _updateStats();
273
- }
274
-
275
- Future<void> cacheSearchResult(String query, VideoResult result, int searchCount) async {
276
- final key = 'search_${query}_$searchCount';
277
- final data = Uint8List.fromList(utf8.encode(json.encode({
278
- 'query': query,
279
- 'searchCount': searchCount,
280
- 'result': result.toJson(),
281
- })));
282
-
283
- await _storage.write(key, data);
284
- await _prefs.setString('$_metadataPrefix$key', json.encode({
285
- 'timestamp': DateTime.now().toIso8601String(),
286
- 'type': 'search',
287
- }));
288
-
289
- await _updateStats();
290
- }
291
-
292
- Future<List<VideoResult>> getCachedSearchResults(String query) async {
293
- final results = <VideoResult>[];
294
- final searchKeys = _prefs.getKeys()
295
- .where((k) => k.startsWith('${_metadataPrefix}search_$query'));
296
-
297
- for (final key in searchKeys) {
298
- final data = await _storage.read(key.substring(_metadataPrefix.length));
299
- if (data != null) {
300
- final decoded = json.decode(utf8.decode(data));
301
- results.add(VideoResult.fromJson(decoded['result'] as Map<String, dynamic>));
302
- }
303
- }
304
-
305
- return results..sort((a, b) => a.createdAt.compareTo(b.createdAt));
306
- }
307
-
308
- Future<int> getLastSearchCount(String query) async {
309
- final searchKeys = _prefs.getKeys()
310
- .where((k) => k.startsWith('${_metadataPrefix}search_$query'))
311
- .toList();
312
-
313
- if (searchKeys.isEmpty) return 0;
314
-
315
- int maxCount = -1;
316
- for (final key in searchKeys) {
317
- final match = RegExp(r'search_.*_(\d+)$').firstMatch(key);
318
- if (match != null) {
319
- final count = int.parse(match.group(1)!);
320
- if (count > maxCount) maxCount = count;
321
- }
322
- }
323
-
324
- return maxCount + 1;
325
- }
326
-
327
- void dispose() {
328
- _statsController.close();
329
- }
330
- }
331
-
332
- class CacheStats {
333
- final int totalItems;
334
- final double totalSizeMB;
335
-
336
- CacheStats({
337
- required this.totalItems,
338
- required this.totalSizeMB,
339
- });
340
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/services/clip_queue/clip_generation_handler.dart ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/clip_generation_handler.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/foundation.dart';
5
+ import 'package:aitube2/config/config.dart';
6
+ import '../websocket_api_service.dart';
7
+ import '../../models/video_result.dart';
8
+ import 'clip_states.dart';
9
+ import 'video_clip.dart';
10
+ import 'queue_stats_logger.dart';
11
+
12
+ /// Handles the generation of video clips
13
+ class ClipGenerationHandler {
14
+ /// WebSocket service for API communication
15
+ final WebSocketApiService _websocketService;
16
+
17
+ /// Logger for tracking stats
18
+ final QueueStatsLogger _logger;
19
+
20
+ /// Set of active generations (by seed)
21
+ final Set<String> _activeGenerations;
22
+
23
+ /// Whether the handler is disposed
24
+ bool _isDisposed = false;
25
+
26
+ /// Callback for when the queue is updated
27
+ final void Function()? onQueueUpdated;
28
+
29
+ /// Constructor
30
+ ClipGenerationHandler({
31
+ required WebSocketApiService websocketService,
32
+ required QueueStatsLogger logger,
33
+ required Set<String> activeGenerations,
34
+ required this.onQueueUpdated,
35
+ }) : _websocketService = websocketService,
36
+ _logger = logger,
37
+ _activeGenerations = activeGenerations;
38
+
39
+ /// Setter for the disposed state
40
+ set isDisposed(bool value) {
41
+ _isDisposed = value;
42
+ }
43
+
44
+ /// Whether a new generation can be started
45
+ bool canStartNewGeneration(int maxConcurrentGenerations) =>
46
+ _activeGenerations.length < maxConcurrentGenerations;
47
+
48
+ /// Handle a stuck generation
49
+ void handleStuckGeneration(VideoClip clip) {
50
+ ClipQueueConstants.logEvent('Found stuck generation for clip ${clip.seed}');
51
+
52
+ if (_activeGenerations.contains(clip.seed.toString())) {
53
+ _activeGenerations.remove(clip.seed.toString());
54
+ }
55
+
56
+ clip.state = ClipState.failedToGenerate;
57
+
58
+ if (clip.canRetry) {
59
+ scheduleRetry(clip);
60
+ }
61
+ }
62
+
63
+ /// Schedule a retry for a failed generation
64
+ void scheduleRetry(VideoClip clip) {
65
+ clip.retryTimer?.cancel();
66
+ clip.retryTimer = Timer(ClipQueueConstants.retryDelay, () {
67
+ if (!_isDisposed && clip.hasFailed) {
68
+ ClipQueueConstants.logEvent('Retrying clip ${clip.seed} (attempt ${clip.retryCount + 1}/${VideoClip.maxRetries})');
69
+ clip.state = ClipState.generationPending;
70
+ clip.generationCompleter = null;
71
+ clip.generationStartTime = null;
72
+ onQueueUpdated?.call();
73
+ }
74
+ });
75
+ }
76
+
77
+ /// Generate a video clip
78
+ Future<void> generateClip(VideoClip clip, VideoResult video) async {
79
+ if (clip.isGenerating || clip.isReady || _isDisposed ||
80
+ !canStartNewGeneration(Configuration.instance.renderQueueMaxConcurrentGenerations)) {
81
+ return;
82
+ }
83
+
84
+ final clipSeed = clip.seed.toString();
85
+ if (_activeGenerations.contains(clipSeed)) {
86
+ ClipQueueConstants.logEvent('Clip $clipSeed already generating');
87
+ return;
88
+ }
89
+
90
+ _activeGenerations.add(clipSeed);
91
+ clip.state = ClipState.generationInProgress;
92
+ clip.generationCompleter = Completer<void>();
93
+ clip.generationStartTime = DateTime.now();
94
+
95
+ try {
96
+ // Check if we're disposed before proceeding
97
+ if (_isDisposed) {
98
+ ClipQueueConstants.logEvent('Cancelled generation of clip $clipSeed - handler disposed');
99
+ return;
100
+ }
101
+
102
+ // Generate new video with timeout
103
+ String videoData = await _websocketService.generateVideo(
104
+ video,
105
+ seed: clip.seed,
106
+ ).timeout(ClipQueueConstants.generationTimeout);
107
+
108
+ if (!_isDisposed) {
109
+ await handleSuccessfulGeneration(clip, videoData);
110
+ }
111
+
112
+ } catch (e) {
113
+ if (!_isDisposed) {
114
+ handleFailedGeneration(clip, e);
115
+ }
116
+ } finally {
117
+ cleanupGeneration(clip);
118
+ }
119
+ }
120
+
121
+ /// Handle a successful generation
122
+ Future<void> handleSuccessfulGeneration(
123
+ VideoClip clip,
124
+ String videoData,
125
+ ) async {
126
+ if (_isDisposed) return;
127
+
128
+ clip.base64Data = videoData;
129
+ clip.completeGeneration();
130
+
131
+ // Only complete the completer if it exists and isn't already completed
132
+ if (clip.generationCompleter != null && !clip.generationCompleter!.isCompleted) {
133
+ clip.generationCompleter!.complete();
134
+ }
135
+
136
+ _logger.updateGenerationStats(clip);
137
+ onQueueUpdated?.call();
138
+ }
139
+
140
+ /// Handle a failed generation
141
+ void handleFailedGeneration(VideoClip clip, dynamic error) {
142
+ if (_isDisposed) return;
143
+ clip.state = ClipState.failedToGenerate;
144
+ clip.retryCount++;
145
+
146
+ // Only complete with error if the completer exists and isn't completed
147
+ if (clip.generationCompleter != null && !clip.generationCompleter!.isCompleted) {
148
+ clip.generationCompleter!.completeError(error);
149
+ }
150
+
151
+ if (clip.canRetry) {
152
+ scheduleRetry(clip);
153
+ }
154
+ }
155
+
156
+ /// Clean up after a generation attempt
157
+ void cleanupGeneration(VideoClip clip) {
158
+ if (!_isDisposed) {
159
+ _activeGenerations.remove(clip.seed.toString());
160
+ onQueueUpdated?.call();
161
+ }
162
+ }
163
+
164
+ /// Check for stuck generations
165
+ void checkForStuckGenerations(List<VideoClip> clipBuffer) {
166
+ final now = DateTime.now();
167
+ var hadStuckGenerations = false;
168
+
169
+ for (final clip in clipBuffer) {
170
+ if (clip.isGenerating &&
171
+ clip.generationStartTime != null &&
172
+ now.difference(clip.generationStartTime!) > ClipQueueConstants.clipTimeout) {
173
+ hadStuckGenerations = true;
174
+ handleStuckGeneration(clip);
175
+ }
176
+ }
177
+
178
+ if (hadStuckGenerations) {
179
+ ClipQueueConstants.logEvent('Cleaned up stuck generations. Active: ${_activeGenerations.length}');
180
+ }
181
+ }
182
+ }
lib/services/clip_queue/clip_queue_manager.dart ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/clip_queue_manager.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:aitube2/config/config.dart';
5
+ import 'package:flutter/foundation.dart';
6
+ import 'package:collection/collection.dart';
7
+ import '../../models/video_result.dart';
8
+ import '../websocket_api_service.dart';
9
+ import '../../utils/seed.dart';
10
+ import 'clip_states.dart';
11
+ import 'video_clip.dart';
12
+ import 'queue_stats_logger.dart';
13
+ import 'clip_generation_handler.dart';
14
+
15
+ /// Manages a queue of video clips for generation and playback
16
+ class ClipQueueManager {
17
+ /// The video for which clips are being generated
18
+ final VideoResult video;
19
+
20
+ /// WebSocket service for API communication
21
+ final WebSocketApiService _websocketService;
22
+
23
+ /// Callback for when the queue is updated
24
+ final void Function()? onQueueUpdated;
25
+
26
+ /// Buffer of clips being managed
27
+ final List<VideoClip> _clipBuffer = [];
28
+
29
+ /// History of played clips
30
+ final List<VideoClip> _clipHistory = [];
31
+
32
+ /// Set of active generations (by seed)
33
+ final Set<String> _activeGenerations = {};
34
+
35
+ /// Timer for checking the buffer state
36
+ Timer? _bufferCheckTimer;
37
+
38
+ /// Whether the manager is disposed
39
+ bool _isDisposed = false;
40
+
41
+ /// Stats logger
42
+ final QueueStatsLogger _logger = QueueStatsLogger();
43
+
44
+ /// Generation handler
45
+ late final ClipGenerationHandler _generationHandler;
46
+
47
+ /// ID of the video being managed
48
+ final String videoId;
49
+
50
+ /// Constructor
51
+ ClipQueueManager({
52
+ required this.video,
53
+ WebSocketApiService? websocketService,
54
+ this.onQueueUpdated,
55
+ }) : videoId = video.id,
56
+ _websocketService = websocketService ?? WebSocketApiService() {
57
+ _generationHandler = ClipGenerationHandler(
58
+ websocketService: _websocketService,
59
+ logger: _logger,
60
+ activeGenerations: _activeGenerations,
61
+ onQueueUpdated: onQueueUpdated,
62
+ );
63
+ }
64
+
65
+ /// Whether a new generation can be started
66
+ bool get canStartNewGeneration =>
67
+ _activeGenerations.length < Configuration.instance.renderQueueMaxConcurrentGenerations;
68
+
69
+ /// Number of pending generations
70
+ int get pendingGenerations => _clipBuffer.where((c) => c.isPending).length;
71
+
72
+ /// Number of active generations
73
+ int get activeGenerations => _activeGenerations.length;
74
+
75
+ /// Current clip that is ready or playing
76
+ VideoClip? get currentClip => _clipBuffer.firstWhereOrNull((c) => c.isReady || c.isPlaying);
77
+
78
+ /// Next clip that is ready to play
79
+ VideoClip? get nextReadyClip => _clipBuffer.where((c) => c.isReady && !c.isPlaying).firstOrNull;
80
+
81
+ /// Whether there are any ready clips
82
+ bool get hasReadyClips => _clipBuffer.any((c) => c.isReady);
83
+
84
+ /// Unmodifiable view of the clip buffer
85
+ List<VideoClip> get clipBuffer => List.unmodifiable(_clipBuffer);
86
+
87
+ /// Unmodifiable view of the clip history
88
+ List<VideoClip> get clipHistory => List.unmodifiable(_clipHistory);
89
+
90
+ /// Initialize the clip queue
91
+ Future<void> initialize() async {
92
+ if (_isDisposed) return;
93
+
94
+ _logger.logStateChange(
95
+ 'initialize:start',
96
+ clipBuffer: _clipBuffer,
97
+ activeGenerations: _activeGenerations,
98
+ clipHistory: _clipHistory,
99
+ isDisposed: _isDisposed,
100
+ );
101
+ _clipBuffer.clear();
102
+
103
+ try {
104
+ final bufferSize = Configuration.instance.renderQueueBufferSize;
105
+ while (_clipBuffer.length < bufferSize) {
106
+ if (_isDisposed) return;
107
+
108
+ final newClip = VideoClip(
109
+ prompt: "${video.title}\n${video.description}",
110
+ seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
111
+ );
112
+ _clipBuffer.add(newClip);
113
+ ClipQueueConstants.logEvent('Added initial clip ${newClip.seed} to buffer');
114
+ }
115
+
116
+ if (_isDisposed) return;
117
+
118
+ _startBufferCheck();
119
+ await _fillBuffer();
120
+ ClipQueueConstants.logEvent('Initialization complete. Buffer size: ${_clipBuffer.length}');
121
+ printQueueState();
122
+ } catch (e) {
123
+ ClipQueueConstants.logEvent('Initialization error: $e');
124
+ rethrow;
125
+ }
126
+
127
+ _logger.logStateChange(
128
+ 'initialize:complete',
129
+ clipBuffer: _clipBuffer,
130
+ activeGenerations: _activeGenerations,
131
+ clipHistory: _clipHistory,
132
+ isDisposed: _isDisposed,
133
+ );
134
+ }
135
+
136
+ /// Start the buffer check timer
137
+ void _startBufferCheck() {
138
+ _bufferCheckTimer?.cancel();
139
+ _bufferCheckTimer = Timer.periodic(
140
+ const Duration(milliseconds: 200),
141
+ (timer) {
142
+ if (!_isDisposed) {
143
+ _fillBuffer();
144
+ }
145
+ },
146
+ );
147
+ ClipQueueConstants.logEvent('Started buffer check timer');
148
+ }
149
+
150
+ /// Mark a specific clip as played
151
+ void markClipAsPlayed(String clipId) {
152
+ _logger.logStateChange(
153
+ 'markAsPlayed:start',
154
+ clipBuffer: _clipBuffer,
155
+ activeGenerations: _activeGenerations,
156
+ clipHistory: _clipHistory,
157
+ isDisposed: _isDisposed,
158
+ );
159
+ final playingClip = _clipBuffer.firstWhereOrNull((c) => c.id == clipId);
160
+ if (playingClip != null) {
161
+ playingClip.finishPlaying();
162
+
163
+ _reorderBufferByPriority();
164
+ _fillBuffer();
165
+ onQueueUpdated?.call();
166
+ }
167
+ _logger.logStateChange(
168
+ 'markAsPlayed:complete',
169
+ clipBuffer: _clipBuffer,
170
+ activeGenerations: _activeGenerations,
171
+ clipHistory: _clipHistory,
172
+ isDisposed: _isDisposed,
173
+ );
174
+ }
175
+
176
+ /// Fill the buffer with clips and start generations as needed
177
+ Future<void> _fillBuffer() async {
178
+ if (_isDisposed) return;
179
+
180
+ // First ensure we have the correct buffer size
181
+ while (_clipBuffer.length < Configuration.instance.renderQueueBufferSize) {
182
+ final newClip = VideoClip(
183
+ prompt: "${video.title}\n${video.description}",
184
+ seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
185
+ );
186
+ _clipBuffer.add(newClip);
187
+ ClipQueueConstants.logEvent('Added new clip ${newClip.seed} to maintain buffer size');
188
+ }
189
+
190
+ // Process played clips first
191
+ final playedClips = _clipBuffer.where((clip) => clip.hasPlayed).toList();
192
+ if (playedClips.isNotEmpty) {
193
+ _processPlayedClips(playedClips);
194
+ }
195
+
196
+ // Remove failed clips and replace them
197
+ final failedClips = _clipBuffer.where((clip) => clip.hasFailed && !clip.canRetry).toList();
198
+ for (final clip in failedClips) {
199
+ _clipBuffer.remove(clip);
200
+ final newClip = VideoClip(
201
+ prompt: "${video.title}\n${video.description}",
202
+ seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
203
+ );
204
+ _clipBuffer.add(newClip);
205
+ }
206
+
207
+ // Clean up stuck generations
208
+ _generationHandler.checkForStuckGenerations(_clipBuffer);
209
+
210
+ // Get pending clips that aren't being generated
211
+ final pendingClips = _clipBuffer
212
+ .where((clip) => clip.isPending && !_activeGenerations.contains(clip.seed.toString()))
213
+ .toList();
214
+
215
+ // Calculate available generation slots
216
+ final availableSlots = Configuration.instance.renderQueueMaxConcurrentGenerations - _activeGenerations.length;
217
+
218
+ if (availableSlots > 0 && pendingClips.isNotEmpty) {
219
+ final clipsToGenerate = pendingClips.take(availableSlots).toList();
220
+ ClipQueueConstants.logEvent('Starting ${clipsToGenerate.length} parallel generations');
221
+
222
+ final generationFutures = clipsToGenerate.map((clip) =>
223
+ _generationHandler.generateClip(clip, video).catchError((e) {
224
+ debugPrint('Generation failed for clip ${clip.seed}: $e');
225
+ return null;
226
+ })
227
+ ).toList();
228
+
229
+ ClipQueueConstants.unawaited(
230
+ Future.wait(generationFutures, eagerError: false).then((_) {
231
+ if (!_isDisposed) {
232
+ onQueueUpdated?.call();
233
+ // Recursively ensure buffer stays full
234
+ _fillBuffer();
235
+ }
236
+ })
237
+ );
238
+ }
239
+
240
+ onQueueUpdated?.call();
241
+
242
+ _logger.logStateChange(
243
+ 'fillBuffer:complete',
244
+ clipBuffer: _clipBuffer,
245
+ activeGenerations: _activeGenerations,
246
+ clipHistory: _clipHistory,
247
+ isDisposed: _isDisposed,
248
+ );
249
+ }
250
+
251
+ /// Reorder the buffer by priority
252
+ void _reorderBufferByPriority() {
253
+ // First, extract all clips that aren't played
254
+ final activeClips = _clipBuffer.where((c) => !c.hasPlayed).toList();
255
+
256
+ // Sort clips by priority:
257
+ // 1. Currently playing clips stay at their position
258
+ // 2. Ready clips move to the front (right after playing clips)
259
+ // 3. In-progress generations
260
+ // 4. Pending generations
261
+ // 5. Failed generations
262
+ activeClips.sort((a, b) {
263
+ // Helper function to get priority value for a state
264
+ int getPriority(ClipState state) {
265
+ switch (state) {
266
+ case ClipState.generatedAndPlaying:
267
+ return 0;
268
+ case ClipState.generatedAndReadyToPlay:
269
+ return 1;
270
+ case ClipState.generationInProgress:
271
+ return 2;
272
+ case ClipState.generationPending:
273
+ return 3;
274
+ case ClipState.failedToGenerate:
275
+ return 4;
276
+ case ClipState.generatedAndPlayed:
277
+ return 5;
278
+ }
279
+ }
280
+
281
+ // Compare priorities
282
+ final priorityA = getPriority(a.state);
283
+ final priorityB = getPriority(b.state);
284
+
285
+ if (priorityA != priorityB) {
286
+ return priorityA.compareTo(priorityB);
287
+ }
288
+
289
+ // If same priority, maintain relative order by keeping original indices
290
+ return _clipBuffer.indexOf(a).compareTo(_clipBuffer.indexOf(b));
291
+ });
292
+
293
+ // Clear and refill the buffer with the sorted clips
294
+ _clipBuffer.clear();
295
+ _clipBuffer.addAll(activeClips);
296
+ }
297
+
298
+ /// Process clips that have been played
299
+ void _processPlayedClips(List<VideoClip> playedClips) {
300
+ for (final clip in playedClips) {
301
+ _clipBuffer.remove(clip);
302
+ _clipHistory.add(clip);
303
+
304
+ // Add a new pending clip
305
+ final newClip = VideoClip(
306
+ prompt: "${video.title}\n${video.description}",
307
+ seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
308
+ );
309
+ _clipBuffer.add(newClip);
310
+ ClipQueueConstants.logEvent('Replaced played clip ${clip.seed} with new clip ${newClip.seed}');
311
+ }
312
+
313
+ // Immediately trigger buffer fill to start generating new clips
314
+ _fillBuffer();
315
+ }
316
+
317
+ /// Mark the current playing clip as played
318
+ void markCurrentClipAsPlayed() {
319
+ _logger.logStateChange(
320
+ 'markAsPlayed:start',
321
+ clipBuffer: _clipBuffer,
322
+ activeGenerations: _activeGenerations,
323
+ clipHistory: _clipHistory,
324
+ isDisposed: _isDisposed,
325
+ );
326
+ final playingClip = _clipBuffer.firstWhereOrNull((c) => c.isPlaying);
327
+ if (playingClip != null) {
328
+ playingClip.finishPlaying();
329
+
330
+ _reorderBufferByPriority();
331
+ _fillBuffer();
332
+ onQueueUpdated?.call();
333
+ }
334
+ _logger.logStateChange(
335
+ 'markAsPlayed:complete',
336
+ clipBuffer: _clipBuffer,
337
+ activeGenerations: _activeGenerations,
338
+ clipHistory: _clipHistory,
339
+ isDisposed: _isDisposed,
340
+ );
341
+ }
342
+
343
+ /// Start playing a specific clip
344
+ void startPlayingClip(VideoClip clip) {
345
+ _logger.logStateChange(
346
+ 'startPlaying:start',
347
+ clipBuffer: _clipBuffer,
348
+ activeGenerations: _activeGenerations,
349
+ clipHistory: _clipHistory,
350
+ isDisposed: _isDisposed,
351
+ );
352
+ if (clip.isReady) {
353
+ clip.startPlaying();
354
+ onQueueUpdated?.call();
355
+ }
356
+ _logger.logStateChange(
357
+ 'startPlaying:complete',
358
+ clipBuffer: _clipBuffer,
359
+ activeGenerations: _activeGenerations,
360
+ clipHistory: _clipHistory,
361
+ isDisposed: _isDisposed,
362
+ );
363
+ }
364
+
365
+ /// Manually fill the buffer
366
+ void fillBuffer() {
367
+ ClipQueueConstants.logEvent('Manual buffer fill requested');
368
+ _fillBuffer();
369
+ }
370
+
371
+ /// Print the current state of the queue
372
+ void printQueueState() {
373
+ _logger.printQueueState(
374
+ clipBuffer: _clipBuffer,
375
+ activeGenerations: _activeGenerations,
376
+ clipHistory: _clipHistory,
377
+ );
378
+ }
379
+
380
+ /// Get statistics for the buffer
381
+ Map<String, dynamic> getBufferStats() {
382
+ return _logger.getBufferStats(
383
+ clipBuffer: _clipBuffer,
384
+ clipHistory: _clipHistory,
385
+ activeGenerations: _activeGenerations,
386
+ );
387
+ }
388
+
389
+ /// Dispose the manager and clean up resources
390
+ Future<void> dispose() async {
391
+ debugPrint('ClipQueueManager: Starting disposal for video $videoId');
392
+ _isDisposed = true;
393
+ _generationHandler.isDisposed = true;
394
+
395
+ // Cancel all timers first
396
+ _bufferCheckTimer?.cancel();
397
+
398
+ // Complete any pending generation completers
399
+ for (var clip in _clipBuffer) {
400
+ clip.retryTimer?.cancel();
401
+
402
+ if (clip.isGenerating &&
403
+ clip.generationCompleter != null &&
404
+ !clip.generationCompleter!.isCompleted) {
405
+ // Don't throw an error, just complete normally
406
+ clip.generationCompleter!.complete();
407
+ }
408
+ }
409
+
410
+ // Cancel any pending requests for this video
411
+ if (videoId.isNotEmpty) {
412
+ _websocketService.cancelRequestsForVideo(videoId);
413
+ }
414
+
415
+ // Clear all collections
416
+ _clipBuffer.clear();
417
+ _clipHistory.clear();
418
+ _activeGenerations.clear();
419
+
420
+ debugPrint('ClipQueueManager: Completed disposal for video $videoId');
421
+ }
422
+ }
lib/services/clip_queue/clip_states.dart ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/clip_states.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/foundation.dart';
5
+
6
+ /// Represents the different states a video clip can be in during its lifecycle
7
+ enum ClipState {
8
+ /// The clip is waiting to be generated
9
+ generationPending,
10
+
11
+ /// The clip is currently being generated
12
+ generationInProgress,
13
+
14
+ /// The clip has been generated and is ready to be played
15
+ generatedAndReadyToPlay,
16
+
17
+ /// The clip has been generated and is currently playing
18
+ generatedAndPlaying,
19
+
20
+ /// The clip generation failed
21
+ failedToGenerate,
22
+
23
+ /// The clip has been generated and has been played
24
+ generatedAndPlayed
25
+ }
26
+
27
+ /// Constants for clip queue management
28
+ class ClipQueueConstants {
29
+ /// The delay before retrying a failed clip generation
30
+ static const Duration retryDelay = Duration(seconds: 2);
31
+
32
+ /// The timeout for a clip generation before it is considered stuck
33
+ static const Duration clipTimeout = Duration(seconds: 90);
34
+
35
+ /// The timeout for the actual generation process
36
+ static const Duration generationTimeout = Duration(seconds: 60);
37
+
38
+ /// Whether to show logs in debug mode
39
+ static const bool showLogsInDebugMode = false;
40
+
41
+ /// Maximum number of generation times to store for averaging
42
+ static const int maxStoredGenerationTimes = 10;
43
+
44
+ /// Helper function to avoid having to type unawaited everywhere
45
+ static void unawaited(Future<void> future) {
46
+ // This function intentionally does nothing.
47
+ // It's used to explicitly mark that we're not waiting for this Future.
48
+ }
49
+
50
+ /// Logs an event if debug mode is enabled
51
+ static void logEvent(String message) {
52
+ if (showLogsInDebugMode && kDebugMode) {
53
+ debugPrint('ClipQueue: $message');
54
+ }
55
+ }
56
+ }
lib/services/clip_queue/index.dart ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/index.dart
2
+
3
+ export 'clip_states.dart';
4
+ export 'video_clip.dart';
5
+ export 'clip_queue_manager.dart';
6
+ export 'clip_generation_handler.dart';
7
+ export 'queue_stats_logger.dart';
lib/services/clip_queue/queue_stats_logger.dart ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/queue_stats_logger.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/foundation.dart';
5
+ import 'package:collection/collection.dart';
6
+ import 'video_clip.dart';
7
+ import 'clip_states.dart';
8
+
9
+ /// Handles logging and statistics for the ClipQueueManager
10
+ class QueueStatsLogger {
11
+ /// Last time the state was logged
12
+ DateTime? _lastStateLogTime;
13
+
14
+ /// Last state that was logged
15
+ Map<String, dynamic>? _lastLoggedState;
16
+
17
+ /// The most recent successful generation time
18
+ DateTime? _lastSuccessfulGeneration;
19
+
20
+ /// List of recent generation times for calculating averages
21
+ final List<Duration> _generationTimes = [];
22
+
23
+ /// Log a clip queue state change
24
+ void logStateChange(
25
+ String trigger, {
26
+ required List<VideoClip> clipBuffer,
27
+ required Set<String> activeGenerations,
28
+ required List<VideoClip> clipHistory,
29
+ required bool isDisposed,
30
+ }) {
31
+ if (isDisposed) return;
32
+
33
+ final currentState = {
34
+ 'readyClips': clipBuffer.where((c) => c.isReady).length,
35
+ 'playingClips': clipBuffer.where((c) => c.isPlaying).length,
36
+ 'generatingClips': activeGenerations.length,
37
+ 'pendingClips': clipBuffer.where((c) => c.isPending).length,
38
+ 'failedClips': clipBuffer.where((c) => c.hasFailed).length,
39
+ 'clipStates': clipBuffer.map((c) => {
40
+ 'seed': c.seed,
41
+ 'state': c.state.toString(),
42
+ 'retryCount': c.retryCount,
43
+ 'genDuration': c.generationDuration?.inSeconds,
44
+ 'playDuration': c.playbackDuration?.inSeconds,
45
+ }).toList(),
46
+ 'activeGenerations': List<String>.from(activeGenerations),
47
+ 'historySize': clipHistory.length,
48
+ };
49
+
50
+ // Only log if state has changed
51
+ if (_lastLoggedState == null ||
52
+ !_areStatesEqual(_lastLoggedState!, currentState) ||
53
+ _shouldLogDueToTimeout()) {
54
+
55
+ debugPrint('\n=== Queue State Change [$trigger] ===');
56
+ debugPrint('Ready: ${currentState['readyClips']}');
57
+ debugPrint('Playing: ${currentState['playingClips']}');
58
+ debugPrint('Generating: ${currentState['generatingClips']}');
59
+
60
+ /*
61
+ debugPrint('Pending: ${currentState['pendingClips']}');
62
+ debugPrint('Failed: ${currentState['failedClips']}');
63
+ debugPrint('History: ${currentState['historySize']}');
64
+
65
+ debugPrint('\nClip Details:');
66
+ final clipStates = currentState['clipStates'] as List<Map<String, dynamic>>;
67
+ for (var clipState in clipStates) {
68
+ debugPrint('Clip ${clipState['seed']}: ${clipState['state']} '
69
+ '(retries: ${clipState['retryCount']}, '
70
+ 'gen: ${clipState['genDuration']}s, '
71
+ 'play: ${clipState['playDuration']}s)');
72
+ }
73
+
74
+ final activeGenerations = currentState['activeGenerations'] as List<String>;
75
+ if (activeGenerations.isNotEmpty) {
76
+ debugPrint('\nActive Generations: ${activeGenerations.join(', ')}');
77
+ }
78
+
79
+ debugPrint('=====================================\n');
80
+ */
81
+
82
+ _lastLoggedState = currentState;
83
+ _lastStateLogTime = DateTime.now();
84
+ }
85
+ }
86
+
87
+ /// Update generation statistics
88
+ void updateGenerationStats(VideoClip clip) {
89
+ if (clip.generationStartTime != null) {
90
+ final duration = DateTime.now().difference(clip.generationStartTime!);
91
+ _generationTimes.add(duration);
92
+ if (_generationTimes.length > ClipQueueConstants.maxStoredGenerationTimes) {
93
+ _generationTimes.removeAt(0);
94
+ }
95
+ _lastSuccessfulGeneration = DateTime.now();
96
+ }
97
+ }
98
+
99
+ /// Calculate the average generation time
100
+ Duration? getAverageGenerationTime() {
101
+ if (_generationTimes.isEmpty) return null;
102
+ final totalMs = _generationTimes.fold<int>(
103
+ 0,
104
+ (sum, duration) => sum + duration.inMilliseconds
105
+ );
106
+ return Duration(milliseconds: totalMs ~/ _generationTimes.length);
107
+ }
108
+
109
+ /// Print current state of the clip queue
110
+ void printQueueState({
111
+ required List<VideoClip> clipBuffer,
112
+ required Set<String> activeGenerations,
113
+ required List<VideoClip> clipHistory,
114
+ }) {
115
+ final ready = clipBuffer.where((c) => c.isReady).length;
116
+ final playing = clipBuffer.where((c) => c.isPlaying).length;
117
+ final generating = activeGenerations.length;
118
+ final pending = clipBuffer.where((c) => c.isPending).length;
119
+ final failed = clipBuffer.where((c) => c.hasFailed).length;
120
+
121
+ ClipQueueConstants.logEvent('\nQueue State:');
122
+ ClipQueueConstants.logEvent('Buffer size: ${clipBuffer.length}');
123
+ ClipQueueConstants.logEvent('Ready: $ready, Playing: $playing, Generating: $generating, Pending: $pending, Failed: $failed');
124
+ ClipQueueConstants.logEvent('History size: ${clipHistory.length}');
125
+
126
+ for (var i = 0; i < clipBuffer.length; i++) {
127
+ final clip = clipBuffer[i];
128
+ final genDuration = clip.generationDuration;
129
+ final playDuration = clip.playbackDuration;
130
+ ClipQueueConstants.logEvent('Clip $i: seed=${clip.seed}, state=${clip.state}, '
131
+ 'retries=${clip.retryCount}, generation time=${genDuration?.inSeconds}s'
132
+ '${playDuration != null ? ", playing for ${playDuration.inSeconds}s" : ""}');
133
+ }
134
+ }
135
+
136
+ /// Get statistics for the buffer
137
+ Map<String, dynamic> getBufferStats({
138
+ required List<VideoClip> clipBuffer,
139
+ required List<VideoClip> clipHistory,
140
+ required Set<String> activeGenerations,
141
+ }) {
142
+ final averageGeneration = getAverageGenerationTime();
143
+ return {
144
+ 'bufferSize': clipBuffer.length,
145
+ 'historySize': clipHistory.length,
146
+ 'activeGenerations': activeGenerations.length,
147
+ 'pendingClips': clipBuffer.where((c) => c.isPending).length,
148
+ 'readyClips': clipBuffer.where((c) => c.isReady).length,
149
+ 'failedClips': clipBuffer.where((c) => c.hasFailed).length,
150
+ 'lastSuccessfulGeneration': _lastSuccessfulGeneration?.toString(),
151
+ 'averageGenerationTime': averageGeneration?.toString(),
152
+ 'clipStates': clipBuffer.map((c) => c.state.toString()).toList(),
153
+ };
154
+ }
155
+
156
+ /// Log generation status
157
+ void logGenerationStatus({
158
+ required List<VideoClip> clipBuffer,
159
+ required Set<String> activeGenerations,
160
+ }) {
161
+ final pending = clipBuffer.where((c) => c.isPending).length;
162
+ final generating = activeGenerations.length;
163
+ final ready = clipBuffer.where((c) => c.isReady).length;
164
+ final playing = clipBuffer.where((c) => c.isPlaying).length;
165
+
166
+ ClipQueueConstants.logEvent('''
167
+ Buffer Status:
168
+ - Pending: $pending
169
+ - Generating: $generating
170
+ - Ready: $ready
171
+ - Playing: $playing
172
+ - Active generations: ${activeGenerations.join(', ')}
173
+ ''');
174
+ }
175
+
176
+ /// Check if two states are equal
177
+ bool _areStatesEqual(Map<String, dynamic> state1, Map<String, dynamic> state2) {
178
+ return state1['readyClips'] == state2['readyClips'] &&
179
+ state1['playingClips'] == state2['playingClips'] &&
180
+ state1['generatingClips'] == state2['generatingClips'] &&
181
+ state1['pendingClips'] == state2['pendingClips'] &&
182
+ state1['failedClips'] == state2['failedClips'] &&
183
+ state1['historySize'] == state2['historySize'] &&
184
+ const ListEquality().equals(
185
+ state1['activeGenerations'] as List,
186
+ state2['activeGenerations'] as List
187
+ );
188
+ }
189
+
190
+ /// Check if we should log due to timeout
191
+ bool _shouldLogDueToTimeout() {
192
+ if (_lastStateLogTime == null) return true;
193
+ // Force log every 30 seconds even if state hasn't changed
194
+ return DateTime.now().difference(_lastStateLogTime!) > const Duration(seconds: 30);
195
+ }
196
+ }
lib/services/clip_queue/video_clip.dart ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/clip_queue/video_clip.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:uuid/uuid.dart';
5
+ import 'clip_states.dart';
6
+
7
+ /// Represents a video clip in the queue
8
+ class VideoClip {
9
+ /// Unique identifier for the clip
10
+ final String id;
11
+
12
+ /// The prompt used to generate the clip
13
+ final String prompt;
14
+
15
+ /// The seed used for generation
16
+ final int seed;
17
+
18
+ /// Current state of the clip
19
+ ClipState state;
20
+
21
+ /// Base64 encoded video data
22
+ String? base64Data;
23
+
24
+ /// Timer for retrying generation
25
+ Timer? retryTimer;
26
+
27
+ /// Completer for tracking generation completion
28
+ Completer<void>? generationCompleter;
29
+
30
+ /// When generation started
31
+ DateTime? generationStartTime;
32
+
33
+ /// When generation ended
34
+ DateTime? generationEndTime;
35
+
36
+ /// When playback started
37
+ DateTime? playStartTime;
38
+
39
+ /// Number of retry attempts
40
+ int retryCount = 0;
41
+
42
+ /// Maximum number of retries allowed
43
+ static const maxRetries = 3;
44
+
45
+ /// Constructor
46
+ VideoClip({
47
+ String? id,
48
+ required this.prompt,
49
+ required this.seed,
50
+ this.state = ClipState.generationPending,
51
+ this.base64Data,
52
+ }): id = id ?? const Uuid().v4();
53
+
54
+ /// Whether the clip is ready to play
55
+ bool get isReady => state == ClipState.generatedAndReadyToPlay;
56
+
57
+ /// Whether the clip is waiting to be generated
58
+ bool get isPending => state == ClipState.generationPending;
59
+
60
+ /// Whether the clip is currently being generated
61
+ bool get isGenerating => state == ClipState.generationInProgress;
62
+
63
+ /// Whether the clip is currently playing
64
+ bool get isPlaying => state == ClipState.generatedAndPlaying;
65
+
66
+ /// Whether the clip failed to generate
67
+ bool get hasFailed => state == ClipState.failedToGenerate;
68
+
69
+ /// Whether the clip has been played
70
+ bool get hasPlayed => state == ClipState.generatedAndPlayed;
71
+
72
+ /// Whether the clip can be retried
73
+ bool get canRetry => retryCount < maxRetries;
74
+
75
+ /// Duration of the generation process
76
+ Duration? get generationDuration {
77
+ if (generationStartTime == null) return null;
78
+ if (isGenerating) {
79
+ return DateTime.now().difference(generationStartTime!);
80
+ }
81
+ if (isReady || isPlaying || hasPlayed) {
82
+ return generationEndTime?.difference(generationStartTime!);
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /// Duration of playback
88
+ Duration? get playbackDuration {
89
+ if (playStartTime == null) return null;
90
+ return DateTime.now().difference(playStartTime!);
91
+ }
92
+
93
+ /// Mark the clip as playing
94
+ void startPlaying() {
95
+ if (state == ClipState.generatedAndReadyToPlay) {
96
+ state = ClipState.generatedAndPlaying;
97
+ playStartTime = DateTime.now();
98
+ }
99
+ }
100
+
101
+ /// Mark the clip as played
102
+ void finishPlaying() {
103
+ if (state == ClipState.generatedAndPlaying) {
104
+ state = ClipState.generatedAndPlayed;
105
+ }
106
+ }
107
+
108
+ /// Mark the clip as generated
109
+ void completeGeneration() {
110
+ if (state == ClipState.generationInProgress) {
111
+ generationEndTime = DateTime.now();
112
+ state = ClipState.generatedAndReadyToPlay;
113
+ }
114
+ }
115
+
116
+ @override
117
+ String toString() => 'VideoClip(seed: $seed, state: $state, retryCount: $retryCount)';
118
+ }
lib/services/clip_queue_manager.dart CHANGED
@@ -1,719 +1,5 @@
1
  // lib/services/clip_queue_manager.dart
 
 
2
 
3
- import 'dart:async';
4
- import 'package:aitube2/config/config.dart';
5
- import 'package:flutter/foundation.dart';
6
- import 'package:collection/collection.dart';
7
- import 'package:uuid/uuid.dart';
8
- import '../models/video_result.dart';
9
- import '../services/websocket_api_service.dart';
10
- import '../services/cache_service.dart';
11
- import '../utils/seed.dart';
12
-
13
- enum ClipState {
14
- generationPending,
15
- generationInProgress,
16
- generatedAndReadyToPlay,
17
- generatedAndPlaying,
18
- failedToGenerate,
19
- generatedAndPlayed
20
- }
21
-
22
- class VideoClip {
23
- final String id;
24
- final String prompt;
25
- final int seed;
26
- ClipState state;
27
- String? base64Data;
28
- Timer? retryTimer;
29
- Completer<void>? generationCompleter;
30
- DateTime? generationStartTime;
31
- DateTime? generationEndTime;
32
- DateTime? playStartTime;
33
- int retryCount = 0;
34
- static const maxRetries = 3;
35
-
36
- VideoClip({
37
- String? id,
38
- required this.prompt,
39
- required this.seed,
40
- this.state = ClipState.generationPending,
41
- this.base64Data,
42
- }): id = id ?? const Uuid().v4();
43
-
44
- bool get isReady => state == ClipState.generatedAndReadyToPlay;
45
- bool get isPending => state == ClipState.generationPending;
46
- bool get isGenerating => state == ClipState.generationInProgress;
47
- bool get isPlaying => state == ClipState.generatedAndPlaying;
48
- bool get hasFailed => state == ClipState.failedToGenerate;
49
- bool get hasPlayed => state == ClipState.generatedAndPlayed;
50
- bool get canRetry => retryCount < maxRetries;
51
-
52
- Duration? get generationDuration {
53
- if (generationStartTime == null) return null;
54
- if (isGenerating) {
55
- return DateTime.now().difference(generationStartTime!);
56
- }
57
- if (isReady || isPlaying || hasPlayed) {
58
- return generationEndTime?.difference(generationStartTime!);
59
- }
60
- return null;
61
- }
62
-
63
- Duration? get playbackDuration {
64
- if (playStartTime == null) return null;
65
- return DateTime.now().difference(playStartTime!);
66
- }
67
-
68
- void startPlaying() {
69
- if (state == ClipState.generatedAndReadyToPlay) {
70
- state = ClipState.generatedAndPlaying;
71
- playStartTime = DateTime.now();
72
- }
73
- }
74
-
75
- void finishPlaying() {
76
- if (state == ClipState.generatedAndPlaying) {
77
- state = ClipState.generatedAndPlayed;
78
- }
79
- }
80
-
81
- void completeGeneration() {
82
- if (state == ClipState.generationInProgress) {
83
- generationEndTime = DateTime.now();
84
- state = ClipState.generatedAndReadyToPlay;
85
- }
86
- }
87
-
88
- @override
89
- String toString() => 'VideoClip(seed: $seed, state: $state, retryCount: $retryCount)';
90
- }
91
-
92
- class ClipQueueManager {
93
- static const bool _showLogsInDebugMode = false;
94
- static const Duration retryDelay = Duration(seconds: 2);
95
- static const Duration clipTimeout = Duration(seconds: 90);
96
- static const Duration generationTimeout = Duration(seconds: 60);
97
-
98
- final VideoResult video;
99
- final WebSocketApiService _websocketService;
100
- final CacheService _cacheService;
101
- final void Function()? onQueueUpdated;
102
-
103
- final List<VideoClip> _clipBuffer = [];
104
- final List<VideoClip> _clipHistory = [];
105
- final _activeGenerations = <String>{};
106
- Timer? _bufferCheckTimer;
107
- bool _isDisposed = false;
108
-
109
- DateTime? _lastSuccessfulGeneration;
110
- final _generationTimes = <Duration>[];
111
- static const _maxStoredGenerationTimes = 10;
112
-
113
- final String videoId;
114
-
115
- DateTime? _lastStateLogTime;
116
- Map<String, dynamic>? _lastLoggedState;
117
-
118
- ClipQueueManager({
119
- required this.video,
120
- WebSocketApiService? websocketService,
121
- CacheService? cacheService,
122
- this.onQueueUpdated,
123
- }) : videoId = video.id,
124
- _websocketService = websocketService ?? WebSocketApiService(),
125
- _cacheService = cacheService ?? CacheService();
126
-
127
- bool get canStartNewGeneration =>
128
- _activeGenerations.length < Configuration.instance.renderQueueMaxConcurrentGenerations;
129
- int get pendingGenerations => _clipBuffer.where((c) => c.isPending).length;
130
- int get activeGenerations => _activeGenerations.length;
131
- VideoClip? get currentClip => _clipBuffer.firstWhereOrNull((c) => c.isReady || c.isPlaying);
132
- VideoClip? get nextReadyClip => _clipBuffer.where((c) => c.isReady && !c.isPlaying).firstOrNull;
133
- bool get hasReadyClips => _clipBuffer.any((c) => c.isReady);
134
- List<VideoClip> get clipBuffer => List.unmodifiable(_clipBuffer);
135
- List<VideoClip> get clipHistory => List.unmodifiable(_clipHistory);
136
-
137
- Future<void> initialize() async {
138
- if (_isDisposed) return;
139
-
140
- _logStateChange('initialize:start');
141
- _clipBuffer.clear();
142
-
143
- try {
144
- final bufferSize = Configuration.instance.renderQueueBufferSize;
145
- while (_clipBuffer.length < bufferSize) {
146
- if (_isDisposed) return;
147
-
148
- final newClip = VideoClip(
149
- prompt: "${video.title}\n${video.description}",
150
- seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
151
- );
152
- _clipBuffer.add(newClip);
153
- _logEvent('Added initial clip ${newClip.seed} to buffer');
154
- }
155
-
156
- if (_isDisposed) return;
157
-
158
- _startBufferCheck();
159
- await _fillBuffer();
160
- _logEvent('Initialization complete. Buffer size: ${_clipBuffer.length}');
161
- printQueueState();
162
- } catch (e) {
163
- _logEvent('Initialization error: $e');
164
- rethrow;
165
- }
166
-
167
- _logStateChange('initialize:complete');
168
- }
169
-
170
- void _startBufferCheck() {
171
- _bufferCheckTimer?.cancel();
172
- _bufferCheckTimer = Timer.periodic(
173
- const Duration(milliseconds: 200),
174
- (timer) {
175
- if (!_isDisposed) {
176
- _fillBuffer();
177
- }
178
- },
179
- );
180
- _logEvent('Started buffer check timer');
181
- }
182
-
183
- void markClipAsPlayed(String clipId) {
184
- _logStateChange('markAsPlayed:start');
185
- final playingClip = _clipBuffer.firstWhereOrNull((c) => c.id == clipId);
186
- if (playingClip != null) {
187
- playingClip.finishPlaying();
188
-
189
- final cacheKey = "${video.id}_${playingClip.seed}";
190
- unawaited(_cacheService.delete(cacheKey).catchError((e) {
191
- debugPrint('Failed to remove clip ${playingClip.seed} from cache: $e');
192
- }));
193
-
194
- _reorderBufferByPriority();
195
- _fillBuffer();
196
- onQueueUpdated?.call();
197
- }
198
- _logStateChange('markAsPlayed:complete');
199
- }
200
-
201
- Future<void> _fillBuffer() async {
202
- if (_isDisposed) return;
203
-
204
- // First ensure we have the correct buffer size
205
- while (_clipBuffer.length < Configuration.instance.renderQueueBufferSize) {
206
- final newClip = VideoClip(
207
- prompt: "${video.title}\n${video.description}",
208
- seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
209
- );
210
- _clipBuffer.add(newClip);
211
- _logEvent('Added new clip ${newClip.seed} to maintain buffer size');
212
- }
213
-
214
- // Process played clips first
215
- final playedClips = _clipBuffer.where((clip) => clip.hasPlayed).toList();
216
- if (playedClips.isNotEmpty) {
217
- _processPlayedClips(playedClips);
218
- }
219
-
220
- // Remove failed clips and replace them
221
- final failedClips = _clipBuffer.where((clip) => clip.hasFailed && !clip.canRetry).toList();
222
- for (final clip in failedClips) {
223
- _clipBuffer.remove(clip);
224
- final newClip = VideoClip(
225
- prompt: "${video.title}\n${video.description}",
226
- seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
227
- );
228
- _clipBuffer.add(newClip);
229
- }
230
-
231
- // Clean up stuck generations
232
- _checkForStuckGenerations();
233
-
234
- // Get pending clips that aren't being generated
235
- final pendingClips = _clipBuffer
236
- .where((clip) => clip.isPending && !_activeGenerations.contains(clip.seed.toString()))
237
- .toList();
238
-
239
- // Calculate available generation slots
240
- final availableSlots = Configuration.instance.renderQueueMaxConcurrentGenerations - _activeGenerations.length;
241
-
242
- if (availableSlots > 0 && pendingClips.isNotEmpty) {
243
- final clipsToGenerate = pendingClips.take(availableSlots).toList();
244
- _logEvent('Starting ${clipsToGenerate.length} parallel generations');
245
-
246
- final generationFutures = clipsToGenerate.map((clip) =>
247
- _generateClip(clip).catchError((e) {
248
- debugPrint('Generation failed for clip ${clip.seed}: $e');
249
- return null;
250
- })
251
- ).toList();
252
-
253
- unawaited(
254
- Future.wait(generationFutures, eagerError: false).then((_) {
255
- if (!_isDisposed) {
256
- onQueueUpdated?.call();
257
- // Recursively ensure buffer stays full
258
- _fillBuffer();
259
- }
260
- })
261
- );
262
- }
263
-
264
- onQueueUpdated?.call();
265
-
266
- _logStateChange('fillBuffer:complete');
267
- }
268
-
269
- void _reorderBufferByPriority() {
270
- // First, extract all clips that aren't played
271
- final activeClips = _clipBuffer.where((c) => !c.hasPlayed).toList();
272
-
273
- // Sort clips by priority:
274
- // 1. Currently playing clips stay at their position
275
- // 2. Ready clips move to the front (right after playing clips)
276
- // 3. In-progress generations
277
- // 4. Pending generations
278
- // 5. Failed generations
279
- activeClips.sort((a, b) {
280
- // Helper function to get priority value for a state
281
- int getPriority(ClipState state) {
282
- switch (state) {
283
- case ClipState.generatedAndPlaying:
284
- return 0;
285
- case ClipState.generatedAndReadyToPlay:
286
- return 1;
287
- case ClipState.generationInProgress:
288
- return 2;
289
- case ClipState.generationPending:
290
- return 3;
291
- case ClipState.failedToGenerate:
292
- return 4;
293
- case ClipState.generatedAndPlayed:
294
- return 5;
295
- }
296
- }
297
-
298
- // Compare priorities
299
- final priorityA = getPriority(a.state);
300
- final priorityB = getPriority(b.state);
301
-
302
- if (priorityA != priorityB) {
303
- return priorityA.compareTo(priorityB);
304
- }
305
-
306
- // If same priority, maintain relative order by keeping original indices
307
- return _clipBuffer.indexOf(a).compareTo(_clipBuffer.indexOf(b));
308
- });
309
-
310
- // Clear and refill the buffer with the sorted clips
311
- _clipBuffer.clear();
312
- _clipBuffer.addAll(activeClips);
313
- }
314
-
315
- void _processPlayedClips(List<VideoClip> playedClips) {
316
- for (final clip in playedClips) {
317
- _clipBuffer.remove(clip);
318
- _clipHistory.add(clip);
319
-
320
- // Add a new pending clip
321
- final newClip = VideoClip(
322
- prompt: "${video.title}\n${video.description}",
323
- seed: video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed(),
324
- );
325
- _clipBuffer.add(newClip);
326
- _logEvent('Replaced played clip ${clip.seed} with new clip ${newClip.seed}');
327
- }
328
-
329
- // Immediately trigger buffer fill to start generating new clips
330
- _fillBuffer();
331
- }
332
-
333
- void _checkForStuckGenerations() {
334
- final now = DateTime.now();
335
- var hadStuckGenerations = false;
336
-
337
- for (final clip in _clipBuffer) {
338
- if (clip.isGenerating &&
339
- clip.generationStartTime != null &&
340
- now.difference(clip.generationStartTime!) > clipTimeout) {
341
- hadStuckGenerations = true;
342
- _handleStuckGeneration(clip);
343
- }
344
- }
345
-
346
- if (hadStuckGenerations) {
347
- _logEvent('Cleaned up stuck generations. Active: ${_activeGenerations.length}');
348
- }
349
- }
350
-
351
- void _handleStuckGeneration(VideoClip clip) {
352
- _logEvent('Found stuck generation for clip ${clip.seed}');
353
-
354
- if (_activeGenerations.contains(clip.seed.toString())) {
355
- _activeGenerations.remove(clip.seed.toString());
356
- }
357
-
358
- clip.state = ClipState.failedToGenerate;
359
-
360
- if (clip.canRetry) {
361
- _scheduleRetry(clip);
362
- }
363
- }
364
-
365
- // Also reorder after retries
366
- void _scheduleRetry(VideoClip clip) {
367
- clip.retryTimer?.cancel();
368
- clip.retryTimer = Timer(retryDelay, () {
369
- if (!_isDisposed && clip.hasFailed) {
370
- _logEvent('Retrying clip ${clip.seed} (attempt ${clip.retryCount + 1}/${VideoClip.maxRetries})');
371
- clip.state = ClipState.generationPending;
372
- clip.generationCompleter = null;
373
- clip.generationStartTime = null;
374
- _reorderBufferByPriority(); // Add reordering here
375
- onQueueUpdated?.call();
376
- _fillBuffer();
377
- }
378
- });
379
- }
380
-
381
- Future<void> _generateClip(VideoClip clip) async {
382
- if (clip.isGenerating || clip.isReady || _isDisposed || !canStartNewGeneration) {
383
- return;
384
- }
385
-
386
- final clipSeed = clip.seed.toString();
387
- if (_activeGenerations.contains(clipSeed)) {
388
- _logEvent('Clip $clipSeed already generating');
389
- return;
390
- }
391
-
392
- _activeGenerations.add(clipSeed);
393
- clip.state = ClipState.generationInProgress;
394
- clip.generationCompleter = Completer<void>();
395
- clip.generationStartTime = DateTime.now();
396
-
397
- try {
398
- final cacheKey = "${video.id}_${clip.seed}";
399
- String? videoData;
400
-
401
- // Check if we're disposed before proceeding
402
- if (_isDisposed) {
403
- _logEvent('Cancelled generation of clip $clipSeed - manager disposed');
404
- return;
405
- }
406
-
407
- // Try cache first
408
- try {
409
- videoData = await _cacheService.getVideoData(cacheKey);
410
- } catch (e) {
411
- if (_isDisposed) return; // Check disposed state after each await
412
- debugPrint('Cache error for clip ${clip.seed}: $e');
413
- }
414
-
415
- if (videoData != null && !_isDisposed) {
416
- await _handleSuccessfulGeneration(clip, videoData, cacheKey);
417
- return;
418
- }
419
-
420
- if (_isDisposed) {
421
- _logEvent('Cancelled generation of clip $clipSeed - manager disposed after cache check');
422
- return;
423
- }
424
-
425
- // Generate new video with timeout
426
- videoData = await _websocketService.generateVideo(
427
- video,
428
- seed: clip.seed,
429
- ).timeout(generationTimeout);
430
-
431
- if (!_isDisposed) {
432
- await _handleSuccessfulGeneration(clip, videoData, cacheKey);
433
- }
434
-
435
- } catch (e) {
436
- if (!_isDisposed) {
437
- _handleFailedGeneration(clip, e);
438
- }
439
- } finally {
440
- _cleanupGeneration(clip);
441
- }
442
- }
443
-
444
- Future<void> _handleSuccessfulGeneration(
445
- VideoClip clip,
446
- String videoData,
447
- String cacheKey,
448
- ) async {
449
- if (_isDisposed) return;
450
-
451
- clip.base64Data = videoData;
452
- clip.completeGeneration();
453
-
454
- // Only complete the completer if it exists and isn't already completed
455
- if (clip.generationCompleter != null && !clip.generationCompleter!.isCompleted) {
456
- clip.generationCompleter!.complete();
457
- }
458
-
459
- // Cache only if the clip isn't already played
460
- if (!clip.hasPlayed) {
461
- unawaited(_cacheService.cacheVideoData(cacheKey, videoData).catchError((e) {
462
- debugPrint('Failed to cache clip ${clip.seed}: $e');
463
- }));
464
- }
465
-
466
- // Reorder the buffer to prioritize this newly ready clip
467
- _reorderBufferByPriority();
468
-
469
- _updateGenerationStats(clip);
470
- onQueueUpdated?.call();
471
- }
472
-
473
-
474
- void _handleFailedGeneration(VideoClip clip, dynamic error) {
475
- if (_isDisposed) return;
476
- _logStateChange('generation:failed:start');
477
- clip.state = ClipState.failedToGenerate;
478
- clip.retryCount++;
479
-
480
- // Only complete with error if the completer exists and isn't completed
481
- if (clip.generationCompleter != null && !clip.generationCompleter!.isCompleted) {
482
- clip.generationCompleter!.completeError(error);
483
- }
484
-
485
- if (clip.canRetry) {
486
- _scheduleRetry(clip);
487
- }
488
- _logStateChange('generation:failed:complete');
489
- }
490
-
491
- void _cleanupGeneration(VideoClip clip) {
492
- if (!_isDisposed) {
493
- _activeGenerations.remove(clip.seed.toString());
494
- onQueueUpdated?.call();
495
- _fillBuffer();
496
- }
497
- }
498
-
499
- void _updateGenerationStats(VideoClip clip) {
500
- if (clip.generationStartTime != null) {
501
- final duration = DateTime.now().difference(clip.generationStartTime!);
502
- _generationTimes.add(duration);
503
- if (_generationTimes.length > _maxStoredGenerationTimes) {
504
- _generationTimes.removeAt(0);
505
- }
506
- _lastSuccessfulGeneration = DateTime.now();
507
- }
508
- }
509
-
510
- Duration? _getAverageGenerationTime() {
511
- if (_generationTimes.isEmpty) return null;
512
- final totalMs = _generationTimes.fold<int>(
513
- 0,
514
- (sum, duration) => sum + duration.inMilliseconds
515
- );
516
- return Duration(milliseconds: totalMs ~/ _generationTimes.length);
517
- }
518
-
519
- void markCurrentClipAsPlayed() {
520
- _logStateChange('markAsPlayed:start');
521
- final playingClip = _clipBuffer.firstWhereOrNull((c) => c.isPlaying);
522
- if (playingClip != null) {
523
- playingClip.finishPlaying();
524
-
525
- // Remove from cache when played
526
- final cacheKey = "${video.id}_${playingClip.seed}";
527
- unawaited(_cacheService.delete(cacheKey).catchError((e) {
528
- debugPrint('Failed to remove clip ${playingClip.seed} from cache: $e');
529
- }));
530
-
531
- _reorderBufferByPriority();
532
- _fillBuffer();
533
- onQueueUpdated?.call();
534
- }
535
- _logStateChange('markAsPlayed:complete');
536
- }
537
-
538
- void startPlayingClip(VideoClip clip) {
539
- _logStateChange('startPlaying:start');
540
- if (clip.isReady) {
541
- clip.startPlaying();
542
- onQueueUpdated?.call();
543
- }
544
- _logStateChange('startPlaying:complete');
545
- }
546
-
547
- void fillBuffer() {
548
- _logEvent('Manual buffer fill requested');
549
- _fillBuffer();
550
- }
551
-
552
- void printQueueState() {
553
- final ready = _clipBuffer.where((c) => c.isReady).length;
554
- final playing = _clipBuffer.where((c) => c.isPlaying).length;
555
- final generating = _activeGenerations.length;
556
- final pending = pendingGenerations;
557
- final failed = _clipBuffer.where((c) => c.hasFailed).length;
558
-
559
- _logEvent('\nQueue State:');
560
- _logEvent('Buffer size: ${_clipBuffer.length}');
561
- _logEvent('Ready: $ready, Playing: $playing, Generating: $generating, Pending: $pending, Failed: $failed');
562
- _logEvent('History size: ${_clipHistory.length}');
563
-
564
- for (var i = 0; i < _clipBuffer.length; i++) {
565
- final clip = _clipBuffer[i];
566
- final genDuration = clip.generationDuration;
567
- final playDuration = clip.playbackDuration;
568
- _logEvent('Clip $i: seed=${clip.seed}, state=${clip.state}, '
569
- 'retries=${clip.retryCount}, generation time=${genDuration?.inSeconds}s'
570
- '${playDuration != null ? ", playing for ${playDuration.inSeconds}s" : ""}');
571
- }
572
- }
573
-
574
- Map<String, dynamic> getBufferStats() {
575
- final averageGeneration = _getAverageGenerationTime();
576
- return {
577
- 'bufferSize': _clipBuffer.length,
578
- 'historySize': _clipHistory.length,
579
- 'activeGenerations': _activeGenerations.length,
580
- 'pendingClips': pendingGenerations,
581
- 'readyClips': _clipBuffer.where((c) => c.isReady).length,
582
- 'failedClips': _clipBuffer.where((c) => c.hasFailed).length,
583
- 'lastSuccessfulGeneration': _lastSuccessfulGeneration?.toString(),
584
- 'averageGenerationTime': averageGeneration?.toString(),
585
- 'clipStates': _clipBuffer.map((c) => c.state.toString()).toList(),
586
- };
587
- }
588
-
589
- void _logEvent(String message) {
590
- if (_showLogsInDebugMode && kDebugMode) {
591
- debugPrint('ClipQueue: $message');
592
- }
593
- }
594
-
595
- void _logGenerationStatus() {
596
- final pending = _clipBuffer.where((c) => c.isPending).length;
597
- final generating = _activeGenerations.length;
598
- final ready = _clipBuffer.where((c) => c.isReady).length;
599
- final playing = _clipBuffer.where((c) => c.isPlaying).length;
600
-
601
- _logEvent('''
602
- Buffer Status:
603
- - Pending: $pending
604
- - Generating: $generating
605
- - Ready: $ready
606
- - Playing: $playing
607
- - Active generations: ${_activeGenerations.join(', ')}
608
- ''');
609
- }
610
-
611
- void _logStateChange(String trigger) {
612
- if (_isDisposed) return;
613
-
614
- final currentState = {
615
- 'readyClips': _clipBuffer.where((c) => c.isReady).length,
616
- 'playingClips': _clipBuffer.where((c) => c.isPlaying).length,
617
- 'generatingClips': _activeGenerations.length,
618
- 'pendingClips': pendingGenerations,
619
- 'failedClips': _clipBuffer.where((c) => c.hasFailed).length,
620
- 'clipStates': _clipBuffer.map((c) => {
621
- 'seed': c.seed,
622
- 'state': c.state.toString(),
623
- 'retryCount': c.retryCount,
624
- 'genDuration': c.generationDuration?.inSeconds,
625
- 'playDuration': c.playbackDuration?.inSeconds,
626
- }).toList(),
627
- 'activeGenerations': List<String>.from(_activeGenerations),
628
- 'historySize': _clipHistory.length,
629
- };
630
-
631
- // Only log if state has changed
632
- if (_lastLoggedState == null ||
633
- !_areStatesEqual(_lastLoggedState!, currentState) ||
634
- _shouldLogDueToTimeout()) {
635
-
636
- debugPrint('\n=== Queue State Change [$trigger] ===');
637
- debugPrint('Ready: ${currentState['readyClips']}');
638
- debugPrint('Playing: ${currentState['playingClips']}');
639
- debugPrint('Generating: ${currentState['generatingClips']}');
640
-
641
- /*
642
- debugPrint('Pending: ${currentState['pendingClips']}');
643
- debugPrint('Failed: ${currentState['failedClips']}');
644
- debugPrint('History: ${currentState['historySize']}');
645
-
646
- debugPrint('\nClip Details:');
647
- final clipStates = currentState['clipStates'] as List<Map<String, dynamic>>;
648
- for (var clipState in clipStates) {
649
- debugPrint('Clip ${clipState['seed']}: ${clipState['state']} '
650
- '(retries: ${clipState['retryCount']}, '
651
- 'gen: ${clipState['genDuration']}s, '
652
- 'play: ${clipState['playDuration']}s)');
653
- }
654
-
655
- final activeGenerations = currentState['activeGenerations'] as List<String>;
656
- if (activeGenerations.isNotEmpty) {
657
- debugPrint('\nActive Generations: ${activeGenerations.join(', ')}');
658
- }
659
-
660
- debugPrint('=====================================\n');
661
-
662
- */
663
-
664
- _lastLoggedState = currentState;
665
- _lastStateLogTime = DateTime.now();
666
- }
667
- }
668
-
669
- bool _areStatesEqual(Map<String, dynamic> state1, Map<String, dynamic> state2) {
670
- return state1['readyClips'] == state2['readyClips'] &&
671
- state1['playingClips'] == state2['playingClips'] &&
672
- state1['generatingClips'] == state2['generatingClips'] &&
673
- state1['pendingClips'] == state2['pendingClips'] &&
674
- state1['failedClips'] == state2['failedClips'] &&
675
- state1['historySize'] == state2['historySize'] &&
676
- const ListEquality().equals(
677
- state1['activeGenerations'] as List,
678
- state2['activeGenerations'] as List
679
- );
680
- }
681
-
682
- bool _shouldLogDueToTimeout() {
683
- if (_lastStateLogTime == null) return true;
684
- // Force log every 30 seconds even if state hasn't changed
685
- return DateTime.now().difference(_lastStateLogTime!) > const Duration(seconds: 30);
686
- }
687
-
688
- Future<void> dispose() async {
689
- debugPrint('ClipQueueManager: Starting disposal for video $videoId');
690
- _isDisposed = true;
691
-
692
- // Cancel all timers first
693
- _bufferCheckTimer?.cancel();
694
-
695
- // Complete any pending generation completers
696
- for (var clip in _clipBuffer) {
697
- clip.retryTimer?.cancel();
698
-
699
- if (clip.isGenerating &&
700
- clip.generationCompleter != null &&
701
- !clip.generationCompleter!.isCompleted) {
702
- // Don't throw an error, just complete normally
703
- clip.generationCompleter!.complete();
704
- }
705
- }
706
-
707
- // Cancel any pending requests for this video
708
- if (videoId.isNotEmpty) {
709
- _websocketService.cancelRequestsForVideo(videoId);
710
- }
711
-
712
- // Clear all collections
713
- _clipBuffer.clear();
714
- _clipHistory.clear();
715
- _activeGenerations.clear();
716
-
717
- debugPrint('ClipQueueManager: Completed disposal for video $videoId');
718
- }
719
- }
 
1
  // lib/services/clip_queue_manager.dart
2
+ // This file is now a re-export of the refactored clip queue module
3
+ // It maintains backward compatibility with existing imports
4
 
5
+ export 'clip_queue/index.dart';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/services/html_stub.dart CHANGED
@@ -50,9 +50,7 @@ class Document {
50
  }
51
 
52
  class History {
53
- void pushState(dynamic data, String title, String url) {
54
- // No-op for non-web platforms
55
- }
56
  }
57
 
58
  class Location {
 
50
  }
51
 
52
  class History {
53
+ void pushState(dynamic data, String title, String url) {}
 
 
54
  }
55
 
56
  class Location {
lib/services/websocket_api_service.dart CHANGED
@@ -143,8 +143,15 @@ class WebSocketApiService {
143
  if (_initialized) return;
144
 
145
  try {
 
146
  await connect();
147
 
 
 
 
 
 
 
148
  try {
149
  // Request user role after connection
150
  await _requestUserRole();
@@ -159,6 +166,7 @@ class WebSocketApiService {
159
  }
160
 
161
  _initialized = true;
 
162
  } catch (e) {
163
  debugPrint('Failed to initialize WebSocketApiService: $e');
164
  rethrow;
@@ -376,13 +384,34 @@ class WebSocketApiService {
376
 
377
  // Prevent multiple simultaneous connection attempts
378
  return _connectionLock.synchronized(() async {
379
- if (_status == ConnectionStatus.connecting ||
380
- _status == ConnectionStatus.connected) {
381
  return;
382
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
  try {
385
  _setStatus(ConnectionStatus.connecting);
 
386
 
387
  // Close existing channel if any
388
  await _channel?.sink.close();
@@ -461,6 +490,7 @@ class WebSocketApiService {
461
  }
462
 
463
  try {
 
464
  _channel = WebSocketChannel.connect(connectionUrl);
465
  } catch (e) {
466
  debugPrint('WebSocketApiService: Failed to create WebSocket channel: $e');
@@ -470,19 +500,23 @@ class WebSocketApiService {
470
  debugPrint('WebSocketApiService: Retrying connection without API key');
471
  _channel = WebSocketChannel.connect(baseUrl);
472
  } else {
 
473
  rethrow;
474
  }
475
  }
476
 
477
  // Wait for connection with proper error handling
478
  try {
 
479
  await _channel!.ready.timeout(
480
  const Duration(seconds: 10),
481
  onTimeout: () {
 
482
  _setStatus(ConnectionStatus.error);
483
  throw TimeoutException('Connection timeout');
484
  },
485
  );
 
486
  } catch (e) {
487
  debugPrint('WebSocketApiService: Connection failed: $e');
488
 
@@ -587,14 +621,17 @@ class WebSocketApiService {
587
  }
588
 
589
  debugPrint('WebSocketApiService: Fallback connection also failed: $retryError');
 
590
  rethrow;
591
  }
592
  } else {
 
593
  rethrow;
594
  }
595
  }
596
 
597
  // Setup stream listener with error handling
 
598
  _channel!.stream.listen(
599
  _handleMessage,
600
  onError: _handleError,
@@ -608,6 +645,7 @@ class WebSocketApiService {
608
  _startConnectionHeartbeat();
609
  }
610
 
 
611
  _setStatus(ConnectionStatus.connected);
612
  _reconnectAttempts = 0;
613
 
@@ -621,6 +659,8 @@ class WebSocketApiService {
621
  _isDeviceLimitExceeded = false;
622
  _deviceLimitController.add(false);
623
  }
 
 
624
  } catch (e) {
625
  // Check if the error indicates maintenance mode
626
  if (e.toString().contains('maintenance')) {
@@ -777,11 +817,11 @@ class WebSocketApiService {
777
  String get statusMessage {
778
  switch (_status) {
779
  case ConnectionStatus.disconnected:
780
- return 'Disconnected ';
781
  case ConnectionStatus.connecting:
782
- return 'Connecting...';
783
  case ConnectionStatus.connected:
784
- return 'Connected';
785
  case ConnectionStatus.reconnecting:
786
  return 'Connection lost. Attempting to reconnect (${_reconnectAttempts + 1}/$_maxReconnectAttempts)...';
787
  case ConnectionStatus.error:
@@ -795,9 +835,14 @@ class WebSocketApiService {
795
  if (_status != newStatus) {
796
  _status = newStatus;
797
  _statusController.add(newStatus);
798
- if (kDebugMode) {
799
- print('WebSocket Status: $statusMessage');
800
- }
 
 
 
 
 
801
  }
802
  }
803
 
@@ -875,6 +920,12 @@ class WebSocketApiService {
875
  } else if (action == 'join_chat') {
876
  debugPrint('WebSocketApiService: Processing join chat response');
877
  _pendingRequests[requestId]!.complete(data);
 
 
 
 
 
 
878
  } else {
879
  // debugPrint('WebSocketApiService: Processing generic response');
880
  _pendingRequests[requestId]!.complete(data);
@@ -1135,51 +1186,6 @@ class WebSocketApiService {
1135
  return response['caption'] as String;
1136
  }
1137
 
1138
- Future<String> generateThumbnail(String title, String description) async {
1139
- const int maxRetries = 3;
1140
- const Duration baseDelay = Duration(seconds: 2);
1141
-
1142
- for (int attempt = 0; attempt < maxRetries; attempt++) {
1143
- try {
1144
- debugPrint('Attempting to generate thumbnail (attempt ${attempt + 1}/$maxRetries)');
1145
-
1146
- final response = await _sendRequest(
1147
- WebSocketRequest(
1148
- action: 'generate_thumbnail',
1149
- params: {
1150
- 'title': title,
1151
- 'description': "$description (attempt $attempt)",
1152
- 'attempt': attempt,
1153
- },
1154
- ),
1155
- timeout: const Duration(seconds: 60),
1156
- );
1157
-
1158
- if (response['success'] == true) {
1159
- debugPrint('Successfully generated thumbnail on attempt ${attempt + 1}');
1160
- return response['thumbnailUrl'] as String;
1161
- }
1162
-
1163
- throw Exception(response['error'] ?? 'Thumbnail generation failed');
1164
-
1165
- } catch (e) {
1166
- debugPrint('Error generating thumbnail (attempt ${attempt + 1}): $e');
1167
-
1168
- // If this was our last attempt, rethrow the error
1169
- if (attempt == maxRetries - 1) {
1170
- throw Exception('Failed to generate thumbnail after $maxRetries attempts: $e');
1171
- }
1172
-
1173
- // Exponential backoff for retries
1174
- final delay = baseDelay * (attempt + 1);
1175
- debugPrint('Waiting ${delay.inSeconds}s before retry...');
1176
- await Future.delayed(delay);
1177
- }
1178
- }
1179
-
1180
- // This shouldn't be reached due to the throw in the loop, but Dart requires it
1181
- throw Exception('Failed to generate thumbnail after $maxRetries attempts');
1182
- }
1183
 
1184
  // Additional utility methods
1185
  Future<void> waitForConnection() async {
 
143
  if (_initialized) return;
144
 
145
  try {
146
+ debugPrint('WebSocketApiService: Initializing and connecting...');
147
  await connect();
148
 
149
+ // Only continue if we're properly connected
150
+ if (_status != ConnectionStatus.connected) {
151
+ debugPrint('WebSocketApiService: Connection not established, status: $_status');
152
+ return;
153
+ }
154
+
155
  try {
156
  // Request user role after connection
157
  await _requestUserRole();
 
166
  }
167
 
168
  _initialized = true;
169
+ debugPrint('WebSocketApiService: Successfully initialized, status: $_status');
170
  } catch (e) {
171
  debugPrint('Failed to initialize WebSocketApiService: $e');
172
  rethrow;
 
384
 
385
  // Prevent multiple simultaneous connection attempts
386
  return _connectionLock.synchronized(() async {
387
+ if (_status == ConnectionStatus.connected) {
388
+ debugPrint('WebSocketApiService: Already connected, skipping connection attempt');
389
  return;
390
  }
391
+
392
+ if (_status == ConnectionStatus.connecting) {
393
+ debugPrint('WebSocketApiService: Connection already in progress, waiting...');
394
+
395
+ // Wait for a short time to see if connection completes
396
+ for (int i = 0; i < 10; i++) {
397
+ await Future.delayed(const Duration(milliseconds: 200));
398
+ if (_status == ConnectionStatus.connected) {
399
+ debugPrint('WebSocketApiService: Connection completed while waiting');
400
+ return;
401
+ }
402
+ if (_status == ConnectionStatus.error || _status == ConnectionStatus.maintenance) {
403
+ debugPrint('WebSocketApiService: Connection failed while waiting with status: $_status');
404
+ throw Exception('Connection attempt failed with status: $_status');
405
+ }
406
+ }
407
+
408
+ // If still connecting after waiting, we'll try again
409
+ debugPrint('WebSocketApiService: Previous connection attempt timed out, trying again');
410
+ }
411
 
412
  try {
413
  _setStatus(ConnectionStatus.connecting);
414
+ debugPrint('WebSocketApiService: Setting status to CONNECTING');
415
 
416
  // Close existing channel if any
417
  await _channel?.sink.close();
 
490
  }
491
 
492
  try {
493
+ debugPrint('WebSocketApiService: Creating WebSocket channel...');
494
  _channel = WebSocketChannel.connect(connectionUrl);
495
  } catch (e) {
496
  debugPrint('WebSocketApiService: Failed to create WebSocket channel: $e');
 
500
  debugPrint('WebSocketApiService: Retrying connection without API key');
501
  _channel = WebSocketChannel.connect(baseUrl);
502
  } else {
503
+ _setStatus(ConnectionStatus.error);
504
  rethrow;
505
  }
506
  }
507
 
508
  // Wait for connection with proper error handling
509
  try {
510
+ debugPrint('WebSocketApiService: Waiting for connection ready signal...');
511
  await _channel!.ready.timeout(
512
  const Duration(seconds: 10),
513
  onTimeout: () {
514
+ debugPrint('WebSocketApiService: Connection timeout');
515
  _setStatus(ConnectionStatus.error);
516
  throw TimeoutException('Connection timeout');
517
  },
518
  );
519
+ debugPrint('WebSocketApiService: Connection ready signal received!');
520
  } catch (e) {
521
  debugPrint('WebSocketApiService: Connection failed: $e');
522
 
 
621
  }
622
 
623
  debugPrint('WebSocketApiService: Fallback connection also failed: $retryError');
624
+ _setStatus(ConnectionStatus.error);
625
  rethrow;
626
  }
627
  } else {
628
+ _setStatus(ConnectionStatus.error);
629
  rethrow;
630
  }
631
  }
632
 
633
  // Setup stream listener with error handling
634
+ debugPrint('WebSocketApiService: Setting up stream listeners...');
635
  _channel!.stream.listen(
636
  _handleMessage,
637
  onError: _handleError,
 
645
  _startConnectionHeartbeat();
646
  }
647
 
648
+ debugPrint('WebSocketApiService: Setting status to CONNECTED');
649
  _setStatus(ConnectionStatus.connected);
650
  _reconnectAttempts = 0;
651
 
 
659
  _isDeviceLimitExceeded = false;
660
  _deviceLimitController.add(false);
661
  }
662
+
663
+ debugPrint('WebSocketApiService: Connection completed successfully');
664
  } catch (e) {
665
  // Check if the error indicates maintenance mode
666
  if (e.toString().contains('maintenance')) {
 
817
  String get statusMessage {
818
  switch (_status) {
819
  case ConnectionStatus.disconnected:
820
+ return 'Disconnected';
821
  case ConnectionStatus.connecting:
822
+ return 'Connected...'; // Make connecting status appear like connected to show green
823
  case ConnectionStatus.connected:
824
+ return _userRole == 'anon' ? 'Connected as anon' : 'Connected as $_userRole';
825
  case ConnectionStatus.reconnecting:
826
  return 'Connection lost. Attempting to reconnect (${_reconnectAttempts + 1}/$_maxReconnectAttempts)...';
827
  case ConnectionStatus.error:
 
835
  if (_status != newStatus) {
836
  _status = newStatus;
837
  _statusController.add(newStatus);
838
+
839
+ // Force an additional status emission for UI updates
840
+ // This ensures Flutter's reactive system picks up the change
841
+ Future.microtask(() {
842
+ if (!_statusController.isClosed && _status == newStatus) {
843
+ _statusController.add(newStatus);
844
+ }
845
+ });
846
  }
847
  }
848
 
 
920
  } else if (action == 'join_chat') {
921
  debugPrint('WebSocketApiService: Processing join chat response');
922
  _pendingRequests[requestId]!.complete(data);
923
+ } else if (action == 'search' && data['success'] == true && data['result'] != null) {
924
+ final result = VideoResult.fromJson(data['result'] as Map<String, dynamic>);
925
+
926
+ _pendingRequests[requestId]!.complete(data);
927
+
928
+ _searchController.add(result);
929
  } else {
930
  // debugPrint('WebSocketApiService: Processing generic response');
931
  _pendingRequests[requestId]!.complete(data);
 
1186
  return response['caption'] as String;
1187
  }
1188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
 
1190
  // Additional utility methods
1191
  Future<void> waitForConnection() async {
lib/services/websocket_core_interface.dart ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/services/websocket_core_interface.dart
2
+
3
+ /// This file provides a subset of WebSocket functionality needed for the nano player
4
+ /// It's a simplified version that avoids exposing the entire WebSocketApiService
5
+
6
+ /// WebSocketRequest model
7
+ class WebSocketRequest {
8
+ /// Request identifier
9
+ final String requestId;
10
+
11
+ /// Action to perform
12
+ final String action;
13
+
14
+ /// Parameters for the action
15
+ final Map<String, dynamic> params;
16
+
17
+ /// Constructor
18
+ WebSocketRequest({
19
+ required this.requestId,
20
+ required this.action,
21
+ required this.params,
22
+ });
23
+
24
+ /// Convert to JSON
25
+ Map<String, dynamic> toJson() => {
26
+ 'requestId': requestId,
27
+ 'action': action,
28
+ ...params,
29
+ };
30
+ }
31
+
32
+ /// Extension methods for the WebSocketApiService
33
+ extension WebSocketApiServiceExtensions on dynamic {
34
+ /// Send a WebSocket request without waiting for a response
35
+ Future<void> sendRequestWithoutResponse(WebSocketRequest request) async {
36
+ // This method will be provided by the main WebSocketApiService class
37
+ // It's just a stub for compilation purposes
38
+ return Future.value();
39
+ }
40
+ }
lib/widgets/ai_content_disclaimer.dart CHANGED
@@ -1,4 +1,4 @@
1
- // lib/widgets/ai_content_disclaimer_widget.dart
2
 
3
  import 'package:flutter/material.dart';
4
  import 'package:google_fonts/google_fonts.dart';
@@ -6,17 +6,67 @@ import '../theme/colors.dart';
6
 
7
  class AiContentDisclaimer extends StatelessWidget {
8
  final bool isInteractive;
 
9
 
10
  const AiContentDisclaimer({
11
  super.key,
12
  this.isInteractive = false,
 
13
  });
14
 
15
  @override
16
  Widget build(BuildContext context) {
17
- // Get the text scaling factor
18
- final textScaler = MediaQuery.textScalerOf(context);
19
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  return Container(
21
  width: double.infinity,
22
  height: double.infinity,
 
1
+ // lib/widgets/ai_content_disclaimer.dart
2
 
3
  import 'package:flutter/material.dart';
4
  import 'package:google_fonts/google_fonts.dart';
 
6
 
7
  class AiContentDisclaimer extends StatelessWidget {
8
  final bool isInteractive;
9
+ final bool compact;
10
 
11
  const AiContentDisclaimer({
12
  super.key,
13
  this.isInteractive = false,
14
+ this.compact = false,
15
  });
16
 
17
  @override
18
  Widget build(BuildContext context) {
19
+ if (compact) {
20
+ return _buildCompactDisclaimer(context);
21
+ }
22
+ return _buildFullDisclaimer(context);
23
+ }
24
+
25
+ Widget _buildCompactDisclaimer(BuildContext context) {
26
+ return Container(
27
+ width: double.infinity,
28
+ height: double.infinity,
29
+ color: const Color(0xFF047857), // emerald-700
30
+ child: Center(
31
+ child: LayoutBuilder(
32
+ builder: (context, constraints) {
33
+ final baseSize = constraints.maxWidth / 20;
34
+ final iconSize = baseSize * 1.5;
35
+
36
+ return Column(
37
+ mainAxisAlignment: MainAxisAlignment.center,
38
+ children: [
39
+ Icon(
40
+ Icons.smart_toy_outlined,
41
+ color: Colors.white,
42
+ size: iconSize,
43
+ ),
44
+ const SizedBox(height: 8),
45
+ Text(
46
+ 'AI Content',
47
+ style: GoogleFonts.arimo(
48
+ fontSize: baseSize,
49
+ color: Colors.white,
50
+ fontWeight: FontWeight.w700,
51
+ letterSpacing: 1.0,
52
+ shadows: const [
53
+ Shadow(
54
+ offset: Offset(0, 1),
55
+ blurRadius: 2.0,
56
+ color: Color.fromRGBO(0, 0, 0, 0.3),
57
+ ),
58
+ ],
59
+ ),
60
+ ),
61
+ ],
62
+ );
63
+ },
64
+ ),
65
+ ),
66
+ );
67
+ }
68
+
69
+ Widget _buildFullDisclaimer(BuildContext context) {
70
  return Container(
71
  width: double.infinity,
72
  height: double.infinity,
lib/widgets/search_box.dart CHANGED
@@ -1,7 +1,6 @@
1
  // lib/widgets/search_box.dart
2
  import 'package:flutter/material.dart';
3
  import '../theme/colors.dart';
4
- import '../services/cache_service.dart';
5
 
6
  class SearchBox extends StatefulWidget {
7
  final TextEditingController controller;
@@ -25,136 +24,25 @@ class SearchBox extends StatefulWidget {
25
 
26
  class _SearchBoxState extends State<SearchBox> {
27
  final _focusNode = FocusNode();
28
- final _cacheService = CacheService();
29
- bool _showSuggestions = false;
30
- List<String> _suggestions = [];
31
- OverlayEntry? _overlayEntry;
32
- final _layerLink = LayerLink();
33
  bool _isComposing = false;
34
 
35
  @override
36
  void initState() {
37
  super.initState();
38
- _focusNode.addListener(_onFocusChanged);
39
  widget.controller.addListener(_onSearchTextChanged);
40
  }
41
 
42
- void _onFocusChanged() {
43
- if (_focusNode.hasFocus) {
44
- _showSuggestions = true;
45
- _updateSuggestions();
46
- } else {
47
- WidgetsBinding.instance.addPostFrameCallback((_) {
48
- _hideSuggestions();
49
- });
50
- }
51
- }
52
-
53
  void _onSearchTextChanged() {
54
  if (_focusNode.hasFocus) {
55
  setState(() {
56
  _isComposing = widget.controller.text.isNotEmpty;
57
  });
58
- _updateSuggestions();
59
- }
60
- }
61
-
62
- Future<void> _updateSuggestions() async {
63
- if (!mounted) return;
64
-
65
- final query = widget.controller.text.toLowerCase();
66
- if (query.isEmpty) {
67
- setState(() {
68
- _suggestions = [];
69
- _hideOverlay();
70
- });
71
- return;
72
  }
73
-
74
- try {
75
- final results = await _cacheService.getCachedSearchResults('');
76
- if (!mounted) return;
77
-
78
- setState(() {
79
- _suggestions = results
80
- .map((result) => result.title)
81
- .where((title) => title.toLowerCase().contains(query))
82
- .take(8)
83
- .toList();
84
-
85
- if (_suggestions.isNotEmpty && _focusNode.hasFocus) {
86
- _showOverlay();
87
- } else {
88
- _hideOverlay();
89
- }
90
- });
91
- } catch (e) {
92
- debugPrint('Error updating suggestions: $e');
93
- }
94
- }
95
-
96
- void _showOverlay() {
97
- _hideOverlay();
98
-
99
- final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
100
- if (renderBox == null) return;
101
-
102
- final size = renderBox.size;
103
- final offset = renderBox.localToGlobal(Offset.zero);
104
-
105
- _overlayEntry = OverlayEntry(
106
- builder: (context) => Positioned(
107
- left: offset.dx,
108
- top: offset.dy + size.height + 5.0,
109
- width: size.width,
110
- child: CompositedTransformFollower(
111
- link: _layerLink,
112
- showWhenUnlinked: false,
113
- offset: Offset(0.0, size.height + 5.0),
114
- child: Material(
115
- elevation: 4.0,
116
- color: AiTubeColors.surface,
117
- borderRadius: BorderRadius.circular(8),
118
- child: Column(
119
- mainAxisSize: MainAxisSize.min,
120
- children: _suggestions.map((suggestion) {
121
- return ListTile(
122
- title: Text(
123
- suggestion,
124
- style: const TextStyle(color: AiTubeColors.onSurface),
125
- ),
126
- onTap: () {
127
- widget.controller.text = suggestion;
128
- _hideSuggestions();
129
- _handleSubmitted(suggestion);
130
- },
131
- );
132
- }).toList(),
133
- ),
134
- ),
135
- ),
136
- ),
137
- );
138
-
139
- Overlay.of(context).insert(_overlayEntry!);
140
- }
141
-
142
- void _hideOverlay() {
143
- _overlayEntry?.remove();
144
- _overlayEntry = null;
145
- }
146
-
147
- void _hideSuggestions() {
148
- setState(() {
149
- _showSuggestions = false;
150
- _hideOverlay();
151
- });
152
  }
153
 
154
  void _handleSubmitted(String value) {
155
  final trimmedValue = value.trim();
156
  if (trimmedValue.isNotEmpty) {
157
- _hideSuggestions();
158
  FocusScope.of(context).unfocus();
159
  widget.onSearch(trimmedValue);
160
  // Reset _isComposing to ensure the field can be edited again
@@ -168,48 +56,45 @@ class _SearchBoxState extends State<SearchBox> {
168
  Widget build(BuildContext context) {
169
  return Material(
170
  color: Colors.transparent,
171
- child: CompositedTransformTarget(
172
- link: _layerLink,
173
- child: TextFormField(
174
- controller: widget.controller,
175
- focusNode: _focusNode,
176
- style: const TextStyle(color: AiTubeColors.onBackground),
177
- enabled: widget.enabled,
178
- textInputAction: TextInputAction.search,
179
- onFieldSubmitted: _handleSubmitted,
180
- onTapOutside: (_) {
181
- FocusScope.of(context).unfocus();
182
- },
183
- decoration: InputDecoration(
184
- hintText: 'Describe a video you want to generate...',
185
- hintStyle: const TextStyle(color: AiTubeColors.onSurfaceVariant),
186
- filled: true,
187
- fillColor: AiTubeColors.surface,
188
- border: OutlineInputBorder(
189
- borderRadius: BorderRadius.circular(24),
190
- borderSide: BorderSide.none,
191
- ),
192
- contentPadding: const EdgeInsets.symmetric(
193
- horizontal: 16,
194
- vertical: 12,
195
- ),
196
- suffixIcon: widget.isSearching
197
- ? IconButton(
198
- icon: const SizedBox(
199
- width: 20,
200
- height: 20,
201
- child: CircularProgressIndicator(strokeWidth: 2),
202
- ),
203
- onPressed: widget.onCancel,
204
- )
205
- : IconButton(
206
- icon: const Icon(
207
- Icons.search,
208
- color: AiTubeColors.onSurfaceVariant,
209
- ),
210
- onPressed: () => _handleSubmitted(widget.controller.text),
211
- ),
212
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  ),
214
  ),
215
  );
@@ -217,8 +102,6 @@ class _SearchBoxState extends State<SearchBox> {
217
 
218
  @override
219
  void dispose() {
220
- _hideOverlay();
221
- _focusNode.removeListener(_onFocusChanged);
222
  _focusNode.dispose();
223
  widget.controller.removeListener(_onSearchTextChanged);
224
  super.dispose();
 
1
  // lib/widgets/search_box.dart
2
  import 'package:flutter/material.dart';
3
  import '../theme/colors.dart';
 
4
 
5
  class SearchBox extends StatefulWidget {
6
  final TextEditingController controller;
 
24
 
25
  class _SearchBoxState extends State<SearchBox> {
26
  final _focusNode = FocusNode();
 
 
 
 
 
27
  bool _isComposing = false;
28
 
29
  @override
30
  void initState() {
31
  super.initState();
 
32
  widget.controller.addListener(_onSearchTextChanged);
33
  }
34
 
 
 
 
 
 
 
 
 
 
 
 
35
  void _onSearchTextChanged() {
36
  if (_focusNode.hasFocus) {
37
  setState(() {
38
  _isComposing = widget.controller.text.isNotEmpty;
39
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  }
42
 
43
  void _handleSubmitted(String value) {
44
  final trimmedValue = value.trim();
45
  if (trimmedValue.isNotEmpty) {
 
46
  FocusScope.of(context).unfocus();
47
  widget.onSearch(trimmedValue);
48
  // Reset _isComposing to ensure the field can be edited again
 
56
  Widget build(BuildContext context) {
57
  return Material(
58
  color: Colors.transparent,
59
+ child: TextFormField(
60
+ controller: widget.controller,
61
+ focusNode: _focusNode,
62
+ style: const TextStyle(color: AiTubeColors.onBackground),
63
+ enabled: widget.enabled,
64
+ textInputAction: TextInputAction.search,
65
+ onFieldSubmitted: _handleSubmitted,
66
+ onTapOutside: (_) {
67
+ FocusScope.of(context).unfocus();
68
+ },
69
+ decoration: InputDecoration(
70
+ hintText: 'Describe a video you want to generate...',
71
+ hintStyle: const TextStyle(color: AiTubeColors.onSurfaceVariant),
72
+ filled: true,
73
+ fillColor: AiTubeColors.surface,
74
+ border: OutlineInputBorder(
75
+ borderRadius: BorderRadius.circular(24),
76
+ borderSide: BorderSide.none,
77
+ ),
78
+ contentPadding: const EdgeInsets.symmetric(
79
+ horizontal: 16,
80
+ vertical: 12,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  ),
82
+ suffixIcon: widget.isSearching
83
+ ? IconButton(
84
+ icon: const SizedBox(
85
+ width: 20,
86
+ height: 20,
87
+ child: CircularProgressIndicator(strokeWidth: 2),
88
+ ),
89
+ onPressed: widget.onCancel,
90
+ )
91
+ : IconButton(
92
+ icon: const Icon(
93
+ Icons.search,
94
+ color: AiTubeColors.onSurfaceVariant,
95
+ ),
96
+ onPressed: () => _handleSubmitted(widget.controller.text),
97
+ ),
98
  ),
99
  ),
100
  );
 
102
 
103
  @override
104
  void dispose() {
 
 
105
  _focusNode.dispose();
106
  widget.controller.removeListener(_onSearchTextChanged);
107
  super.dispose();
lib/widgets/video_card.dart CHANGED
@@ -1,6 +1,8 @@
1
  import 'package:flutter/material.dart';
 
2
  import '../theme/colors.dart';
3
  import '../models/video_result.dart';
 
4
 
5
  class VideoCard extends StatelessWidget {
6
  final VideoResult video;
@@ -25,9 +27,7 @@ class VideoCard extends StatelessWidget {
25
  ),
26
  SizedBox(height: 8),
27
  Text(
28
- // 'Generating preview...',
29
- // thumbnail generation
30
- '(TODO: thumbnails)',
31
  style: TextStyle(
32
  color: AiTubeColors.onSurfaceVariant,
33
  fontSize: 12,
@@ -40,11 +40,13 @@ class VideoCard extends StatelessWidget {
40
  }
41
 
42
  try {
 
43
  if (video.thumbnailUrl.startsWith('data:image')) {
44
  final uri = Uri.parse(video.thumbnailUrl);
45
  final base64Data = uri.data?.contentAsBytes();
46
 
47
  if (base64Data == null) {
 
48
  throw Exception('Invalid image data');
49
  }
50
 
@@ -52,19 +54,38 @@ class VideoCard extends StatelessWidget {
52
  base64Data,
53
  fit: BoxFit.cover,
54
  errorBuilder: (context, error, stackTrace) {
 
55
  return _buildErrorThumbnail();
56
  },
57
  );
58
  }
59
-
60
- return Image.network(
61
- video.thumbnailUrl,
62
- fit: BoxFit.cover,
63
- errorBuilder: (context, error, stackTrace) {
64
- return _buildErrorThumbnail();
65
- },
66
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  } catch (e) {
 
68
  return _buildErrorThumbnail();
69
  }
70
  }
 
1
  import 'package:flutter/material.dart';
2
+ import 'dart:convert';
3
  import '../theme/colors.dart';
4
  import '../models/video_result.dart';
5
+ import './video_player/index.dart';
6
 
7
  class VideoCard extends StatelessWidget {
8
  final VideoResult video;
 
27
  ),
28
  SizedBox(height: 8),
29
  Text(
30
+ 'Generating preview...',
 
 
31
  style: TextStyle(
32
  color: AiTubeColors.onSurfaceVariant,
33
  fontSize: 12,
 
40
  }
41
 
42
  try {
43
+ // Handle image thumbnails
44
  if (video.thumbnailUrl.startsWith('data:image')) {
45
  final uri = Uri.parse(video.thumbnailUrl);
46
  final base64Data = uri.data?.contentAsBytes();
47
 
48
  if (base64Data == null) {
49
+ debugPrint('Invalid image data in thumbnailUrl');
50
  throw Exception('Invalid image data');
51
  }
52
 
 
54
  base64Data,
55
  fit: BoxFit.cover,
56
  errorBuilder: (context, error, stackTrace) {
57
+ debugPrint('Error loading image thumbnail: $error');
58
  return _buildErrorThumbnail();
59
  },
60
  );
61
  }
62
+ // Handle video thumbnails
63
+ else if (video.thumbnailUrl.startsWith('data:video')) {
64
+ return NanoVideoPlayer(
65
+ video: video,
66
+ autoPlay: true,
67
+ muted: true,
68
+ loop: true,
69
+ borderRadius: 0,
70
+ showLoadingIndicator: true,
71
+ playbackSpeed: 0.7,
72
+ );
73
+ }
74
+ // Regular URL thumbnail
75
+ else if (video.thumbnailUrl.isNotEmpty) {
76
+ return Image.network(
77
+ video.thumbnailUrl,
78
+ fit: BoxFit.cover,
79
+ errorBuilder: (context, error, stackTrace) {
80
+ debugPrint('Error loading network thumbnail: $error');
81
+ return _buildErrorThumbnail();
82
+ },
83
+ );
84
+ } else {
85
+ return _buildErrorThumbnail();
86
+ }
87
  } catch (e) {
88
+ debugPrint('Unexpected error in thumbnail rendering: $e');
89
  return _buildErrorThumbnail();
90
  }
91
  }
lib/widgets/video_player/buffer_manager.dart ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/buffer_manager.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/foundation.dart';
5
+ import 'package:aitube2/config/config.dart';
6
+ import 'package:aitube2/services/clip_queue/video_clip.dart';
7
+ import 'package:aitube2/services/clip_queue/clip_queue_manager.dart';
8
+ import 'package:video_player/video_player.dart';
9
+ import 'package:aitube2/models/video_result.dart';
10
+
11
+ /// Manages buffering and clip preloading for video player
12
+ class BufferManager {
13
+ /// The queue manager for clips
14
+ final ClipQueueManager queueManager;
15
+
16
+ /// Whether the manager is disposed
17
+ bool isDisposed = false;
18
+
19
+ /// Loading progress (0.0 to 1.0)
20
+ double loadingProgress = 0.0;
21
+
22
+ /// Timer for showing loading progress
23
+ Timer? progressTimer;
24
+
25
+ /// Timer for debug printing
26
+ Timer? debugTimer;
27
+
28
+ /// Constructor
29
+ BufferManager({
30
+ required VideoResult video,
31
+ required Function() onQueueUpdated,
32
+ ClipQueueManager? existingQueueManager,
33
+ }) : queueManager = existingQueueManager ?? ClipQueueManager(
34
+ video: video,
35
+ onQueueUpdated: onQueueUpdated,
36
+ );
37
+
38
+ /// Initialize the buffer with clips
39
+ Future<void> initialize() async {
40
+ if (isDisposed) return;
41
+
42
+ // Start loading progress animation
43
+ startLoadingProgress();
44
+
45
+ // Initialize queue manager but don't await it
46
+ await queueManager.initialize();
47
+ }
48
+
49
+ /// Start loading progress animation
50
+ void startLoadingProgress() {
51
+ progressTimer?.cancel();
52
+ loadingProgress = 0.0;
53
+
54
+ const totalDuration = Duration(seconds: 12);
55
+ const updateInterval = Duration(milliseconds: 50);
56
+ final steps = totalDuration.inMilliseconds / updateInterval.inMilliseconds;
57
+ final increment = 1.0 / steps;
58
+
59
+ progressTimer = Timer.periodic(updateInterval, (timer) {
60
+ if (isDisposed) {
61
+ timer.cancel();
62
+ return;
63
+ }
64
+
65
+ loadingProgress += increment;
66
+ if (loadingProgress >= 1.0) {
67
+ progressTimer?.cancel();
68
+ }
69
+ });
70
+ }
71
+
72
+ /// Start debug printing (for development)
73
+ void startDebugPrinting() {
74
+ debugTimer = Timer.periodic(const Duration(seconds: 5), (_) {
75
+ if (!isDisposed) {
76
+ queueManager.printQueueState();
77
+ }
78
+ });
79
+ }
80
+
81
+ /// Check if buffer is ready to start playback
82
+ bool isBufferReadyToStartPlayback() {
83
+ final readyClips = queueManager.clipBuffer.where((c) => c.isReady).length;
84
+ final totalClips = queueManager.clipBuffer.length;
85
+ final bufferPercentage = (readyClips / totalClips * 100);
86
+
87
+ return bufferPercentage >= Configuration.instance.minimumBufferPercentToStartPlayback;
88
+ }
89
+
90
+ /// Ensure the buffer remains full
91
+ void ensureBufferFull() {
92
+ if (isDisposed) return;
93
+
94
+ // Add additional safety check to avoid errors if the widget has been disposed
95
+ // but this method is still being called by an ongoing operation
96
+ try {
97
+ queueManager.fillBuffer();
98
+ } catch (e) {
99
+ debugPrint('Error filling buffer: $e');
100
+ }
101
+ }
102
+
103
+ /// Preload the next clip to ensure smooth playback
104
+ Future<VideoPlayerController?> preloadNextClip() async {
105
+ if (isDisposed) return null;
106
+
107
+ VideoPlayerController? nextController;
108
+ try {
109
+ // Always try to preload the next ready clip
110
+ final nextReadyClip = queueManager.nextReadyClip;
111
+
112
+ if (nextReadyClip?.base64Data != null &&
113
+ nextReadyClip != queueManager.currentClip &&
114
+ !nextReadyClip!.isPlaying) {
115
+
116
+ nextController = VideoPlayerController.networkUrl(
117
+ Uri.parse(nextReadyClip.base64Data!),
118
+ );
119
+
120
+ await nextController.initialize();
121
+
122
+ if (isDisposed) {
123
+ nextController.dispose();
124
+ return null;
125
+ }
126
+
127
+ // we always keep things looping. We never want any video to stop.
128
+ nextController.setLooping(true);
129
+ nextController.setVolume(0.0);
130
+ nextController.setPlaybackSpeed(Configuration.instance.clipPlaybackSpeed);
131
+
132
+ // Always ensure we're generating new clips after preloading
133
+ // This is wrapped in a try-catch within ensureBufferFull now
134
+ ensureBufferFull();
135
+
136
+ return nextController;
137
+ }
138
+ } catch (e) {
139
+ // Make sure we dispose any created controller if there was an error
140
+ nextController?.dispose();
141
+ debugPrint('Error preloading next clip: $e');
142
+ }
143
+
144
+ // Always ensure we're generating new clips after preloading
145
+ // This is wrapped in a try-catch within ensureBufferFull now
146
+ if (!isDisposed) {
147
+ ensureBufferFull();
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /// Dispose resources
153
+ void dispose() {
154
+ isDisposed = true;
155
+ progressTimer?.cancel();
156
+ debugTimer?.cancel();
157
+ }
158
+ }
lib/widgets/video_player/index.dart ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/index.dart
2
+
3
+ export 'video_player_widget.dart';
4
+ export 'nano_video_player.dart';
5
+ export 'playback_controller.dart';
6
+ export 'buffer_manager.dart';
7
+ export 'nano_clip_manager.dart';
8
+ export 'lifecycle_manager.dart';
9
+ export 'ui_components.dart';
lib/widgets/video_player/lifecycle_manager.dart ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/lifecycle_manager.dart
2
+
3
+ import 'package:flutter/foundation.dart' show kIsWeb;
4
+ import 'package:flutter/material.dart';
5
+
6
+ /// A mixin for managing video player lifecycle and visibility changes
7
+ /// Must be used on a State that implements WidgetsBindingObserver
8
+ mixin VideoPlayerLifecycleMixin<T extends StatefulWidget> on State<T>, WidgetsBindingObserver {
9
+ /// Whether video was playing before going to background
10
+ bool _wasPlayingBeforeBackground = false;
11
+
12
+ /// Whether the player is currently playing
13
+ bool get isPlaying;
14
+
15
+ /// Set the playing state
16
+ set isPlaying(bool value);
17
+
18
+ /// Sets up visibility listeners
19
+ @override
20
+ void initState() {
21
+ super.initState();
22
+
23
+ // Register as an observer to detect app lifecycle changes
24
+ WidgetsBinding.instance.addObserver(this);
25
+
26
+ // Add web-specific visibility change listener
27
+ if (kIsWeb) {
28
+ setupWebVisibilityListeners();
29
+ }
30
+ }
31
+
32
+ /// Set up web-specific visibility listeners
33
+ void setupWebVisibilityListeners();
34
+
35
+ /// Handles visibility state changes
36
+ void handleVisibilityChange();
37
+
38
+ /// Toggles playback state
39
+ void togglePlayback();
40
+
41
+ /// Pauses video playback when app goes to background
42
+ void pauseVideo() {
43
+ if (isPlaying) {
44
+ _wasPlayingBeforeBackground = true;
45
+ togglePlayback();
46
+ }
47
+ }
48
+
49
+ /// Resumes video playback when app comes to foreground
50
+ void resumeVideo() {
51
+ if (!isPlaying && _wasPlayingBeforeBackground) {
52
+ _wasPlayingBeforeBackground = false;
53
+ togglePlayback();
54
+ }
55
+ }
56
+
57
+ /// Handles app lifecycle state changes
58
+ @override
59
+ void didChangeAppLifecycleState(AppLifecycleState state) {
60
+ // Handle app lifecycle changes for native platforms
61
+ if (!kIsWeb) {
62
+ if (state == AppLifecycleState.paused ||
63
+ state == AppLifecycleState.inactive ||
64
+ state == AppLifecycleState.detached) {
65
+ pauseVideo();
66
+ } else if (state == AppLifecycleState.resumed && _wasPlayingBeforeBackground) {
67
+ resumeVideo();
68
+ }
69
+ }
70
+ }
71
+
72
+ /// Clean up resources
73
+ @override
74
+ void dispose() {
75
+ // Unregister the observer
76
+ WidgetsBinding.instance.removeObserver(this);
77
+ super.dispose();
78
+ }
79
+ }
lib/widgets/video_player/nano_clip_manager.dart ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/nano_clip_manager.dart
2
+
3
+ import 'dart:async';
4
+ import 'dart:convert';
5
+ import 'package:flutter/foundation.dart';
6
+ import 'package:aitube2/models/video_result.dart';
7
+ import 'package:aitube2/services/clip_queue/video_clip.dart';
8
+ import 'package:aitube2/services/clip_queue/clip_states.dart';
9
+ import 'package:aitube2/services/websocket_api_service.dart';
10
+ import 'package:aitube2/utils/seed.dart';
11
+ import 'package:uuid/uuid.dart';
12
+
13
+ /// Manages a single video clip generation for thumbnail playback
14
+ class NanoClipManager {
15
+ /// The video result for which the clip is being generated
16
+ final VideoResult video;
17
+
18
+ /// WebSocket service for API communication
19
+ final WebSocketApiService _websocketService;
20
+
21
+ /// Callback for when the clip is updated
22
+ final void Function()? onClipUpdated;
23
+
24
+ /// The generated video clip
25
+ VideoClip? _videoClip;
26
+
27
+ /// Whether the manager is disposed
28
+ bool _isDisposed = false;
29
+
30
+ /// Status text to show during generation
31
+ String _statusText = 'Initializing...';
32
+
33
+ /// Get the current video clip
34
+ VideoClip? get videoClip => _videoClip;
35
+
36
+ /// Get the current status text
37
+ String get statusText => _statusText;
38
+
39
+ /// Constructor
40
+ NanoClipManager({
41
+ required this.video,
42
+ WebSocketApiService? websocketService,
43
+ this.onClipUpdated,
44
+ }) : _websocketService = websocketService ?? WebSocketApiService();
45
+
46
+ /// Initialize and generate a single clip
47
+ Future<void> initialize({
48
+ int? overrideSeed,
49
+ Duration timeout = const Duration(seconds: 10),
50
+ }) async {
51
+ if (_isDisposed) return;
52
+
53
+ try {
54
+ // Use either provided seed, video's seed, or generate a new one
55
+ final seed = overrideSeed ??
56
+ (video.useFixedSeed && video.seed > 0 ? video.seed : generateSeed());
57
+
58
+ // Create a video clip
59
+ _videoClip = VideoClip(
60
+ prompt: "${video.title}\n${video.description}",
61
+ seed: seed,
62
+ );
63
+
64
+ _updateStatus('Connecting...');
65
+
66
+ // Set up WebSocket API service if needed
67
+ if (_websocketService.status != ConnectionStatus.connected) {
68
+ _updateStatus('Connecting to server...');
69
+ await _websocketService.initialize();
70
+
71
+ if (_isDisposed) return;
72
+
73
+ if (_websocketService.status != ConnectionStatus.connected) {
74
+ _updateStatus('Connection failed');
75
+ _videoClip!.state = ClipState.failedToGenerate;
76
+ return;
77
+ }
78
+ }
79
+
80
+ _updateStatus('Requesting thumbnail...');
81
+
82
+ // Set up timeout
83
+ final completer = Completer<void>();
84
+ Timer? timeoutTimer;
85
+ timeoutTimer = Timer(timeout, () {
86
+ if (!completer.isCompleted) {
87
+ _updateStatus('Generation timed out');
88
+ completer.complete();
89
+ }
90
+ });
91
+
92
+ // Request the thumbnail generation
93
+ try {
94
+ // Create request for thumbnail generation
95
+ final requestId = const Uuid().v4();
96
+
97
+ // Mark as generating
98
+ _videoClip!.state = ClipState.generationInProgress;
99
+
100
+ // Initiate a request to generate a thumbnail
101
+ // Using available methods in WebSocketApiService
102
+ _generateThumbnail(seed, requestId).then((thumbnailData) {
103
+ if (_isDisposed) return;
104
+
105
+ if (thumbnailData != null && thumbnailData.isNotEmpty) {
106
+ // Successful generation
107
+ _videoClip!.base64Data = thumbnailData;
108
+ _videoClip!.state = ClipState.generatedAndReadyToPlay;
109
+ _updateStatus('Ready');
110
+ } else {
111
+ // Generation failed
112
+ _videoClip!.state = ClipState.failedToGenerate;
113
+ _updateStatus('Failed to generate');
114
+ }
115
+
116
+ completer.complete();
117
+ }).catchError((error) {
118
+ debugPrint('Error generating thumbnail: $error');
119
+ _videoClip!.state = ClipState.failedToGenerate;
120
+ _updateStatus('Error: $error');
121
+ completer.complete();
122
+ });
123
+
124
+ // Wait for completion or timeout
125
+ await completer.future;
126
+ timeoutTimer.cancel();
127
+
128
+ } catch (e) {
129
+ // Handle any errors
130
+ debugPrint('Error in thumbnail generation: $e');
131
+ _videoClip!.state = ClipState.failedToGenerate;
132
+ _updateStatus('Error generating');
133
+ timeoutTimer.cancel();
134
+ }
135
+
136
+ } catch (e) {
137
+ debugPrint('Error initializing nano clip: $e');
138
+ _updateStatus('Error initializing');
139
+ }
140
+ }
141
+
142
+ /// Generate a thumbnail using the WebSocketApiService
143
+ Future<String?> _generateThumbnail(int seed, String requestId) async {
144
+ if (_isDisposed) return null;
145
+
146
+ // Show progress updates
147
+ _simulateProgress();
148
+
149
+ // If we're in debug mode and on web, we might need to mock the response
150
+ if (kDebugMode && !_websocketService.isConnected) {
151
+ await Future.delayed(const Duration(seconds: 3));
152
+ return 'data:video/mp4;base64,AAAA'; // Mock base64 data
153
+ }
154
+
155
+ try {
156
+ // Create a request to generate the thumbnail
157
+ // We'll actually implement this using the VideoResult object since that's
158
+ // what the API expects
159
+ final result = await _websocketService.generateVideo(
160
+ video,
161
+ width: 512, // Small size for thumbnail
162
+ height: 288, // 16:9 aspect ratio
163
+ seed: seed, // Use our specific seed
164
+ );
165
+
166
+ return result;
167
+ } catch (e) {
168
+ debugPrint('Error generating thumbnail through API: $e');
169
+ if (kDebugMode) {
170
+ // In debug mode, return mock data so development can continue
171
+ return 'data:video/mp4;base64,AAAA';
172
+ }
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /// Simulate generation progress during development or when server is slow
178
+ void _simulateProgress() {
179
+ if (_isDisposed) return;
180
+
181
+ const progressSteps = [
182
+ {'delay': Duration(milliseconds: 500), 'progress': 20},
183
+ {'delay': Duration(seconds: 1), 'progress': 40},
184
+ {'delay': Duration(seconds: 2), 'progress': 60},
185
+ {'delay': Duration(seconds: 3), 'progress': 80}
186
+ ];
187
+
188
+ // Show progress updates
189
+ for (final step in progressSteps) {
190
+ Future.delayed(step['delay'] as Duration, () {
191
+ if (_isDisposed) return;
192
+ _updateStatus('Generating (${step['progress']}%)');
193
+ });
194
+ }
195
+ }
196
+
197
+ /// Update the status text and notify listeners
198
+ void _updateStatus(String status) {
199
+ if (_isDisposed) return;
200
+ _statusText = status;
201
+ onClipUpdated?.call();
202
+ }
203
+
204
+ /// Dispose resources
205
+ void dispose() {
206
+ _isDisposed = true;
207
+ }
208
+ }
lib/widgets/video_player/nano_video_player.dart ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/nano_video_player.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/material.dart';
5
+ import 'package:flutter/foundation.dart' show kIsWeb;
6
+ import 'package:video_player/video_player.dart';
7
+ import 'package:aitube2/models/video_result.dart';
8
+ import 'package:aitube2/theme/colors.dart';
9
+ import 'package:aitube2/widgets/video_player/nano_clip_manager.dart';
10
+ import 'package:aitube2/widgets/video_player/lifecycle_manager.dart';
11
+ import 'package:aitube2/widgets/ai_content_disclaimer.dart';
12
+
13
+ // Conditionally import dart:html for web platform
14
+ import '../web_utils.dart' if (dart.library.html) 'dart:html' as html;
15
+
16
+ /// A lightweight video player for thumbnails with autoplay functionality
17
+ class NanoVideoPlayer extends StatefulWidget {
18
+ /// The video to display
19
+ final VideoResult video;
20
+
21
+ /// Initial thumbnail URL to show while loading
22
+ final String? initialThumbnailUrl;
23
+
24
+ /// Whether to autoplay the video
25
+ final bool autoPlay;
26
+
27
+ /// Whether to mute the video
28
+ final bool muted;
29
+
30
+ /// Border radius of the player
31
+ final double borderRadius;
32
+
33
+ /// Playback speed
34
+ final double playbackSpeed;
35
+
36
+ /// Callback when video is tapped
37
+ final VoidCallback? onTap;
38
+
39
+ /// Callback when video is loaded
40
+ final VoidCallback? onLoaded;
41
+
42
+ /// Whether to show loading indicator
43
+ final bool showLoadingIndicator;
44
+
45
+ /// Whether to loop the video
46
+ final bool loop;
47
+
48
+ /// Constructor with sensible defaults for thumbnail usage
49
+ const NanoVideoPlayer({
50
+ super.key,
51
+ required this.video,
52
+ this.initialThumbnailUrl,
53
+ this.autoPlay = true,
54
+ this.muted = true,
55
+ this.borderRadius = 8.0,
56
+ this.playbackSpeed = 0.7,
57
+ this.onTap,
58
+ this.onLoaded,
59
+ this.showLoadingIndicator = true,
60
+ this.loop = true,
61
+ });
62
+
63
+ @override
64
+ State<NanoVideoPlayer> createState() => _NanoVideoPlayerState();
65
+ }
66
+
67
+ class _NanoVideoPlayerState extends State<NanoVideoPlayer> with WidgetsBindingObserver, VideoPlayerLifecycleMixin {
68
+ /// Clip manager for the nano video
69
+ late final NanoClipManager _clipManager;
70
+
71
+ /// Video player controller
72
+ VideoPlayerController? _controller;
73
+
74
+ /// Whether the video is playing
75
+ bool _isPlaying = false;
76
+
77
+ /// Whether the video is loading
78
+ bool _isLoading = true;
79
+
80
+ /// Whether the component is disposed
81
+ bool _isDisposed = false;
82
+
83
+ /// Implements the isPlaying getter required by the mixin
84
+ @override
85
+ bool get isPlaying => _isPlaying;
86
+
87
+ /// Implements the isPlaying setter required by the mixin
88
+ @override
89
+ set isPlaying(bool value) {
90
+ if (_isDisposed) return;
91
+ setState(() {
92
+ _isPlaying = value;
93
+ });
94
+ }
95
+
96
+ @override
97
+ void initState() {
98
+ // Initialize the manager
99
+ _clipManager = NanoClipManager(
100
+ video: widget.video,
101
+ onClipUpdated: _onClipUpdated,
102
+ );
103
+
104
+ _initialize();
105
+
106
+ // Call super after setting up variables that the mixin needs
107
+ super.initState();
108
+ }
109
+
110
+ /// Initialize the player and start clip generation
111
+ Future<void> _initialize() async {
112
+ if (_isDisposed) return;
113
+
114
+ setState(() {
115
+ _isLoading = true;
116
+ });
117
+
118
+ // Start generating the clip
119
+ await _clipManager.initialize();
120
+
121
+ // Set up the video controller if clip is ready
122
+ if (_clipManager.videoClip?.isReady == true && _clipManager.videoClip?.base64Data != null) {
123
+ await _setupController();
124
+ }
125
+ }
126
+
127
+ /// Set up the video controller with the clip
128
+ Future<void> _setupController() async {
129
+ if (_isDisposed || _clipManager.videoClip?.base64Data == null) return;
130
+
131
+ try {
132
+ final clip = _clipManager.videoClip!;
133
+
134
+ // Dispose previous controller if exists
135
+ await _controller?.dispose();
136
+
137
+ // Create new controller
138
+ _controller = VideoPlayerController.networkUrl(
139
+ Uri.parse(clip.base64Data!),
140
+ );
141
+
142
+ await _controller!.initialize();
143
+
144
+ if (_isDisposed) {
145
+ await _controller?.dispose();
146
+ return;
147
+ }
148
+
149
+ // Configure the controller
150
+ _controller!.setLooping(widget.loop);
151
+ _controller!.setVolume(widget.muted ? 0.0 : 1.0);
152
+ _controller!.setPlaybackSpeed(widget.playbackSpeed);
153
+
154
+ setState(() {
155
+ _isLoading = false;
156
+ _isPlaying = widget.autoPlay;
157
+ });
158
+
159
+ if (widget.autoPlay) {
160
+ await _controller!.play();
161
+ }
162
+
163
+ widget.onLoaded?.call();
164
+
165
+ } catch (e) {
166
+ debugPrint('Error setting up nano video controller: $e');
167
+ setState(() {
168
+ _isLoading = false;
169
+ });
170
+ }
171
+ }
172
+
173
+ /// Callback when clip is updated
174
+ void _onClipUpdated() {
175
+ if (_isDisposed) return;
176
+
177
+ setState(() {});
178
+
179
+ if (_clipManager.videoClip?.isReady == true && _controller == null) {
180
+ _setupController();
181
+ }
182
+ }
183
+
184
+ /// Toggle playback
185
+ @override
186
+ void togglePlayback() {
187
+ if (_isLoading || _controller == null) return;
188
+
189
+ setState(() {
190
+ _isPlaying = !_isPlaying;
191
+ });
192
+
193
+ if (_isPlaying) {
194
+ _controller!.play();
195
+ } else {
196
+ _controller!.pause();
197
+ }
198
+ }
199
+
200
+ /// Set up web visibility listeners
201
+ @override
202
+ void setupWebVisibilityListeners() {
203
+ if (kIsWeb) {
204
+ try {
205
+ html.document.onVisibilityChange.listen((_) {
206
+ handleVisibilityChange();
207
+ });
208
+ } catch (e) {
209
+ debugPrint('Error setting up web visibility listeners: $e');
210
+ }
211
+ }
212
+ }
213
+
214
+ /// Handle visibility changes
215
+ @override
216
+ void handleVisibilityChange() {
217
+ if (!kIsWeb) return;
218
+
219
+ try {
220
+ final visibilityState = html.window.document.visibilityState;
221
+ if (visibilityState == 'hidden') {
222
+ pauseVideo();
223
+ } else if (visibilityState == 'visible') {
224
+ resumeVideo();
225
+ }
226
+ } catch (e) {
227
+ debugPrint('Error handling visibility change: $e');
228
+ }
229
+ }
230
+
231
+ @override
232
+ void dispose() {
233
+ _isDisposed = true;
234
+ _controller?.dispose();
235
+ _clipManager.dispose();
236
+ super.dispose();
237
+ }
238
+
239
+ @override
240
+ Widget build(BuildContext context) {
241
+ return GestureDetector(
242
+ onTap: widget.onTap,
243
+ child: ClipRRect(
244
+ borderRadius: BorderRadius.circular(widget.borderRadius),
245
+ child: Stack(
246
+ fit: StackFit.passthrough,
247
+ children: [
248
+ // Base layer: Video or placeholder
249
+ Container(
250
+ color: AiTubeColors.surfaceVariant,
251
+ child: _controller?.value.isInitialized == true
252
+ ? AspectRatio(
253
+ aspectRatio: _controller!.value.aspectRatio,
254
+ child: VideoPlayer(_controller!),
255
+ )
256
+ : _buildPlaceholder(),
257
+ ),
258
+
259
+ // Loading indicator
260
+ if (_isLoading && widget.showLoadingIndicator)
261
+ const Center(
262
+ child: CircularProgressIndicator(),
263
+ ),
264
+
265
+ // Status text overlay for debugging
266
+ if (_clipManager.statusText.isNotEmpty && _controller?.value.isInitialized != true)
267
+ Positioned(
268
+ bottom: 8,
269
+ left: 8,
270
+ child: Container(
271
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
272
+ decoration: BoxDecoration(
273
+ color: Colors.black.withOpacity(0.6),
274
+ borderRadius: BorderRadius.circular(4),
275
+ ),
276
+ child: Text(
277
+ _clipManager.statusText,
278
+ style: const TextStyle(
279
+ color: Colors.white,
280
+ fontSize: 10,
281
+ ),
282
+ ),
283
+ ),
284
+ ),
285
+ ],
286
+ ),
287
+ ),
288
+ );
289
+ }
290
+
291
+ /// Build placeholder widget
292
+ Widget _buildPlaceholder() {
293
+ if (widget.initialThumbnailUrl?.isNotEmpty == true) {
294
+ try {
295
+ if (widget.initialThumbnailUrl!.startsWith('data:image')) {
296
+ final uri = Uri.parse(widget.initialThumbnailUrl!);
297
+ final base64Data = uri.data?.contentAsBytes();
298
+
299
+ if (base64Data == null) {
300
+ throw Exception('Invalid image data');
301
+ }
302
+
303
+ return Image.memory(
304
+ base64Data,
305
+ fit: BoxFit.cover,
306
+ errorBuilder: (_, __, ___) => _buildFallbackPlaceholder(),
307
+ );
308
+ }
309
+
310
+ return Image.network(
311
+ widget.initialThumbnailUrl!,
312
+ fit: BoxFit.cover,
313
+ errorBuilder: (_, __, ___) => _buildFallbackPlaceholder(),
314
+ );
315
+ } catch (e) {
316
+ return _buildFallbackPlaceholder();
317
+ }
318
+ } else {
319
+ return _buildFallbackPlaceholder();
320
+ }
321
+ }
322
+
323
+ /// Build fallback placeholder when image fails to load
324
+ Widget _buildFallbackPlaceholder() {
325
+ return const Center(
326
+ child: AiContentDisclaimer(
327
+ isInteractive: true,
328
+ compact: true,
329
+ ),
330
+ );
331
+ }
332
+ }
lib/widgets/video_player/playback_controller.dart ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/playback_controller.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:flutter/foundation.dart';
5
+ import 'package:flutter/material.dart';
6
+ import 'package:video_player/video_player.dart';
7
+ import 'package:aitube2/config/config.dart';
8
+ import 'package:aitube2/services/clip_queue/video_clip.dart';
9
+
10
+ /// Manages video playback logic for the video player
11
+ class PlaybackController {
12
+ /// The current video player controller
13
+ VideoPlayerController? currentController;
14
+
15
+ /// The next video player controller (preloaded)
16
+ VideoPlayerController? nextController;
17
+
18
+ /// The current clip being played
19
+ VideoClip? currentClip;
20
+
21
+ /// Whether the video is playing
22
+ bool isPlaying = false;
23
+
24
+ /// Whether the video is loading
25
+ bool isLoading = false;
26
+
27
+ /// Whether this is the initial load
28
+ bool isInitialLoad = true;
29
+
30
+ /// Current playback position
31
+ Duration currentPlaybackPosition = Duration.zero;
32
+
33
+ /// Whether initial playback has started
34
+ bool startedInitialPlayback = false;
35
+
36
+ /// Timer for checking if buffer is ready
37
+ Timer? nextClipCheckTimer;
38
+
39
+ /// Timer for playback duration
40
+ Timer? playbackTimer;
41
+
42
+ /// Timer for tracking position
43
+ Timer? positionTrackingTimer;
44
+
45
+ /// Whether the controller is disposed
46
+ bool isDisposed = false;
47
+
48
+ /// Callback for when video is completed
49
+ Function()? onVideoCompleted;
50
+
51
+ /// Callback for when the queue needs updating
52
+ Function()? onQueueUpdate;
53
+
54
+ /// Toggle playback between play and pause
55
+ void togglePlayback() {
56
+ if (isLoading) return;
57
+
58
+ final controller = currentController;
59
+ if (controller == null) return;
60
+
61
+ isPlaying = !isPlaying;
62
+
63
+ if (isPlaying) {
64
+ // Restore previous position before playing
65
+ controller.seekTo(currentPlaybackPosition);
66
+ controller.play();
67
+ startPlaybackTimer();
68
+ } else {
69
+ controller.pause();
70
+ playbackTimer?.cancel();
71
+ positionTrackingTimer?.cancel();
72
+ }
73
+ }
74
+
75
+ /// Start the playback timer to manage clip duration
76
+ void startPlaybackTimer() {
77
+ playbackTimer?.cancel();
78
+ nextClipCheckTimer?.cancel();
79
+
80
+ playbackTimer = Timer(Configuration.instance.actualClipPlaybackDuration, () {
81
+ if (isDisposed || !isPlaying) return;
82
+
83
+ onVideoCompleted?.call();
84
+ });
85
+
86
+ startPositionTracking();
87
+ }
88
+
89
+ /// Start tracking the video position
90
+ void startPositionTracking() {
91
+ positionTrackingTimer?.cancel();
92
+ positionTrackingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
93
+ if (isDisposed || !isPlaying) return;
94
+
95
+ final controller = currentController;
96
+ if (controller != null && controller.value.isInitialized) {
97
+ currentPlaybackPosition = controller.value.position;
98
+ }
99
+ });
100
+ }
101
+
102
+ /// Start checking for the next clip
103
+ void startNextClipCheck() {
104
+ nextClipCheckTimer?.cancel();
105
+ nextClipCheckTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
106
+ if (isDisposed || !isPlaying) {
107
+ timer.cancel();
108
+ return;
109
+ }
110
+
111
+ onQueueUpdate?.call();
112
+ });
113
+ }
114
+
115
+ /// Log the current playback status (debug only)
116
+ void logPlaybackStatus() {
117
+ if (kDebugMode) {
118
+ final controller = currentController;
119
+ if (controller != null && controller.value.isInitialized) {
120
+ final position = controller.value.position;
121
+ final duration = controller.value.duration;
122
+ debugPrint('Playback status: ${position.inSeconds}s / ${duration.inSeconds}s'
123
+ ' (${isPlaying ? "playing" : "paused"})');
124
+ debugPrint('Current clip: ${currentClip?.seed}, Next controller ready: ${nextController != null}');
125
+ }
126
+ }
127
+ }
128
+
129
+ /// Initialize a controller for a video clip
130
+ Future<VideoPlayerController> initializeController(String videoUrl) async {
131
+ final controller = VideoPlayerController.networkUrl(
132
+ Uri.parse(videoUrl),
133
+ );
134
+
135
+ await controller.initialize();
136
+
137
+ // Configure the controller
138
+ controller.setLooping(true);
139
+ controller.setVolume(0.0);
140
+ controller.setPlaybackSpeed(Configuration.instance.clipPlaybackSpeed);
141
+
142
+ return controller;
143
+ }
144
+
145
+ /// Prepare the controller to be disposed
146
+ Future<void> dispose() async {
147
+ isDisposed = true;
148
+
149
+ playbackTimer?.cancel();
150
+ nextClipCheckTimer?.cancel();
151
+ positionTrackingTimer?.cancel();
152
+
153
+ await currentController?.dispose();
154
+ await nextController?.dispose();
155
+
156
+ currentController = null;
157
+ nextController = null;
158
+ }
159
+ }
lib/widgets/video_player/ui_components.dart ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/ui_components.dart
2
+
3
+ import 'package:flutter/material.dart';
4
+ import 'package:aitube2/theme/colors.dart';
5
+ import 'package:aitube2/widgets/ai_content_disclaimer.dart';
6
+ import 'package:aitube2/config/config.dart';
7
+
8
+ /// Builds a placeholder widget for when video is not loaded
9
+ Widget buildPlaceholder(String? initialThumbnailUrl) {
10
+ // Use our new AI Content Disclaimer widget as the placeholder
11
+ if (initialThumbnailUrl?.isEmpty ?? true) {
12
+ // Set isInteractive to true as we generate content on-the-fly
13
+ return const AiContentDisclaimer(isInteractive: true);
14
+ }
15
+
16
+ try {
17
+ if (initialThumbnailUrl!.startsWith('data:image')) {
18
+ final uri = Uri.parse(initialThumbnailUrl);
19
+ final base64Data = uri.data?.contentAsBytes();
20
+
21
+ if (base64Data == null) {
22
+ throw Exception('Invalid image data');
23
+ }
24
+
25
+ return Image.memory(
26
+ base64Data,
27
+ fit: BoxFit.cover,
28
+ errorBuilder: (_, __, ___) => buildErrorPlaceholder(),
29
+ );
30
+ }
31
+
32
+ return Image.network(
33
+ initialThumbnailUrl,
34
+ fit: BoxFit.cover,
35
+ errorBuilder: (_, __, ___) => buildErrorPlaceholder(),
36
+ );
37
+ } catch (e) {
38
+ return buildErrorPlaceholder();
39
+ }
40
+ }
41
+
42
+ /// Builds an error placeholder for when image loading fails
43
+ Widget buildErrorPlaceholder() {
44
+ return const Center(
45
+ child: Icon(
46
+ Icons.broken_image,
47
+ size: 64,
48
+ color: AiTubeColors.onSurfaceVariant,
49
+ ),
50
+ );
51
+ }
52
+
53
+ /// Builds a buffer status indicator widget
54
+ Widget buildBufferStatus({
55
+ required bool showDuringLoading,
56
+ required bool isLoading,
57
+ required List<dynamic> clipBuffer,
58
+ }) {
59
+ final readyOrPlayingClips = clipBuffer.where((c) => c.isReady || c.isPlaying).length;
60
+ final totalClips = clipBuffer.length;
61
+ final bufferPercentage = (readyOrPlayingClips / totalClips * 100).round();
62
+
63
+ // since we are playing clips at a reduced speed, they each last "longer"
64
+ // eg a video playing back at 0.5 speed will see its duration multiplied by 2
65
+ final actualDurationPerClip = Configuration.instance.actualClipPlaybackDuration;
66
+
67
+ final remainingSeconds = readyOrPlayingClips * actualDurationPerClip.inSeconds;
68
+
69
+ // Don't show 0% during initial loading
70
+ if (!showDuringLoading && bufferPercentage == 0) {
71
+ return const SizedBox.shrink();
72
+ }
73
+
74
+ return Positioned(
75
+ right: 16,
76
+ bottom: 16,
77
+ child: Container(
78
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
79
+ decoration: BoxDecoration(
80
+ color: Colors.black.withOpacity(0.6),
81
+ borderRadius: BorderRadius.circular(16),
82
+ ),
83
+ child: Row(
84
+ mainAxisSize: MainAxisSize.min,
85
+ children: [
86
+ Icon(
87
+ _getBufferIcon(bufferPercentage),
88
+ color: _getBufferStatusColor(bufferPercentage),
89
+ size: 16,
90
+ ),
91
+ const SizedBox(width: 4),
92
+ Text(
93
+ isLoading
94
+ ? 'Buffering $bufferPercentage%'
95
+ : '$bufferPercentage% (${remainingSeconds}s)',
96
+ style: const TextStyle(
97
+ color: Colors.white,
98
+ fontSize: 12,
99
+ fontWeight: FontWeight.bold,
100
+ ),
101
+ ),
102
+ ],
103
+ ),
104
+ ),
105
+ );
106
+ }
107
+
108
+ /// Get icon for buffer status based on percentage
109
+ IconData _getBufferIcon(int percentage) {
110
+ if (percentage >= 40) return Icons.network_wifi;
111
+ if (percentage >= 30) return Icons.network_wifi_3_bar;
112
+ if (percentage >= 20) return Icons.network_wifi_2_bar;
113
+ return Icons.network_wifi_1_bar;
114
+ }
115
+
116
+ /// Get color for buffer status based on percentage
117
+ Color _getBufferStatusColor(int percentage) {
118
+ if (percentage >= 30) return Colors.green;
119
+ if (percentage >= 20) return Colors.orange;
120
+ return Colors.red;
121
+ }
122
+
123
+ /// Formats queue statistics for display
124
+ String formatQueueStats(dynamic queueManager) {
125
+ final stats = queueManager.getBufferStats();
126
+ final currentClipInfo = queueManager.currentClip != null
127
+ ? '\nPlaying: ${queueManager.currentClip!.seed}'
128
+ : '';
129
+ final nextClipInfo = stats['nextControllerReady'] == true
130
+ ? '\nNext clip preloaded'
131
+ : '';
132
+
133
+ return 'Queue: ${stats['readyClips']}/${stats['bufferSize']} ready\n'
134
+ 'Gen: ${stats['activeGenerations']} active'
135
+ '$currentClipInfo$nextClipInfo';
136
+ }
137
+
138
+ /// Builds a play/pause button overlay
139
+ Widget buildPlayPauseButton({
140
+ required bool isPlaying,
141
+ required VoidCallback onTap,
142
+ }) {
143
+ return Positioned(
144
+ left: 16,
145
+ bottom: 16,
146
+ child: GestureDetector(
147
+ onTap: onTap,
148
+ child: Container(
149
+ padding: const EdgeInsets.all(8),
150
+ decoration: BoxDecoration(
151
+ color: Colors.black.withOpacity(0.6),
152
+ borderRadius: BorderRadius.circular(24),
153
+ ),
154
+ child: Icon(
155
+ isPlaying ? Icons.pause : Icons.play_arrow,
156
+ color: Colors.white,
157
+ size: 24,
158
+ ),
159
+ ),
160
+ ),
161
+ );
162
+ }
lib/widgets/video_player/video_player_widget.dart ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/widgets/video_player/video_player_widget.dart
2
+
3
+ import 'dart:async';
4
+ import 'package:aitube2/config/config.dart';
5
+ import 'package:flutter/material.dart';
6
+ import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
7
+ import 'package:video_player/video_player.dart';
8
+ import 'package:aitube2/models/video_result.dart';
9
+ import 'package:aitube2/theme/colors.dart';
10
+
11
+ // Import components
12
+ import 'playback_controller.dart';
13
+ import 'buffer_manager.dart';
14
+ import 'ui_components.dart' as ui;
15
+
16
+ // Conditionally import dart:html for web platform
17
+ import '../web_utils.dart' if (dart.library.html) 'dart:html' as html;
18
+
19
+ /// A widget that plays video clips with buffering and automatic playback
20
+ class VideoPlayerWidget extends StatefulWidget {
21
+ /// The video to play
22
+ final VideoResult video;
23
+
24
+ /// Initial thumbnail URL to show while loading
25
+ final String? initialThumbnailUrl;
26
+
27
+ /// Whether to autoplay the video
28
+ final bool autoPlay;
29
+
30
+ /// Border radius of the video player
31
+ final double borderRadius;
32
+
33
+ /// Callback when video is loaded
34
+ final VoidCallback? onVideoLoaded;
35
+
36
+ /// Constructor
37
+ const VideoPlayerWidget({
38
+ super.key,
39
+ required this.video,
40
+ this.initialThumbnailUrl,
41
+ this.autoPlay = true,
42
+ this.borderRadius = 12,
43
+ this.onVideoLoaded,
44
+ });
45
+
46
+ @override
47
+ State<VideoPlayerWidget> createState() => _VideoPlayerWidgetState();
48
+ }
49
+
50
+ class _VideoPlayerWidgetState extends State<VideoPlayerWidget> with WidgetsBindingObserver {
51
+ /// Playback controller for video playback
52
+ late final PlaybackController _playbackController;
53
+
54
+ /// Buffer manager for clip buffering
55
+ late final BufferManager _bufferManager;
56
+
57
+ /// Whether the widget is disposed
58
+ bool _isDisposed = false;
59
+
60
+ /// Whether playback was happening before going to background
61
+ bool _wasPlayingBeforeBackground = false;
62
+
63
+ @override
64
+ void initState() {
65
+ super.initState();
66
+
67
+ // Register as an observer to detect app lifecycle changes
68
+ WidgetsBinding.instance.addObserver(this);
69
+
70
+ // Add web-specific visibility change listener
71
+ if (kIsWeb) {
72
+ try {
73
+ // Add document visibility change listener
74
+ html.document.onVisibilityChange.listen((_) {
75
+ _handleVisibilityChange();
76
+ });
77
+
78
+ // Add before unload listener
79
+ html.window.onBeforeUnload.listen((_) {
80
+ // Pause video when navigating away from the page
81
+ _pauseVideo();
82
+ });
83
+ } catch (e) {
84
+ debugPrint('Error setting up web visibility listeners: $e');
85
+ }
86
+ }
87
+
88
+ _initializePlayer();
89
+ }
90
+
91
+ void _handleVisibilityChange() {
92
+ if (!kIsWeb) return;
93
+
94
+ try {
95
+ final visibilityState = html.window.document.visibilityState;
96
+ if (visibilityState == 'hidden') {
97
+ _pauseVideo();
98
+ } else if (visibilityState == 'visible' && _wasPlayingBeforeBackground) {
99
+ _resumeVideo();
100
+ }
101
+ } catch (e) {
102
+ debugPrint('Error handling visibility change: $e');
103
+ }
104
+ }
105
+
106
+ void _pauseVideo() {
107
+ if (_playbackController.isPlaying) {
108
+ _wasPlayingBeforeBackground = true;
109
+ _togglePlayback();
110
+ }
111
+ }
112
+
113
+ void _resumeVideo() {
114
+ if (!_playbackController.isPlaying && _wasPlayingBeforeBackground) {
115
+ _wasPlayingBeforeBackground = false;
116
+ _togglePlayback();
117
+ }
118
+ }
119
+
120
+ Future<void> _initializePlayer() async {
121
+ if (_isDisposed) return;
122
+
123
+ _playbackController = PlaybackController();
124
+ _playbackController.isLoading = true;
125
+ _playbackController.isInitialLoad = true;
126
+ _playbackController.onVideoCompleted = _onVideoCompleted;
127
+
128
+ if (_playbackController.isInitialLoad) {
129
+ _bufferManager = BufferManager(
130
+ video: widget.video,
131
+ onQueueUpdated: () {
132
+ // Prevent setState after disposal
133
+ if (!_isDisposed && mounted) {
134
+ setState(() {});
135
+ // Check buffer status whenever queue updates
136
+ _checkBufferAndStartPlayback();
137
+ }
138
+ },
139
+ );
140
+
141
+ // Initialize buffer manager
142
+ await _bufferManager.initialize();
143
+ }
144
+
145
+ if (!_isDisposed && mounted) {
146
+ setState(() {
147
+ _playbackController.isLoading = true;
148
+ });
149
+ }
150
+ }
151
+
152
+ void _checkBufferAndStartPlayback() {
153
+ if (_isDisposed || _playbackController.startedInitialPlayback) return;
154
+
155
+ try {
156
+ if (_bufferManager.isBufferReadyToStartPlayback()) {
157
+ _playbackController.startedInitialPlayback = true;
158
+ _startInitialPlayback();
159
+ } else {
160
+ // Schedule another check
161
+ if (!_isDisposed) {
162
+ Future.delayed(const Duration(milliseconds: 50), _checkBufferAndStartPlayback);
163
+ }
164
+ }
165
+ } catch (e) {
166
+ debugPrint('Error checking buffer status: $e');
167
+ // Don't reschedule if there was an error to prevent infinite error loops
168
+ }
169
+ }
170
+
171
+ Future<void> _startInitialPlayback() async {
172
+ if (_isDisposed) return;
173
+
174
+ final nextClip = _bufferManager.queueManager.currentClip;
175
+ if (nextClip?.isReady == true && !nextClip!.isPlaying) {
176
+ _bufferManager.queueManager.startPlayingClip(nextClip);
177
+ await _playClip(nextClip);
178
+ }
179
+
180
+ if (!_isDisposed && mounted) {
181
+ setState(() {
182
+ _playbackController.isLoading = false;
183
+ _playbackController.isInitialLoad = false;
184
+ });
185
+ }
186
+ }
187
+
188
+ void _togglePlayback() {
189
+ _playbackController.togglePlayback();
190
+ if (!_isDisposed && mounted) {
191
+ setState(() {});
192
+ }
193
+ }
194
+
195
+ Future<void> _playClip(dynamic clip) async {
196
+ if (_isDisposed || clip.base64Data == null) return;
197
+
198
+ try {
199
+ VideoPlayerController? newController;
200
+
201
+ if (_playbackController.nextController != null) {
202
+ debugPrint('Using preloaded controller for clip ${clip.seed}');
203
+ newController = _playbackController.nextController;
204
+ _playbackController.nextController = null;
205
+ } else {
206
+ debugPrint('Creating new controller for clip ${clip.seed}');
207
+ newController = VideoPlayerController.networkUrl(
208
+ Uri.parse(clip.base64Data!),
209
+ );
210
+ await newController.initialize();
211
+ }
212
+
213
+ if (_isDisposed || newController == null) {
214
+ newController?.dispose();
215
+ return;
216
+ }
217
+
218
+ newController.setLooping(true);
219
+ newController.setVolume(0.0);
220
+ newController.setPlaybackSpeed(Configuration.instance.clipPlaybackSpeed);
221
+
222
+ final oldController = _playbackController.currentController;
223
+ final oldClip = _playbackController.currentClip;
224
+
225
+ _bufferManager.queueManager.startPlayingClip(clip);
226
+ _playbackController.currentPlaybackPosition = Duration.zero; // Reset for new clip
227
+
228
+ if (!_isDisposed && mounted) {
229
+ setState(() {
230
+ _playbackController.currentController = newController;
231
+ _playbackController.currentClip = clip;
232
+ _playbackController.isPlaying = widget.autoPlay;
233
+ });
234
+ }
235
+
236
+ if (widget.autoPlay) {
237
+ await newController.play();
238
+ debugPrint('Started playback of clip ${clip.seed}');
239
+ _playbackController.startPlaybackTimer();
240
+ }
241
+
242
+ await oldController?.dispose();
243
+ if (oldClip != null && oldClip != clip) {
244
+ _bufferManager.queueManager.markCurrentClipAsPlayed();
245
+ }
246
+
247
+ widget.onVideoLoaded?.call();
248
+ await _preloadNextClip();
249
+ _bufferManager.ensureBufferFull();
250
+
251
+ } catch (e) {
252
+ debugPrint('Error playing clip: $e');
253
+ if (!_isDisposed) {
254
+ if (!_isDisposed && mounted) {
255
+ setState(() => _playbackController.isLoading = true);
256
+ }
257
+ await Future.delayed(const Duration(milliseconds: 500));
258
+ }
259
+ }
260
+ }
261
+
262
+ Future<void> _onVideoCompleted() async {
263
+ if (_isDisposed) return;
264
+
265
+ // debugPrint('\nHandling video completion');
266
+ _playbackController.playbackTimer?.cancel(); // Cancel current playback timer
267
+
268
+ // Get next clip before cleaning up current
269
+ final nextClip = _bufferManager.queueManager.nextReadyClip;
270
+ if (nextClip == null) {
271
+ // Reset current clip
272
+ _playbackController.currentController?.seekTo(Duration.zero);
273
+ _playbackController.startPlaybackTimer(); // Restart playback timer
274
+ return;
275
+ }
276
+
277
+ // Mark current as played and move to history
278
+ if (_playbackController.currentClip != null) {
279
+ _bufferManager.queueManager.markCurrentClipAsPlayed();
280
+ _playbackController.currentClip = null;
281
+ }
282
+
283
+ // Important: Mark the next clip as playing BEFORE transitioning the video
284
+ _bufferManager.queueManager.startPlayingClip(nextClip);
285
+
286
+ // Transition to next clip
287
+ if (_playbackController.nextController != null) {
288
+ final oldController = _playbackController.currentController;
289
+ if (!_isDisposed && mounted) {
290
+ setState(() {
291
+ _playbackController.currentController = _playbackController.nextController;
292
+ _playbackController.nextController = null;
293
+ _playbackController.currentClip = nextClip;
294
+ _playbackController.isPlaying = true;
295
+ });
296
+ }
297
+
298
+ await _playbackController.currentController?.play();
299
+ _playbackController.startPlaybackTimer(); // Start timer for new clip
300
+ await oldController?.dispose();
301
+
302
+ // Start preloading next
303
+ await _preloadNextClip();
304
+
305
+ } else {
306
+ await _playClip(nextClip);
307
+ }
308
+ }
309
+
310
+ Future<void> _preloadNextClip() async {
311
+ try {
312
+ final nextController = await _bufferManager.preloadNextClip();
313
+ if (!_isDisposed && nextController != null) {
314
+ // Dispose any existing preloaded controller first
315
+ await _playbackController.nextController?.dispose();
316
+ _playbackController.nextController = nextController;
317
+ }
318
+ } catch (e) {
319
+ debugPrint('Error in preloadNextClip: $e');
320
+ }
321
+ }
322
+
323
+ @override
324
+ void didChangeAppLifecycleState(AppLifecycleState state) {
325
+ super.didChangeAppLifecycleState(state);
326
+
327
+ // Handle app lifecycle changes for native platforms
328
+ if (!kIsWeb) {
329
+ if (state == AppLifecycleState.paused ||
330
+ state == AppLifecycleState.inactive ||
331
+ state == AppLifecycleState.detached) {
332
+ _pauseVideo();
333
+ } else if (state == AppLifecycleState.resumed && _wasPlayingBeforeBackground) {
334
+ _resumeVideo();
335
+ }
336
+ }
337
+ }
338
+
339
+ @override
340
+ void dispose() {
341
+ _isDisposed = true;
342
+
343
+ // Unregister the observer
344
+ WidgetsBinding.instance.removeObserver(this);
345
+
346
+ // Dispose controllers and timers
347
+ _playbackController.dispose();
348
+ _bufferManager.dispose();
349
+
350
+ super.dispose();
351
+ }
352
+
353
+ @override
354
+ Widget build(BuildContext context) {
355
+ return LayoutBuilder(
356
+ builder: (context, constraints) {
357
+ final controller = _playbackController.currentController;
358
+ final aspectRatio = controller?.value.aspectRatio ?? 16/9;
359
+ final playerHeight = constraints.maxWidth / aspectRatio;
360
+
361
+ return SizedBox(
362
+ width: constraints.maxWidth,
363
+ height: playerHeight,
364
+ child: Stack(
365
+ fit: StackFit.expand,
366
+ children: [
367
+ // Base layer: Placeholder or Video
368
+ ClipRRect(
369
+ borderRadius: BorderRadius.circular(widget.borderRadius),
370
+ child: Container(
371
+ color: AiTubeColors.surfaceVariant,
372
+ child: controller?.value.isInitialized ?? false
373
+ ? VideoPlayer(controller!)
374
+ : ui.buildPlaceholder(widget.initialThumbnailUrl),
375
+ ),
376
+ ),
377
+
378
+ // Play/Pause button overlay
379
+ if (controller?.value.isInitialized ?? false)
380
+ ui.buildPlayPauseButton(
381
+ isPlaying: _playbackController.isPlaying,
382
+ onTap: _togglePlayback,
383
+ ),
384
+
385
+ // Buffer status
386
+ ui.buildBufferStatus(
387
+ showDuringLoading: true,
388
+ isLoading: _playbackController.isLoading,
389
+ clipBuffer: _bufferManager.queueManager.clipBuffer,
390
+ ),
391
+ ],
392
+ ),
393
+ );
394
+ },
395
+ );
396
+ }
397
+ }
lib/widgets/video_player_widget.dart CHANGED
@@ -1,718 +1,5 @@
1
  // lib/widgets/video_player_widget.dart
 
 
2
 
3
- import 'dart:async';
4
- import 'package:aitube2/config/config.dart';
5
- import 'package:flutter/material.dart';
6
- import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
7
- import 'package:video_player/video_player.dart';
8
- import 'package:aitube2/models/video_result.dart';
9
- import 'package:aitube2/services/clip_queue_manager.dart';
10
- import 'package:aitube2/theme/colors.dart';
11
- import 'package:aitube2/widgets/ai_content_disclaimer.dart';
12
-
13
-
14
- // Conditionally import dart:html for web platform
15
- import 'web_utils.dart' if (dart.library.html) 'dart:html' as html;
16
-
17
- class VideoPlayerWidget extends StatefulWidget {
18
- final VideoResult video;
19
- final String? initialThumbnailUrl;
20
- final bool autoPlay;
21
- final double borderRadius;
22
- final VoidCallback? onVideoLoaded;
23
-
24
- const VideoPlayerWidget({
25
- super.key,
26
- required this.video,
27
- this.initialThumbnailUrl,
28
- this.autoPlay = true,
29
- this.borderRadius = 12,
30
- this.onVideoLoaded,
31
- });
32
-
33
- @override
34
- State<VideoPlayerWidget> createState() => _VideoPlayerWidgetState();
35
- }
36
-
37
- class _VideoPlayerWidgetState extends State<VideoPlayerWidget> with WidgetsBindingObserver {
38
- late final ClipQueueManager _queueManager;
39
- VideoPlayerController? _currentController;
40
- VideoPlayerController? _nextController;
41
- VideoClip? _currentClip;
42
- bool _isPlaying = false;
43
- bool _isLoading = false;
44
- bool _isInitialLoad = true;
45
- bool _wasPlayingBeforeBackground = false;
46
-
47
- double _loadingProgress = 0.0;
48
- Timer? _progressTimer;
49
- Timer? _debugTimer;
50
- Timer? _playbackTimer; // New timer for tracking playback duration
51
- bool _isDisposed = false;
52
-
53
- Duration _currentPlaybackPosition = Duration.zero;
54
- Timer? _positionTrackingTimer;
55
-
56
- bool _startedInitialPlayback = false;
57
- Timer? _nextClipCheckTimer;
58
-
59
- @override
60
- void initState() {
61
- super.initState();
62
-
63
- // Register as an observer to detect app lifecycle changes
64
- WidgetsBinding.instance.addObserver(this);
65
-
66
- // Add web-specific visibility change listener
67
- if (kIsWeb) {
68
- // These handlers only run on web platforms
69
- try {
70
- // Add document visibility change listener
71
- html.document.onVisibilityChange.listen((_) {
72
- _handleVisibilityChange();
73
- });
74
-
75
- // Add before unload listener
76
- html.window.onBeforeUnload.listen((_) {
77
- // Pause video when navigating away from the page
78
- _pauseVideo();
79
- });
80
- } catch (e) {
81
- debugPrint('Error setting up web visibility listeners: $e');
82
- }
83
- }
84
-
85
- _initializePlayer();
86
- // if (kDebugMode) { _startDebugPrinting(); }
87
- }
88
-
89
- void _handleVisibilityChange() {
90
- if (!kIsWeb) return;
91
-
92
- try {
93
- final visibilityState = html.window.document.visibilityState;
94
- if (visibilityState == 'hidden') {
95
- _pauseVideo();
96
- } else if (visibilityState == 'visible' && _wasPlayingBeforeBackground) {
97
- _resumeVideo();
98
- }
99
- } catch (e) {
100
- debugPrint('Error handling visibility change: $e');
101
- }
102
- }
103
-
104
- void _pauseVideo() {
105
- if (_isPlaying) {
106
- _wasPlayingBeforeBackground = true;
107
- _togglePlayback();
108
- }
109
- }
110
-
111
- void _resumeVideo() {
112
- if (!_isPlaying && _wasPlayingBeforeBackground) {
113
- _wasPlayingBeforeBackground = false;
114
- _togglePlayback();
115
- }
116
- }
117
-
118
- void _startNextClipCheck() {
119
- _nextClipCheckTimer?.cancel();
120
- _nextClipCheckTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
121
- if (_isDisposed || !_isPlaying) {
122
- timer.cancel();
123
- return;
124
- }
125
-
126
- final nextClip = _queueManager.nextReadyClip;
127
- if (nextClip != null) {
128
- timer.cancel();
129
- _onVideoCompleted();
130
- }
131
- });
132
- }
133
-
134
- void _checkBufferAndStartPlayback() {
135
- if (_isDisposed || _startedInitialPlayback) return;
136
-
137
- final readyClips = _queueManager.clipBuffer.where((c) => c.isReady).length;
138
- final totalClips = _queueManager.clipBuffer.length;
139
- final bufferPercentage = (readyClips / totalClips * 100);
140
-
141
- if (bufferPercentage >= Configuration.instance.minimumBufferPercentToStartPlayback) {
142
- _startedInitialPlayback = true;
143
- _startInitialPlayback();
144
- } else {
145
- // Schedule another check
146
- Future.delayed(const Duration(milliseconds: 50), _checkBufferAndStartPlayback);
147
- }
148
- }
149
-
150
- Future<void> _startInitialPlayback() async {
151
- final nextClip = _queueManager.currentClip;
152
- if (nextClip?.isReady == true && !nextClip!.isPlaying) {
153
- _queueManager.startPlayingClip(nextClip);
154
- await _playClip(nextClip);
155
- }
156
-
157
- setState(() {
158
- _isLoading = false;
159
- _isInitialLoad = false;
160
- });
161
- }
162
-
163
- void _startPlaybackTimer() {
164
- _playbackTimer?.cancel();
165
- _nextClipCheckTimer?.cancel();
166
-
167
- _playbackTimer = Timer(Configuration.instance.actualClipPlaybackDuration, () {
168
- if (_isDisposed || !_isPlaying) return;
169
-
170
- final nextClip = _queueManager.nextReadyClip;
171
-
172
- if (nextClip != null) {
173
- _onVideoCompleted();
174
- } else {
175
- // Reset current clip
176
- _currentController?.seekTo(Duration.zero);
177
- _currentPlaybackPosition = Duration.zero;
178
-
179
- // Start checking for next clip availability
180
- _startNextClipCheck();
181
- }
182
- });
183
-
184
- _startPositionTracking();
185
- }
186
-
187
- void _startPositionTracking() {
188
- _positionTrackingTimer?.cancel();
189
- _positionTrackingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
190
- if (_isDisposed || !_isPlaying) return;
191
-
192
- final controller = _currentController;
193
- if (controller != null && controller.value.isInitialized) {
194
- _currentPlaybackPosition = controller.value.position;
195
- }
196
- });
197
- }
198
-
199
- void _togglePlayback() {
200
- if (_isLoading) return;
201
-
202
- final controller = _currentController;
203
- if (controller == null) return;
204
-
205
- setState(() => _isPlaying = !_isPlaying);
206
-
207
- if (_isPlaying) {
208
- // Restore previous position before playing
209
- controller.seekTo(_currentPlaybackPosition);
210
- controller.play();
211
- _startPlaybackTimer();
212
- } else {
213
- controller.pause();
214
- _playbackTimer?.cancel();
215
- _positionTrackingTimer?.cancel();
216
- }
217
- }
218
-
219
- Future<void> _playClip(VideoClip clip) async {
220
- if (_isDisposed || clip.base64Data == null) return;
221
-
222
- try {
223
- VideoPlayerController? newController;
224
-
225
- if (_nextController != null) {
226
- debugPrint('Using preloaded controller for clip ${clip.seed}');
227
- newController = _nextController;
228
- _nextController = null;
229
- } else {
230
- debugPrint('Creating new controller for clip ${clip.seed}');
231
- newController = VideoPlayerController.networkUrl(
232
- Uri.parse(clip.base64Data!),
233
- );
234
- await newController.initialize();
235
- }
236
-
237
- if (_isDisposed || newController == null) {
238
- newController?.dispose();
239
- return;
240
- }
241
-
242
- newController.setLooping(true);
243
- newController.setVolume(0.0);
244
- newController.setPlaybackSpeed(Configuration.instance.clipPlaybackSpeed);
245
-
246
- final oldController = _currentController;
247
- final oldClip = _currentClip;
248
-
249
- _queueManager.startPlayingClip(clip);
250
- _currentPlaybackPosition = Duration.zero; // Reset for new clip
251
-
252
- setState(() {
253
- _currentController = newController;
254
- _currentClip = clip;
255
- _isPlaying = widget.autoPlay;
256
- });
257
-
258
- if (widget.autoPlay) {
259
- await newController.play();
260
- debugPrint('Started playback of clip ${clip.seed}');
261
- _startPlaybackTimer();
262
- }
263
-
264
- await oldController?.dispose();
265
- if (oldClip != null && oldClip != clip) {
266
- _queueManager.markCurrentClipAsPlayed();
267
- }
268
-
269
- widget.onVideoLoaded?.call();
270
- await _preloadNextClip();
271
- _ensureBufferFull();
272
-
273
- } catch (e) {
274
- debugPrint('Error playing clip: $e');
275
- if (!_isDisposed) {
276
- setState(() => _isLoading = true);
277
- await Future.delayed(const Duration(milliseconds: 500));
278
- await _waitForClipAndPlay();
279
- }
280
- }
281
- }
282
-
283
- @override
284
- void didChangeAppLifecycleState(AppLifecycleState state) {
285
- super.didChangeAppLifecycleState(state);
286
-
287
- // Handle app lifecycle changes for native platforms
288
- if (!kIsWeb) {
289
- if (state == AppLifecycleState.paused ||
290
- state == AppLifecycleState.inactive ||
291
- state == AppLifecycleState.detached) {
292
- _pauseVideo();
293
- } else if (state == AppLifecycleState.resumed && _wasPlayingBeforeBackground) {
294
- _resumeVideo();
295
- }
296
- }
297
- }
298
-
299
- @override
300
- void dispose() {
301
- _isDisposed = true;
302
-
303
- // Unregister the observer
304
- WidgetsBinding.instance.removeObserver(this);
305
-
306
- _currentController?.dispose();
307
- _nextController?.dispose();
308
- _progressTimer?.cancel();
309
- _debugTimer?.cancel();
310
- _playbackTimer?.cancel();
311
- _nextClipCheckTimer?.cancel();
312
- _positionTrackingTimer?.cancel();
313
- _queueManager.dispose();
314
- super.dispose();
315
- }
316
-
317
- //////////////////////////
318
-
319
-
320
- Future<void> _onVideoCompleted() async {
321
- if (_isDisposed) return;
322
-
323
- debugPrint('\nHandling video completion');
324
- _playbackTimer?.cancel(); // Cancel current playback timer
325
-
326
- // Get next clip before cleaning up current
327
- final nextClip = _queueManager.nextReadyClip;
328
- if (nextClip == null) {
329
- // debugPrint('No next clip ready, resetting current playback');
330
- _currentController?.seekTo(Duration.zero);
331
- _startPlaybackTimer(); // Restart playback timer
332
- return;
333
- }
334
-
335
- // Mark current as played and move to history
336
- if (_currentClip != null) {
337
- // debugPrint('Marking current clip ${_currentClip!.seed} as played');
338
- _queueManager.markCurrentClipAsPlayed();
339
- _currentClip = null;
340
- }
341
-
342
- // Important: Mark the next clip as playing BEFORE transitioning the video
343
- _queueManager.startPlayingClip(nextClip);
344
- // debugPrint('Marked next clip ${nextClip.seed} as playing in queue');
345
-
346
- // Transition to next clip
347
- if (_nextController != null) {
348
- // debugPrint('Using preloaded controller for next clip ${nextClip.seed}');
349
- final oldController = _currentController;
350
- setState(() {
351
- _currentController = _nextController;
352
- _nextController = null;
353
- _currentClip = nextClip;
354
- _isPlaying = true;
355
- });
356
-
357
- await _currentController?.play();
358
- _startPlaybackTimer(); // Start timer for new clip
359
- await oldController?.dispose();
360
-
361
- // Start preloading next
362
- await _preloadNextClip();
363
-
364
- } else {
365
- // debugPrint('No preloaded controller, playing next clip ${nextClip.seed} directly');
366
- await _playClip(nextClip);
367
- }
368
- }
369
-
370
- void _startDebugPrinting() {
371
- _debugTimer = Timer.periodic(const Duration(seconds: 5), (_) {
372
- if (!_isDisposed) {
373
- _queueManager.printQueueState();
374
- _logPlaybackStatus();
375
- }
376
- });
377
- }
378
-
379
- void _logPlaybackStatus() {
380
- if (kDebugMode) {
381
- final controller = _currentController;
382
- if (controller != null && controller.value.isInitialized) {
383
- final position = controller.value.position;
384
- final duration = controller.value.duration;
385
- debugPrint('Playback status: ${position.inSeconds}s / ${duration.inSeconds}s'
386
- ' (${_isPlaying ? "playing" : "paused"})');
387
- debugPrint('Current clip: ${_currentClip?.seed}, Next controller ready: ${_nextController != null}');
388
- }
389
- }
390
- }
391
-
392
- Future<void> _initializePlayer() async {
393
- if (_isDisposed) return;
394
-
395
- setState(() => _isLoading = true);
396
-
397
- if (_isInitialLoad) {
398
- _startLoadingProgress();
399
- }
400
-
401
- _queueManager = ClipQueueManager(
402
- video: widget.video,
403
- onQueueUpdated: () {
404
- _onQueueUpdated();
405
- // Check buffer status whenever queue updates
406
- _checkBufferAndStartPlayback();
407
- },
408
- );
409
-
410
- // Initialize queue manager but don't await it
411
- _queueManager.initialize().then((_) {
412
- if (!_isDisposed) {
413
- _waitForClipAndPlay();
414
- }
415
- });
416
- }
417
-
418
- void _startLoadingProgress() {
419
- _progressTimer?.cancel();
420
- _loadingProgress = 0.0;
421
-
422
- const totalDuration = Duration(seconds: 12);
423
- const updateInterval = Duration(milliseconds: 50);
424
- final steps = totalDuration.inMilliseconds / updateInterval.inMilliseconds;
425
- final increment = 1.0 / steps;
426
-
427
- _progressTimer = Timer.periodic(updateInterval, (timer) {
428
- if (_isDisposed) {
429
- timer.cancel();
430
- return;
431
- }
432
-
433
- setState(() {
434
- _loadingProgress += increment;
435
- if (_loadingProgress >= 1.0) {
436
- _progressTimer?.cancel();
437
- }
438
- });
439
- });
440
- }
441
-
442
- void _onQueueUpdated() {
443
- if (_isDisposed) return;
444
- setState(() {});
445
- }
446
-
447
- Future<void> _waitForClipAndPlay() async {
448
- if (_isDisposed) return;
449
-
450
- try {
451
- // Start periodic buffer checks
452
- _checkBufferAndStartPlayback();
453
- } catch (e) {
454
- debugPrint('Error waiting for clip: $e');
455
- if (!_isDisposed) {
456
- ScaffoldMessenger.of(context).showSnackBar(
457
- SnackBar(content: Text('Error loading video: $e')),
458
- );
459
- setState(() {
460
- _isLoading = false;
461
- _isInitialLoad = false;
462
- });
463
- }
464
- }
465
- }
466
-
467
- // New method to ensure buffer stays full
468
- void _ensureBufferFull() {
469
- if (_isDisposed) return;
470
- // debugPrint('Ensuring buffer is full...');
471
- // Let the queue manager know it should start new generations
472
- // This will trigger generation of new clips up to capacity
473
- _queueManager.fillBuffer();
474
- }
475
-
476
- Future<void> _preloadNextClip() async {
477
- if (_isDisposed) return;
478
-
479
- try {
480
- // Always try to preload the next ready clip
481
- final nextReadyClip = _queueManager.nextReadyClip;
482
-
483
- if (nextReadyClip?.base64Data != null &&
484
- nextReadyClip != _currentClip &&
485
- !nextReadyClip!.isPlaying) {
486
- // debugPrint('Preloading next clip (seed: ${nextReadyClip.seed})');
487
-
488
- final nextController = VideoPlayerController.networkUrl(
489
- Uri.parse(nextReadyClip.base64Data!),
490
- );
491
-
492
- await nextController.initialize();
493
-
494
- if (_isDisposed) {
495
- nextController.dispose();
496
- return;
497
- }
498
-
499
- // we always keep things looping. We never want any video to stop.
500
- nextController.setLooping(true);
501
- nextController.setVolume(0.0);
502
- nextController.setPlaybackSpeed(Configuration.instance.clipPlaybackSpeed);
503
-
504
- _nextController?.dispose();
505
- _nextController = nextController;
506
-
507
- // debugPrint('Successfully preloaded next clip');
508
- }
509
-
510
- // Always ensure we're generating new clips after preloading
511
- _ensureBufferFull();
512
-
513
- } catch (e) {
514
- debugPrint('Error preloading next clip: $e');
515
- _nextController?.dispose();
516
- _nextController = null;
517
- }
518
- }
519
-
520
- @override
521
- Widget build(BuildContext context) {
522
- return LayoutBuilder(
523
- builder: (context, constraints) {
524
- final controller = _currentController;
525
- final aspectRatio = controller?.value.aspectRatio ?? 16/9;
526
- final playerHeight = constraints.maxWidth / aspectRatio;
527
-
528
- return SizedBox(
529
- width: constraints.maxWidth,
530
- height: playerHeight,
531
- child: Stack(
532
- fit: StackFit.expand,
533
- children: [
534
- // Base layer: Placeholder or Video
535
- ClipRRect(
536
- borderRadius: BorderRadius.circular(widget.borderRadius),
537
- child: Container(
538
- color: AiTubeColors.surfaceVariant,
539
- child: controller?.value.isInitialized ?? false
540
- ? VideoPlayer(controller!)
541
- : _buildPlaceholder(),
542
- ),
543
- ),
544
-
545
-
546
-
547
- // Play/Pause button overlay
548
- if (controller?.value.isInitialized ?? false)
549
- Positioned(
550
- left: 16,
551
- bottom: 16,
552
- child: GestureDetector(
553
- onTap: _togglePlayback,
554
- child: Container(
555
- padding: const EdgeInsets.all(8),
556
- decoration: BoxDecoration(
557
- color: Colors.black.withOpacity(0.6),
558
- borderRadius: BorderRadius.circular(24),
559
- ),
560
- child: Icon(
561
- _isPlaying ? Icons.pause : Icons.play_arrow,
562
- color: Colors.white,
563
- size: 24,
564
- ),
565
- ),
566
- ),
567
- ),
568
-
569
- _buildBufferStatus(true),
570
- // Debug stats overlay
571
- /*
572
- if (kDebugMode)
573
- Positioned(
574
- right: 16,
575
- top: 16,
576
- child: Container(
577
- padding: const EdgeInsets.all(8),
578
- decoration: BoxDecoration(
579
- color: Colors.black.withOpacity(0.6),
580
- borderRadius: BorderRadius.circular(8),
581
- ),
582
- child: Text(
583
- _formatQueueStats(),
584
- style: const TextStyle(
585
- color: Colors.white,
586
- fontSize: 12,
587
- ),
588
- ),
589
- ),
590
- ),
591
- */
592
- ],
593
- ),
594
- );
595
- },
596
- );
597
- }
598
-
599
- String _formatQueueStats() {
600
- final stats = _queueManager.getBufferStats();
601
- final currentClipInfo = _currentClip != null
602
- ? '\nPlaying: ${_currentClip!.seed}'
603
- : '';
604
- final nextClipInfo = _nextController != null
605
- ? '\nNext clip preloaded'
606
- : '';
607
-
608
- return 'Queue: ${stats['readyClips']}/${stats['bufferSize']} ready\n'
609
- 'Gen: ${stats['activeGenerations']} active'
610
- '$currentClipInfo$nextClipInfo';
611
- }
612
-
613
- Widget _buildPlaceholder() {
614
- // Use our new AI Content Disclaimer widget as the placeholder
615
- if (widget.initialThumbnailUrl?.isEmpty ?? true) {
616
- // Set isInteractive to true as we generate content on-the-fly
617
- return const AiContentDisclaimer(isInteractive: true);
618
- }
619
-
620
- try {
621
- if (widget.initialThumbnailUrl!.startsWith('data:image')) {
622
- final uri = Uri.parse(widget.initialThumbnailUrl!);
623
- final base64Data = uri.data?.contentAsBytes();
624
-
625
- if (base64Data == null) {
626
- throw Exception('Invalid image data');
627
- }
628
-
629
- return Image.memory(
630
- base64Data,
631
- fit: BoxFit.cover,
632
- errorBuilder: (_, __, ___) => _buildErrorPlaceholder(),
633
- );
634
- }
635
-
636
- return Image.network(
637
- widget.initialThumbnailUrl!,
638
- fit: BoxFit.cover,
639
- errorBuilder: (_, __, ___) => _buildErrorPlaceholder(),
640
- );
641
- } catch (e) {
642
- return _buildErrorPlaceholder();
643
- }
644
- }
645
-
646
- Widget _buildBufferStatus(bool showDuringLoading) {
647
- final readyOrPlayingClips = _queueManager.clipBuffer.where((c) => c.isReady || c.isPlaying).length;
648
- final totalClips = _queueManager.clipBuffer.length;
649
- final bufferPercentage = (readyOrPlayingClips / totalClips * 100).round();
650
-
651
- // since we are playing clips at a reduced speed, they each last "longer"
652
- // eg a video playing back at 0.5 speed will see its duration multiplied by 2
653
- final actualDurationPerClip = Configuration.instance.actualClipPlaybackDuration;
654
-
655
- final remainingSeconds = readyOrPlayingClips * actualDurationPerClip.inSeconds;
656
-
657
- // Don't show 0% during initial loading
658
- if (!showDuringLoading && bufferPercentage == 0) {
659
- return const SizedBox.shrink();
660
- }
661
-
662
- return Positioned(
663
- right: 16,
664
- bottom: 16,
665
- child: Container(
666
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
667
- decoration: BoxDecoration(
668
- color: Colors.black.withOpacity(0.6),
669
- borderRadius: BorderRadius.circular(16),
670
- ),
671
- child: Row(
672
- mainAxisSize: MainAxisSize.min,
673
- children: [
674
- Icon(
675
- _getBufferIcon(bufferPercentage),
676
- color: _getBufferStatusColor(bufferPercentage),
677
- size: 16,
678
- ),
679
- const SizedBox(width: 4),
680
- Text(
681
- _isLoading
682
- ? 'Buffering $bufferPercentage%'
683
- : '$bufferPercentage% (${remainingSeconds}s)',
684
- style: const TextStyle(
685
- color: Colors.white,
686
- fontSize: 12,
687
- fontWeight: FontWeight.bold,
688
- ),
689
- ),
690
- ],
691
- ),
692
- ),
693
- );
694
- }
695
-
696
- IconData _getBufferIcon(int percentage) {
697
- if (percentage >= 40) return Icons.network_wifi;
698
- if (percentage >= 30) return Icons.network_wifi_3_bar;
699
- if (percentage >= 20) return Icons.network_wifi_2_bar;
700
- return Icons.network_wifi_1_bar;
701
- }
702
-
703
- Color _getBufferStatusColor(int percentage) {
704
- if (percentage >= 30) return Colors.green;
705
- if (percentage >= 20) return Colors.orange;
706
- return Colors.red;
707
- }
708
-
709
- Widget _buildErrorPlaceholder() {
710
- return const Center(
711
- child: Icon(
712
- Icons.broken_image,
713
- size: 64,
714
- color: AiTubeColors.onSurfaceVariant,
715
- ),
716
- );
717
- }
718
- }
 
1
  // lib/widgets/video_player_widget.dart
2
+ // This file is now a re-export of the refactored video player module
3
+ // It maintains backward compatibility with existing imports
4
 
5
+ export 'video_player/index.dart';