jbilcke-hf HF Staff commited on
Commit
6c71972
·
1 Parent(s): 83f2577

protection against gpu skimmers, free-compute scammers and other grifters

Browse files
api.py CHANGED
@@ -296,6 +296,10 @@ async def status_handler(request: web.Request) -> web.Response:
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
  async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
300
  # Check if maintenance mode is enabled
301
  if MAINTENANCE_MODE:
@@ -321,6 +325,32 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
321
  user_role = await api.validate_user_token(hf_token)
322
  logger.info(f"User connected with role: {user_role}")
323
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  # Store the user role in the websocket
325
  ws.user_role = user_role
326
 
@@ -372,6 +402,16 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
372
  await asyncio.gather(*background_tasks, return_exceptions=True)
373
  except asyncio.CancelledError:
374
  pass
 
 
 
 
 
 
 
 
 
 
375
 
376
  return ws
377
 
 
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
305
  if MAINTENANCE_MODE:
 
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')
330
+ if peername is not None:
331
+ client_ip = peername[0]
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
 
 
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'):
408
+ client_ip = ws.client_ip
409
+ async with anon_connection_lock:
410
+ if client_ip in anon_connections:
411
+ anon_connections[client_ip] = max(0, anon_connections[client_ip] - 1)
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
 
api_config.py CHANGED
@@ -56,12 +56,9 @@ CONFIG_FOR_ANONYMOUS_USERS = {
56
  # anons can only watch 2 minutes per video
57
  "max_rendering_time_per_client_per_video_in_sec": 2 * 60,
58
 
59
- "max_buffer_size": 2,
60
- "max_concurrent_generations": 2,
61
-
62
  "min_num_inference_steps": 2,
63
  "default_num_inference_steps": 3,
64
- "max_num_inference_steps": 4,
65
 
66
  "min_num_frames": 9, # 8 + 1
67
  "default_max_num_frames": 65, # 8*8 + 1
@@ -92,81 +89,73 @@ CONFIG_FOR_ANONYMOUS_USERS = {
92
  CONFIG_FOR_STANDARD_HF_USERS = {
93
  "max_rendering_time_per_client_per_video_in_sec": 15 * 60,
94
 
95
- "max_buffer_size": 2,
96
- "max_concurrent_generations": 2,
97
-
98
  "min_num_inference_steps": 2,
99
  "default_num_inference_steps": 4,
100
- "max_num_inference_steps": 6,
101
 
102
  "min_num_frames": 9, # 8 + 1
103
  "default_num_frames": 65, # 8*8 + 1
104
- "max_num_frames": 65, # 8*8 + 1
105
 
106
  "min_clip_duration_seconds": 1,
107
  "default_clip_duration_seconds": 2,
108
  "max_clip_duration_seconds": 2,
109
 
110
- "min_clip_playback_speed": 0.7,
111
- "default_clip_playback_speed": 0.8,
112
- "max_clip_playback_speed": 0.8,
113
 
114
  "min_clip_framerate": 8,
115
  "default_clip_framerate": 25,
116
  "max_clip_framerate": 25,
117
 
118
  "min_clip_width": 544,
119
- "default_clip_width": 640,
120
- "max_clip_width": 640,
121
 
122
  "min_clip_height": 320,
123
- "default_clip_height": 416,
124
- "max_clip_height": 416,
125
  }
126
 
127
  # Hugging Face users with a Pro may enjoy an improved experience
128
  CONFIG_FOR_PRO_HF_USERS = {
129
  "max_rendering_time_per_client_per_video_in_sec": 20 * 60,
130
 
131
- "max_buffer_size": 2,
132
- "max_concurrent_generations": 2,
133
-
134
  "min_num_inference_steps": 2,
135
- "max_num_inference_steps": 8,
136
-
 
137
  "min_num_frames": 9, # 8 + 1
138
- "default_num_frames": 97, # (8*12) + 1
139
- "max_num_frames": 97, # (8*12) + 1
140
 
141
- "min_clip_duration_seconds": 1,
142
  "default_clip_duration_seconds": 2,
143
- "max_clip_duration_seconds": 3,
144
 
145
- "min_clip_playback_speed": 0.7,
146
- "default_clip_playback_speed": 0.8,
147
- "max_clip_playback_speed": 0.8,
148
 
149
  "min_clip_framerate": 8,
150
  "default_clip_framerate": 25,
151
- "max_clip_framerate": 30,
152
 
153
  "min_clip_width": 544,
154
- "default_clip_width": 768,
155
- "max_clip_width": 768,
156
 
157
  "min_clip_height": 320,
158
- "default_clip_height": 480,
159
- "max_clip_height": 480,
160
  }
161
 
162
  CONFIG_FOR_ADMIN_HF_USERS = {
163
  "max_rendering_time_per_client_per_video_in_sec": 60 * 60,
164
 
165
- "max_buffer_size": 2,
166
- "max_concurrent_generations": 2,
167
-
168
  "min_num_inference_steps": 2,
169
- "default_num_inference_steps": 4,
170
  "max_num_inference_steps": 8,
171
 
172
  "min_num_frames": 9, # 8 + 1
 
56
  # anons can only watch 2 minutes per video
57
  "max_rendering_time_per_client_per_video_in_sec": 2 * 60,
58
 
 
 
 
59
  "min_num_inference_steps": 2,
60
  "default_num_inference_steps": 3,
61
+ "max_num_inference_steps": 3,
62
 
63
  "min_num_frames": 9, # 8 + 1
64
  "default_max_num_frames": 65, # 8*8 + 1
 
89
  CONFIG_FOR_STANDARD_HF_USERS = {
90
  "max_rendering_time_per_client_per_video_in_sec": 15 * 60,
91
 
 
 
 
92
  "min_num_inference_steps": 2,
93
  "default_num_inference_steps": 4,
94
+ "max_num_inference_steps": 4,
95
 
96
  "min_num_frames": 9, # 8 + 1
97
  "default_num_frames": 65, # 8*8 + 1
98
+ "max_num_frames": 65,
99
 
100
  "min_clip_duration_seconds": 1,
101
  "default_clip_duration_seconds": 2,
102
  "max_clip_duration_seconds": 2,
103
 
104
+ "min_clip_playback_speed": 0.65,
105
+ "default_clip_playback_speed": 0.65,
106
+ "max_clip_playback_speed": 0.65,
107
 
108
  "min_clip_framerate": 8,
109
  "default_clip_framerate": 25,
110
  "max_clip_framerate": 25,
111
 
112
  "min_clip_width": 544,
113
+ "default_clip_width": 768, # 640,
114
+ "max_clip_width": 768, # 640,
115
 
116
  "min_clip_height": 320,
117
+ "default_clip_height": 480, # 416,
118
+ "max_clip_height": 480, # 416,
119
  }
120
 
121
  # Hugging Face users with a Pro may enjoy an improved experience
122
  CONFIG_FOR_PRO_HF_USERS = {
123
  "max_rendering_time_per_client_per_video_in_sec": 20 * 60,
124
 
 
 
 
125
  "min_num_inference_steps": 2,
126
+ "default_num_inference_steps": 4,
127
+ "max_num_inference_steps": 4,
128
+
129
  "min_num_frames": 9, # 8 + 1
130
+ "default_num_frames": 65, # 8*8 + 1
131
+ "max_num_frames": 65,
132
 
133
+ "min_clip_duration_seconds": 1,
134
  "default_clip_duration_seconds": 2,
135
+ "max_clip_duration_seconds": 2,
136
 
137
+ "min_clip_playback_speed": 0.65,
138
+ "default_clip_playback_speed": 0.65,
139
+ "max_clip_playback_speed": 0.65,
140
 
141
  "min_clip_framerate": 8,
142
  "default_clip_framerate": 25,
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": 480, # 416,
151
+ "max_clip_height": 480, # 416,
152
  }
153
 
154
  CONFIG_FOR_ADMIN_HF_USERS = {
155
  "max_rendering_time_per_client_per_video_in_sec": 60 * 60,
156
 
 
 
 
157
  "min_num_inference_steps": 2,
158
+ "default_num_inference_steps": 6,
159
  "max_num_inference_steps": 8,
160
 
161
  "min_num_frames": 9, # 8 + 1
api_core.py CHANGED
@@ -296,14 +296,21 @@ class VideoGenerationAPI:
296
 
297
  async def search_video(self, query: str, search_count: int = 0, attempt_count: int = 0) -> Optional[dict]:
298
  """Generate a single search result using HF text generation"""
299
- prompt = f"""# Instruction
300
- Your response MUST be a YAML object containing a title, description, and tags, consistent with what we can find on a video sharing platform.
301
- Format your YAML response with only those fields: "title" (a short string), "description" (string caption of the scene), and "tags" (array of 3 to 4 strings). Do not add any other field.
302
- In the description field, describe in a very synthetic way the visuals of the first shot (first scene), eg "<STYLE>, medium close-up shot, high angle view of a <AGE>yo <GENDER> <CHARACTERS> <ACTIONS>, <LOCATION> <LIGHTING> <WEATHER>". Keep it minimalist but still descriptive, don't use bullets points, use simple words, go to the essential to describe style (cinematic, documentary footage, 3D rendering..), camera modes and angles, characters, age, gender, action, location, lighting, country, costume, time, weather, textures, color palette.. etc.
 
 
 
 
 
 
 
303
  Make the result unique and different from previous search results. ONLY RETURN YAML AND WITH ENGLISH CONTENT, NOT CHINESE - DO NOT ADD ANY OTHER COMMENT!
304
 
305
  # Context
306
- This is attempt {attempt_count} at generating search result number {search_count}.
307
 
308
  # Input
309
  Describe the first scene/shot for: "{query}".
@@ -313,74 +320,102 @@ Describe the first scene/shot for: "{query}".
313
  ```yaml
314
  title: \""""
315
 
316
- try:
317
- print(f"search_video(): calling self.inference_client.text_generation({prompt}, model={TEXT_MODEL}, max_new_tokens=300, temperature=0.65)")
318
- response = await asyncio.get_event_loop().run_in_executor(
319
- None,
320
- lambda: self.inference_client.text_generation(
321
- prompt,
322
- model=TEXT_MODEL,
323
- max_new_tokens=330,
324
- temperature=0.6
 
325
  )
326
- )
327
 
328
- #print("response: ", response)
329
-
330
- response_text = re.sub(r'^\s*\.\s*\n', '', f"title: \"{response.strip()}")
331
- sanitized_yaml = sanitize_yaml_response(response_text)
332
-
333
- try:
334
- result = yaml.safe_load(sanitized_yaml)
335
- except yaml.YAMLError as e:
336
- logger.error(f"YAML parsing failed: {str(e)}")
337
- result = None
338
-
339
- if not result or not isinstance(result, dict):
340
- logger.error(f"Invalid result format: {result}")
341
- return None
342
-
343
- # Extract fields with defaults
344
- title = str(result.get('title', '')).strip() or 'Untitled Video'
345
- description = str(result.get('description', '')).strip() or 'No description available'
346
- tags = result.get('tags', [])
347
-
348
- # Ensure tags is a list of strings
349
- if not isinstance(tags, list):
350
- tags = []
351
- tags = [str(t).strip() for t in tags if t and isinstance(t, (str, int, float))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- # Generate thumbnail
354
- #print(f"calling self.generate_thumbnail({title}, {description})")
355
- try:
356
- #thumbnail = await self.generate_thumbnail(title, description)
357
- raise ValueError("thumbnail generation is too buggy and slow right now")
358
- except Exception as e:
359
- logger.error(f"Thumbnail generation failed: {str(e)}")
360
- thumbnail = ""
361
 
362
- print("got response thumbnail")
363
- # Return valid result with all required fields
364
- return {
365
- 'id': str(uuid.uuid4()),
366
- 'title': title,
367
- 'description': description,
368
- 'thumbnailUrl': thumbnail,
369
- 'videoUrl': '',
370
 
371
- # not really used yet, maybe one day if we pre-generate or store content
372
- 'isLatent': True,
373
 
374
- 'useFixedSeed': "webcam" in description.lower(),
375
 
376
- 'seed': generate_seed(),
377
- 'views': 0,
378
- 'tags': tags
379
- }
380
 
381
- except Exception as e:
382
- logger.error(f"Search video generation failed: {str(e)}")
383
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
  async def generate_thumbnail(self, title: str, description: str) -> str:
386
  """Generate thumbnail using HF image generation"""
@@ -652,7 +687,7 @@ Your caption:"""
652
  # Consider 17 or 18 to be visually lossless or nearly so;
653
  # it should look the same or nearly the same as the input but it isn't technically lossless.
654
  # 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.
655
- #"quality": 18,
656
 
657
  }
658
  }
 
296
 
297
  async def search_video(self, query: str, search_count: int = 0, attempt_count: int = 0) -> Optional[dict]:
298
  """Generate a single search result using HF text generation"""
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
306
+ Your response MUST be a YAML object containing a title and description, consistent with what we can find on a video sharing platform.
307
+ Format your YAML response with only those fields: "title" (a short string) and "description" (string caption of the scene). Do not add any other field.
308
+ In the description field, describe in a very synthetic way the visuals of the first shot (first scene), eg "<STYLE>, medium close-up shot, high angle view of a <AGE>yo <GENDER> <CHARACTERS> <ACTIONS>, <LOCATION> <LIGHTING> <WEATHER>". This is just an example! you MUST replace the <TAGS>!!. Don't forget to replace <STYLE> etc, by the actual fields!! Keep it minimalist but still descriptive, don't use bullets points, use simple words, go to the essential to describe style (cinematic, documentary footage, 3D rendering..), camera modes and angles, characters, age, gender, action, location, lighting, country, costume, time, weather, textures, color palette.. etc.
309
+ The most import part is to describe the actions and movements in the scene, so don't forget that!
310
  Make the result unique and different from previous search results. ONLY RETURN YAML AND WITH ENGLISH CONTENT, NOT CHINESE - DO NOT ADD ANY OTHER COMMENT!
311
 
312
  # Context
313
+ This is attempt {current_attempt} at generating search result number {search_count}.
314
 
315
  # Input
316
  Describe the first scene/shot for: "{query}".
 
320
  ```yaml
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(
328
+ prompt,
329
+ model=TEXT_MODEL,
330
+ max_new_tokens=150,
331
+ temperature=temperature
332
+ )
333
  )
 
334
 
335
+ response_text = re.sub(r'^\s*\.\s*\n', '', f"title: \"{response.strip()}")
336
+ sanitized_yaml = sanitize_yaml_response(response_text)
337
+
338
+ try:
339
+ result = yaml.safe_load(sanitized_yaml)
340
+ except yaml.YAMLError as e:
341
+ logger.error(f"YAML parsing failed: {str(e)}")
342
+ result = None
343
+
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
351
+ title = str(result.get('title', '')).strip() or 'Untitled Video'
352
+ description = str(result.get('description', '')).strip() or 'No description available'
353
+
354
+ # Check if the description still contains placeholder tags like <LOCATION>, <GENDER>, etc.
355
+ if re.search(r'<[A-Z_]+>', description):
356
+ logger.warning(f"Description still contains placeholder tags: {description}")
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
364
+ description = title
365
+
366
+ # legacy system of tags -- I've decided to to generate them anymore to save some speed
367
+ tags = result.get('tags', [])
368
+
369
+ # Ensure tags is a list of strings
370
+ if not isinstance(tags, list):
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()),
386
+ 'title': title,
387
+ 'description': description,
388
+ 'thumbnailUrl': thumbnail,
389
+ 'videoUrl': '',
390
 
391
+ # not really used yet, maybe one day if we pre-generate or store content
392
+ 'isLatent': True,
393
 
394
+ 'useFixedSeed': "webcam" in description.lower(),
395
 
396
+ 'seed': generate_seed(),
397
+ 'views': 0,
398
+ 'tags': tags
399
+ }
400
 
401
+ except Exception as e:
402
+ logger.error(f"Search video generation failed: {str(e)}")
403
+ current_attempt += 1
404
+ temperature = 0.7 # Try with different temperature on next attempt
405
+
406
+ # If all attempts failed, return a simple result with title only
407
+ return {
408
+ 'id': str(uuid.uuid4()),
409
+ 'title': f"Video about {query}",
410
+ 'description': f"Video about {query}",
411
+ 'thumbnailUrl': "",
412
+ 'videoUrl': '',
413
+ 'isLatent': True,
414
+ 'useFixedSeed': False,
415
+ 'seed': generate_seed(),
416
+ 'views': 0,
417
+ 'tags': []
418
+ }
419
 
420
  async def generate_thumbnail(self, title: str, description: str) -> str:
421
  """Generate thumbnail using HF image generation"""
 
687
  # Consider 17 or 18 to be visually lossless or nearly so;
688
  # it should look the same or nearly the same as the input but it isn't technically lossless.
689
  # 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.
690
+ "quality": 23,
691
 
692
  }
693
  }
build/web/assets/assets/config/aitube_low.yaml CHANGED
@@ -7,10 +7,10 @@ render_queue:
7
  buffer_size: 3
8
 
9
  # how many requests for clips can be run in parallel
10
- max_concurrent_generations: 2
11
 
12
  # start playback as soon as we have 1 video over 3 (25%)
13
- minimum_buffer_percent_to_start_playback: 20
14
 
15
  video:
16
 
@@ -19,7 +19,7 @@ video:
19
  transition_buffer_duration_ms: 300
20
 
21
  # how long a generated clip should be, in Duration
22
- original_clip_duration_seconds: 4
23
 
24
  # The model works on resolutions that are divisible by 32
25
  # and number of frames that are divisible by 8 + 1 (e.g. 257).
 
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:
16
 
 
19
  transition_buffer_duration_ms: 300
20
 
21
  # how long a generated clip should be, in Duration
22
+ original_clip_duration_seconds: 3
23
 
24
  # The model works on resolutions that are divisible by 32
25
  # and number of frames that are divisible by 8 + 1 (e.g. 257).
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: "2175777042"
43
  }
44
  });
 
39
 
40
  _flutter.loader.load({
41
  serviceWorkerSettings: {
42
+ serviceWorkerVersion: "4223262512"
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": "87126968851498701a21880e28da0cfc",
7
  "version.json": "b5eaae4fc120710a3c35125322173615",
8
  "index.html": "f34c56fffc6b38f62412a5db2315dec8",
9
  "/": "f34c56fffc6b38f62412a5db2315dec8",
10
- "main.dart.js": "6906a64b0fc86e1254324745be115bab",
11
  "flutter.js": "83d881c1dbb6d6bcd6b42e274605b69c",
12
  "favicon.png": "5dcef449791fa27946b3d35ad8803796",
13
  "icons/Icon-192.png": "ac9a721a12bbc803b44f645561ecb1e1",
@@ -22,12 +22,12 @@ const RESOURCES = {"flutter_bootstrap.js": "87126968851498701a21880e28da0cfc",
22
  "assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "33b7d9392238c04c131b6ce224e13711",
23
  "assets/shaders/ink_sparkle.frag": "ecc85a2e95f5e9f53123dcaf8cb9b6ce",
24
  "assets/AssetManifest.bin": "5894fe5676e62dc22403a833f2313e43",
25
- "assets/fonts/MaterialIcons-Regular.otf": "f7c7cc97f118137db94cf3e17143bf62",
26
  "assets/assets/config/private.yaml": "97a9ec367206bea5dce64faf94b66332",
27
  "assets/assets/config/README.md": "07a87720dd00dd1ca98c9d6884440e31",
28
  "assets/assets/config/aitube_high.yaml": "c030f221344557ecf05aeef30f224502",
29
  "assets/assets/config/default.yaml": "e98187e5a53a8b0d8bf2c5cd9dd0a365",
30
- "assets/assets/config/aitube_low.yaml": "45750c9b2982d06eff21bd302ed0f8c4",
31
  "canvaskit/skwasm.js": "ea559890a088fe28b4ddf70e17e60052",
32
  "canvaskit/skwasm.js.symbols": "9fe690d47b904d72c7d020bd303adf16",
33
  "canvaskit/canvaskit.js.symbols": "27361387bc24144b46a745f1afe92b50",
 
3
  const TEMP = 'flutter-temp-cache';
4
  const CACHE_NAME = 'flutter-app-cache';
5
 
6
+ const RESOURCES = {"flutter_bootstrap.js": "c93f6411e1218a720bdf1a024b693aa2",
7
  "version.json": "b5eaae4fc120710a3c35125322173615",
8
  "index.html": "f34c56fffc6b38f62412a5db2315dec8",
9
  "/": "f34c56fffc6b38f62412a5db2315dec8",
10
+ "main.dart.js": "006fa064c19ba19242dda13fb6745d69",
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": "5894fe5676e62dc22403a833f2313e43",
25
+ "assets/fonts/MaterialIcons-Regular.otf": "5dbbac66f1ff997124c876ea5f0a049d",
26
  "assets/assets/config/private.yaml": "97a9ec367206bea5dce64faf94b66332",
27
  "assets/assets/config/README.md": "07a87720dd00dd1ca98c9d6884440e31",
28
  "assets/assets/config/aitube_high.yaml": "c030f221344557ecf05aeef30f224502",
29
  "assets/assets/config/default.yaml": "e98187e5a53a8b0d8bf2c5cd9dd0a365",
30
+ "assets/assets/config/aitube_low.yaml": "c8f3c74fd45676ea6402c9fcfc1292f3",
31
  "canvaskit/skwasm.js": "ea559890a088fe28b4ddf70e17e60052",
32
  "canvaskit/skwasm.js.symbols": "9fe690d47b904d72c7d020bd303adf16",
33
  "canvaskit/canvaskit.js.symbols": "27361387bc24144b46a745f1afe92b50",
build/web/main.dart.js CHANGED
The diff for this file is too large to render. See raw diff
 
lib/screens/home_screen.dart CHANGED
@@ -10,6 +10,7 @@ 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/widgets/video_card.dart';
14
  import 'package:aitube2/widgets/search_box.dart';
15
  import 'package:aitube2/theme/colors.dart';
@@ -36,9 +37,28 @@ class _HomeScreenState extends State<HomeScreen> {
36
  StreamSubscription? _searchSubscription;
37
  static const int maxResults = 4;
38
 
 
 
 
 
39
  @override
40
  void initState() {
41
  super.initState();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  _initializeWebSocket();
43
  _setupSearchListener();
44
 
@@ -107,6 +127,14 @@ class _HomeScreenState extends State<HomeScreen> {
107
  Future<void> _initializeWebSocket() async {
108
  try {
109
  await _websocketService.connect();
 
 
 
 
 
 
 
 
110
  } catch (e) {
111
  if (mounted) {
112
  ScaffoldMessenger.of(context).showSnackBar(
@@ -122,6 +150,155 @@ class _HomeScreenState extends State<HomeScreen> {
122
  }
123
  }
124
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  Widget _buildConnectionStatus() {
127
  return StreamBuilder<ConnectionStatus>(
@@ -357,6 +534,8 @@ class _HomeScreenState extends State<HomeScreen> {
357
  @override
358
  void dispose() {
359
  _searchSubscription?.cancel();
 
 
360
  _searchController.dispose();
361
  _websocketService.dispose();
362
  super.dispose();
 
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';
16
  import 'package:aitube2/theme/colors.dart';
 
37
  StreamSubscription? _searchSubscription;
38
  static const int maxResults = 4;
39
 
40
+ // Subscription for limit status
41
+ StreamSubscription? _anonLimitSubscription;
42
+ StreamSubscription? _deviceLimitSubscription;
43
+
44
  @override
45
  void initState() {
46
  super.initState();
47
+
48
+ // Listen for changes to anonymous limit status
49
+ _anonLimitSubscription = _websocketService.anonLimitStream.listen((exceeded) {
50
+ if (exceeded && mounted) {
51
+ _showAnonLimitExceededDialog();
52
+ }
53
+ });
54
+
55
+ // Listen for changes to device limit status (for VIP users on web)
56
+ _deviceLimitSubscription = _websocketService.deviceLimitStream.listen((exceeded) {
57
+ if (exceeded && mounted) {
58
+ _showDeviceLimitExceededDialog();
59
+ }
60
+ });
61
+
62
  _initializeWebSocket();
63
  _setupSearchListener();
64
 
 
127
  Future<void> _initializeWebSocket() async {
128
  try {
129
  await _websocketService.connect();
130
+
131
+ // Check if anonymous limit is exceeded
132
+ if (_websocketService.isAnonLimitExceeded) {
133
+ if (mounted) {
134
+ _showAnonLimitExceededDialog();
135
+ }
136
+ return;
137
+ }
138
  } catch (e) {
139
  if (mounted) {
140
  ScaffoldMessenger.of(context).showSnackBar(
 
150
  }
151
  }
152
  }
153
+
154
+ void _showAnonLimitExceededDialog() async {
155
+ // Create a controller outside the dialog for easier access
156
+ final TextEditingController controller = TextEditingController();
157
+
158
+ final settings = await showDialog<String>(
159
+ context: context,
160
+ barrierDismissible: false,
161
+ builder: (BuildContext dialogContext) {
162
+ bool obscureText = true;
163
+
164
+ return StatefulBuilder(
165
+ builder: (context, setState) {
166
+ return AlertDialog(
167
+ title: const Text(
168
+ 'Connection Limit Reached',
169
+ style: TextStyle(
170
+ color: AiTubeColors.onBackground,
171
+ fontSize: 20,
172
+ fontWeight: FontWeight.bold,
173
+ ),
174
+ ),
175
+ content: Column(
176
+ mainAxisSize: MainAxisSize.min,
177
+ crossAxisAlignment: CrossAxisAlignment.start,
178
+ children: [
179
+ Text(
180
+ _websocketService.anonLimitMessage.isNotEmpty
181
+ ? _websocketService.anonLimitMessage
182
+ : 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!',
183
+ style: const TextStyle(color: AiTubeColors.onSurface),
184
+ ),
185
+ const SizedBox(height: 16),
186
+ const Text(
187
+ 'Enter your HuggingFace API token to continue:',
188
+ style: TextStyle(color: AiTubeColors.onSurface),
189
+ ),
190
+ const SizedBox(height: 8),
191
+ TextField(
192
+ controller: controller,
193
+ obscureText: obscureText,
194
+ decoration: InputDecoration(
195
+ labelText: 'API Key',
196
+ labelStyle: const TextStyle(color: AiTubeColors.onSurfaceVariant),
197
+ suffixIcon: IconButton(
198
+ icon: Icon(
199
+ obscureText ? Icons.visibility : Icons.visibility_off,
200
+ color: AiTubeColors.onSurfaceVariant,
201
+ ),
202
+ onPressed: () => setState(() => obscureText = !obscureText),
203
+ ),
204
+ ),
205
+ onSubmitted: (value) {
206
+ Navigator.pop(dialogContext, value);
207
+ },
208
+ ),
209
+ ],
210
+ ),
211
+ backgroundColor: AiTubeColors.surface,
212
+ actions: [
213
+ TextButton(
214
+ onPressed: () => Navigator.pop(dialogContext),
215
+ child: const Text(
216
+ 'Cancel',
217
+ style: TextStyle(color: AiTubeColors.onSurfaceVariant),
218
+ ),
219
+ ),
220
+ FilledButton(
221
+ onPressed: () => Navigator.pop(dialogContext, controller.text),
222
+ style: FilledButton.styleFrom(
223
+ backgroundColor: AiTubeColors.primary,
224
+ ),
225
+ child: const Text('Save'),
226
+ ),
227
+ ],
228
+ );
229
+ }
230
+ );
231
+ },
232
+ );
233
+
234
+ // Clean up the controller
235
+ controller.dispose();
236
+
237
+ // If user provided an API key, save it and retry connection
238
+ if (settings != null && settings.isNotEmpty) {
239
+ // Save the API key
240
+ final settingsService = SettingsService();
241
+ await settingsService.setHuggingfaceApiKey(settings);
242
+
243
+ // Retry connection
244
+ if (mounted) {
245
+ _initializeWebSocket();
246
+ }
247
+ }
248
+ }
249
+
250
+ void _showDeviceLimitExceededDialog() async {
251
+ await showDialog<void>(
252
+ context: context,
253
+ barrierDismissible: false,
254
+ builder: (BuildContext dialogContext) {
255
+ return AlertDialog(
256
+ title: const Text(
257
+ 'Too Many Connections',
258
+ style: TextStyle(
259
+ color: AiTubeColors.onBackground,
260
+ fontSize: 20,
261
+ fontWeight: FontWeight.bold,
262
+ ),
263
+ ),
264
+ content: Column(
265
+ mainAxisSize: MainAxisSize.min,
266
+ crossAxisAlignment: CrossAxisAlignment.start,
267
+ children: [
268
+ Text(
269
+ _websocketService.deviceLimitMessage,
270
+ style: const TextStyle(color: AiTubeColors.onSurface),
271
+ ),
272
+ const SizedBox(height: 16),
273
+ const Text(
274
+ 'Please close some of your other browser tabs running AiTube to continue.',
275
+ style: TextStyle(color: AiTubeColors.onSurface),
276
+ ),
277
+ ],
278
+ ),
279
+ backgroundColor: AiTubeColors.surface,
280
+ actions: [
281
+ FilledButton(
282
+ onPressed: () {
283
+ Navigator.pop(dialogContext);
284
+
285
+ // Try to reconnect after dialog is closed
286
+ if (mounted) {
287
+ Future.delayed(const Duration(seconds: 1), () {
288
+ _initializeWebSocket();
289
+ });
290
+ }
291
+ },
292
+ style: FilledButton.styleFrom(
293
+ backgroundColor: AiTubeColors.primary,
294
+ ),
295
+ child: const Text('Try Again'),
296
+ ),
297
+ ],
298
+ );
299
+ },
300
+ );
301
+ }
302
 
303
  Widget _buildConnectionStatus() {
304
  return StreamBuilder<ConnectionStatus>(
 
534
  @override
535
  void dispose() {
536
  _searchSubscription?.cancel();
537
+ _anonLimitSubscription?.cancel();
538
+ _deviceLimitSubscription?.cancel();
539
  _searchController.dispose();
540
  _websocketService.dispose();
541
  super.dispose();
lib/screens/video_screen.dart CHANGED
@@ -1,4 +1,6 @@
1
  // lib/screens/video_screen.dart
 
 
2
  import 'package:aitube2/widgets/chat_widget.dart';
3
  import 'package:aitube2/widgets/search_box.dart';
4
  import 'package:aitube2/widgets/web_utils.dart';
@@ -8,6 +10,7 @@ import '../config/config.dart';
8
  import '../models/video_result.dart';
9
  import '../services/websocket_api_service.dart';
10
  import '../services/cache_service.dart';
 
11
  import '../theme/colors.dart';
12
  import '../widgets/video_player_widget.dart';
13
 
@@ -32,12 +35,31 @@ class _VideoScreenState extends State<VideoScreen> {
32
  final _searchController = TextEditingController();
33
  bool _isSearching = false;
34
 
 
 
 
 
35
  @override
36
  void initState() {
37
  super.initState();
38
  _videoData = widget.video;
39
  _searchController.text = _videoData.title;
40
  _websocketService.addSubscriber(widget.video.id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  _initializeConnection();
42
  _loadCachedThumbnail();
43
  }
@@ -54,6 +76,15 @@ class _VideoScreenState extends State<VideoScreen> {
54
  Future<void> _initializeConnection() async {
55
  try {
56
  await _websocketService.connect();
 
 
 
 
 
 
 
 
 
57
  if (mounted) {
58
  setState(() {
59
  _isConnected = true;
@@ -75,6 +106,155 @@ class _VideoScreenState extends State<VideoScreen> {
75
  }
76
  }
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  Future<String> _generateCaption() async {
80
  if (!_isConnected) {
@@ -146,7 +326,7 @@ class _VideoScreenState extends State<VideoScreen> {
146
  appBar: AppBar(
147
  titleSpacing: 0,
148
  title: Padding(
149
- padding: const EdgeInsets.only(right: 8),
150
  child: SearchBox(
151
  controller: _searchController,
152
  isSearching: _isSearching,
@@ -293,6 +473,8 @@ class _VideoScreenState extends State<VideoScreen> {
293
 
294
  // Cleanup other resources
295
  _searchController.dispose();
 
 
296
  super.dispose();
297
  }
298
 
 
1
  // lib/screens/video_screen.dart
2
+ import 'dart:async';
3
+
4
  import 'package:aitube2/widgets/chat_widget.dart';
5
  import 'package:aitube2/widgets/search_box.dart';
6
  import 'package:aitube2/widgets/web_utils.dart';
 
10
  import '../models/video_result.dart';
11
  import '../services/websocket_api_service.dart';
12
  import '../services/cache_service.dart';
13
+ import '../services/settings_service.dart';
14
  import '../theme/colors.dart';
15
  import '../widgets/video_player_widget.dart';
16
 
 
35
  final _searchController = TextEditingController();
36
  bool _isSearching = false;
37
 
38
+ // Subscription for limit statuses
39
+ StreamSubscription? _anonLimitSubscription;
40
+ StreamSubscription? _deviceLimitSubscription;
41
+
42
  @override
43
  void initState() {
44
  super.initState();
45
  _videoData = widget.video;
46
  _searchController.text = _videoData.title;
47
  _websocketService.addSubscriber(widget.video.id);
48
+
49
+ // Listen for changes to anonymous limit status
50
+ _anonLimitSubscription = _websocketService.anonLimitStream.listen((exceeded) {
51
+ if (exceeded && mounted) {
52
+ _showAnonLimitExceededDialog();
53
+ }
54
+ });
55
+
56
+ // Listen for changes to device limit status (for VIP users on web)
57
+ _deviceLimitSubscription = _websocketService.deviceLimitStream.listen((exceeded) {
58
+ if (exceeded && mounted) {
59
+ _showDeviceLimitExceededDialog();
60
+ }
61
+ });
62
+
63
  _initializeConnection();
64
  _loadCachedThumbnail();
65
  }
 
76
  Future<void> _initializeConnection() async {
77
  try {
78
  await _websocketService.connect();
79
+
80
+ // Check if anonymous limit is exceeded
81
+ if (_websocketService.isAnonLimitExceeded) {
82
+ if (mounted) {
83
+ _showAnonLimitExceededDialog();
84
+ }
85
+ return;
86
+ }
87
+
88
  if (mounted) {
89
  setState(() {
90
  _isConnected = true;
 
106
  }
107
  }
108
  }
109
+
110
+ void _showAnonLimitExceededDialog() async {
111
+ // Create a controller outside the dialog for easier access
112
+ final TextEditingController controller = TextEditingController();
113
+
114
+ final settings = await showDialog<String>(
115
+ context: context,
116
+ barrierDismissible: false,
117
+ builder: (BuildContext dialogContext) {
118
+ bool obscureText = true;
119
+
120
+ return StatefulBuilder(
121
+ builder: (context, setState) {
122
+ return AlertDialog(
123
+ title: const Text(
124
+ 'Connection Limit Reached',
125
+ style: TextStyle(
126
+ color: AiTubeColors.onBackground,
127
+ fontSize: 20,
128
+ fontWeight: FontWeight.bold,
129
+ ),
130
+ ),
131
+ content: Column(
132
+ mainAxisSize: MainAxisSize.min,
133
+ crossAxisAlignment: CrossAxisAlignment.start,
134
+ children: [
135
+ Text(
136
+ _websocketService.anonLimitMessage.isNotEmpty
137
+ ? _websocketService.anonLimitMessage
138
+ : 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!',
139
+ style: const TextStyle(color: AiTubeColors.onSurface),
140
+ ),
141
+ const SizedBox(height: 16),
142
+ const Text(
143
+ 'Enter your HuggingFace API token to continue:',
144
+ style: TextStyle(color: AiTubeColors.onSurface),
145
+ ),
146
+ const SizedBox(height: 8),
147
+ TextField(
148
+ controller: controller,
149
+ obscureText: obscureText,
150
+ decoration: InputDecoration(
151
+ labelText: 'API Key',
152
+ labelStyle: const TextStyle(color: AiTubeColors.onSurfaceVariant),
153
+ suffixIcon: IconButton(
154
+ icon: Icon(
155
+ obscureText ? Icons.visibility : Icons.visibility_off,
156
+ color: AiTubeColors.onSurfaceVariant,
157
+ ),
158
+ onPressed: () => setState(() => obscureText = !obscureText),
159
+ ),
160
+ ),
161
+ onSubmitted: (value) {
162
+ Navigator.pop(dialogContext, value);
163
+ },
164
+ ),
165
+ ],
166
+ ),
167
+ backgroundColor: AiTubeColors.surface,
168
+ actions: [
169
+ TextButton(
170
+ onPressed: () => Navigator.pop(dialogContext),
171
+ child: const Text(
172
+ 'Cancel',
173
+ style: TextStyle(color: AiTubeColors.onSurfaceVariant),
174
+ ),
175
+ ),
176
+ FilledButton(
177
+ onPressed: () => Navigator.pop(dialogContext, controller.text),
178
+ style: FilledButton.styleFrom(
179
+ backgroundColor: AiTubeColors.primary,
180
+ ),
181
+ child: const Text('Save'),
182
+ ),
183
+ ],
184
+ );
185
+ }
186
+ );
187
+ },
188
+ );
189
+
190
+ // Clean up the controller
191
+ controller.dispose();
192
+
193
+ // If user provided an API key, save it and retry connection
194
+ if (settings != null && settings.isNotEmpty) {
195
+ // Save the API key
196
+ final settingsService = SettingsService();
197
+ await settingsService.setHuggingfaceApiKey(settings);
198
+
199
+ // Retry connection
200
+ if (mounted) {
201
+ _initializeConnection();
202
+ }
203
+ }
204
+ }
205
+
206
+ void _showDeviceLimitExceededDialog() async {
207
+ await showDialog<void>(
208
+ context: context,
209
+ barrierDismissible: false,
210
+ builder: (BuildContext dialogContext) {
211
+ return AlertDialog(
212
+ title: const Text(
213
+ 'Too Many Connections',
214
+ style: TextStyle(
215
+ color: AiTubeColors.onBackground,
216
+ fontSize: 20,
217
+ fontWeight: FontWeight.bold,
218
+ ),
219
+ ),
220
+ content: Column(
221
+ mainAxisSize: MainAxisSize.min,
222
+ crossAxisAlignment: CrossAxisAlignment.start,
223
+ children: [
224
+ Text(
225
+ _websocketService.deviceLimitMessage,
226
+ style: const TextStyle(color: AiTubeColors.onSurface),
227
+ ),
228
+ const SizedBox(height: 16),
229
+ const Text(
230
+ 'Please close some of your other browser tabs running AiTube to continue.',
231
+ style: TextStyle(color: AiTubeColors.onSurface),
232
+ ),
233
+ ],
234
+ ),
235
+ backgroundColor: AiTubeColors.surface,
236
+ actions: [
237
+ FilledButton(
238
+ onPressed: () {
239
+ Navigator.pop(dialogContext);
240
+
241
+ // Try to reconnect after dialog is closed
242
+ if (mounted) {
243
+ Future.delayed(const Duration(seconds: 1), () {
244
+ _initializeConnection();
245
+ });
246
+ }
247
+ },
248
+ style: FilledButton.styleFrom(
249
+ backgroundColor: AiTubeColors.primary,
250
+ ),
251
+ child: const Text('Try Again'),
252
+ ),
253
+ ],
254
+ );
255
+ },
256
+ );
257
+ }
258
 
259
  Future<String> _generateCaption() async {
260
  if (!_isConnected) {
 
326
  appBar: AppBar(
327
  titleSpacing: 0,
328
  title: Padding(
329
+ padding: const EdgeInsets.all(8),
330
  child: SearchBox(
331
  controller: _searchController,
332
  isSearching: _isSearching,
 
473
 
474
  // Cleanup other resources
475
  _searchController.dispose();
476
+ _anonLimitSubscription?.cancel();
477
+ _deviceLimitSubscription?.cancel();
478
  super.dispose();
479
  }
480
 
lib/services/{null_html.dart → html_stub.dart} RENAMED
@@ -1,15 +1,47 @@
1
- /// Null implementation for html when not on web platform
2
- /// This file is imported conditionally when not running on web
3
 
4
  class Window {
5
  final Document document = Document();
6
  final History history = History();
7
  final Location location = Location();
 
8
 
9
  Stream<dynamic> get onBeforeUnload =>
10
  Stream.fromIterable([]).asBroadcastStream();
11
  }
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  class Document {
14
  String get visibilityState => 'visible';
15
 
@@ -25,8 +57,12 @@ class History {
25
 
26
  class Location {
27
  String get href => '';
 
 
 
 
 
28
  }
29
 
30
  // Exported instances
31
- final Window window = Window();
32
- final Document document = Document();
 
1
+ /// Stub implementation for dart:html when not on web platform
2
+ /// This file is imported when dart.library.html is not available
3
 
4
  class Window {
5
  final Document document = Document();
6
  final History history = History();
7
  final Location location = Location();
8
+ final Storage localStorage = Storage();
9
 
10
  Stream<dynamic> get onBeforeUnload =>
11
  Stream.fromIterable([]).asBroadcastStream();
12
  }
13
 
14
+ class Storage {
15
+ final Map<String, String> _storage = {};
16
+
17
+ String? operator [](String key) => _storage[key];
18
+
19
+ void operator []=(String key, String value) {
20
+ _storage[key] = value;
21
+ }
22
+
23
+ void clear() {
24
+ _storage.clear();
25
+ }
26
+
27
+ void removeItem(String key) {
28
+ _storage.remove(key);
29
+ }
30
+
31
+ String? getItem(String key) => _storage[key];
32
+
33
+ void setItem(String key, String value) {
34
+ _storage[key] = value;
35
+ }
36
+
37
+ int get length => _storage.length;
38
+
39
+ String key(int index) {
40
+ if (index < 0 || index >= _storage.length) return '';
41
+ return _storage.keys.elementAt(index);
42
+ }
43
+ }
44
+
45
  class Document {
46
  String get visibilityState => 'visible';
47
 
 
57
 
58
  class Location {
59
  String get href => '';
60
+ String get host => '';
61
+ String get hostname => '';
62
+ String get protocol => '';
63
+ String get search => '';
64
+ String get pathname => '';
65
  }
66
 
67
  // Exported instances
68
+ final Window window = Window();
 
lib/services/websocket_api_service.dart CHANGED
@@ -5,6 +5,8 @@ import 'package:http/http.dart' as http;
5
  import 'package:aitube2/services/settings_service.dart';
6
  import 'package:synchronized/synchronized.dart';
7
  import 'dart:convert';
 
 
8
  import 'package:aitube2/config/config.dart';
9
  import 'package:aitube2/models/chat_message.dart';
10
  import 'package:flutter/foundation.dart';
@@ -142,8 +144,20 @@ class WebSocketApiService {
142
 
143
  try {
144
  await connect();
145
- // Request user role after connection
146
- await _requestUserRole();
 
 
 
 
 
 
 
 
 
 
 
 
147
  _initialized = true;
148
  } catch (e) {
149
  debugPrint('Failed to initialize WebSocketApiService: $e');
@@ -165,17 +179,201 @@ class WebSocketApiService {
165
  _userRole = response['user_role'] as String;
166
  _userRoleController.add(_userRole);
167
  debugPrint('WebSocketApiService: User role set to $_userRole');
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
  } catch (e) {
170
  debugPrint('WebSocketApiService: Failed to get user role: $e');
 
171
  }
172
  }
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  Future<void> connect() async {
175
  if (_disposed) {
176
  throw Exception('WebSocketApiService has been disposed');
177
  }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  // Prevent multiple simultaneous connection attempts
180
  return _connectionLock.synchronized(() async {
181
  if (_status == ConnectionStatus.connecting ||
@@ -288,8 +486,43 @@ class WebSocketApiService {
288
  } catch (e) {
289
  debugPrint('WebSocketApiService: Connection failed: $e');
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  // If server sent a 503 response with maintenance mode indication
292
- if (e.toString().contains('503') && e.toString().contains('maintenance')) {
293
  debugPrint('WebSocketApiService: Server is in maintenance mode');
294
  _setStatus(ConnectionStatus.maintenance);
295
  return;
@@ -314,9 +547,41 @@ class WebSocketApiService {
314
  },
315
  );
316
  } catch (retryError) {
317
- // Check again for maintenance mode
318
- if (retryError.toString().contains('503') && retryError.toString().contains('maintenance')) {
319
- debugPrint('WebSocketApiService: Server is in maintenance mode');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  _setStatus(ConnectionStatus.maintenance);
321
  return;
322
  }
@@ -338,8 +603,24 @@ class WebSocketApiService {
338
  );
339
 
340
  _startHeartbeat();
 
 
 
 
 
341
  _setStatus(ConnectionStatus.connected);
342
  _reconnectAttempts = 0;
 
 
 
 
 
 
 
 
 
 
 
343
  } catch (e) {
344
  // Check if the error indicates maintenance mode
345
  if (e.toString().contains('maintenance')) {
@@ -948,9 +1229,13 @@ class WebSocketApiService {
948
  _disposed = true;
949
  _initialized = false;
950
 
 
 
 
951
  // Cancel timers
952
  _heartbeatTimer?.cancel();
953
  _reconnectTimer?.cancel();
 
954
 
955
  // Clear all pending requests
956
  _cancelPendingRequests('Service is being disposed');
@@ -970,6 +1255,8 @@ class WebSocketApiService {
970
  await _searchController.close();
971
  await _chatController.close();
972
  await _userRoleController.close();
 
 
973
 
974
  _activeSearches.clear();
975
  _channel = null;
 
5
  import 'package:aitube2/services/settings_service.dart';
6
  import 'package:synchronized/synchronized.dart';
7
  import 'dart:convert';
8
+ // Conditionally import html for web platform with proper handling
9
+ import 'html_stub.dart' if (dart.library.html) 'dart:html' as html;
10
  import 'package:aitube2/config/config.dart';
11
  import 'package:aitube2/models/chat_message.dart';
12
  import 'package:flutter/foundation.dart';
 
144
 
145
  try {
146
  await connect();
147
+
148
+ try {
149
+ // Request user role after connection
150
+ await _requestUserRole();
151
+ } catch (e) {
152
+ // Handle the case where we fail to get user role due to device connection limit
153
+ if (e.toString().contains('Device connection limit exceeded')) {
154
+ // We've already set the appropriate status, just return
155
+ return;
156
+ }
157
+ // Otherwise rethrow
158
+ rethrow;
159
+ }
160
+
161
  _initialized = true;
162
  } catch (e) {
163
  debugPrint('Failed to initialize WebSocketApiService: $e');
 
179
  _userRole = response['user_role'] as String;
180
  _userRoleController.add(_userRole);
181
  debugPrint('WebSocketApiService: User role set to $_userRole');
182
+
183
+ // Now that we know the role, check device connection limit for non-anonymous users
184
+ if (kIsWeb && _userRole != 'anon') {
185
+ final connectionAllowed = _checkAndRegisterDeviceConnection();
186
+ if (!connectionAllowed) {
187
+ _isDeviceLimitExceeded = true;
188
+ _deviceLimitController.add(true);
189
+ _setStatus(ConnectionStatus.error);
190
+ throw Exception('Device connection limit exceeded');
191
+ }
192
+ }
193
  }
194
  } catch (e) {
195
  debugPrint('WebSocketApiService: Failed to get user role: $e');
196
+ rethrow;
197
  }
198
  }
199
 
200
+ // New status for anonymous users exceeding connection limit
201
+ bool _isAnonLimitExceeded = false;
202
+ bool get isAnonLimitExceeded => _isAnonLimitExceeded;
203
+
204
+ // Status for VIP users exceeding device connection limit (web only)
205
+ bool _isDeviceLimitExceeded = false;
206
+ bool get isDeviceLimitExceeded => _isDeviceLimitExceeded;
207
+
208
+ // Message to display when anonymous limit is exceeded
209
+ String _anonLimitMessage = '';
210
+ String get anonLimitMessage => _anonLimitMessage;
211
+
212
+ // Message to display when device limit is exceeded
213
+ String _deviceLimitMessage = 'Too many connections from this device. Please close other tabs running AiTube.';
214
+ String get deviceLimitMessage => _deviceLimitMessage;
215
+
216
+ // Stream to notify listeners when anonymous limit status changes
217
+ final _anonLimitController = StreamController<bool>.broadcast();
218
+ Stream<bool> get anonLimitStream => _anonLimitController.stream;
219
+
220
+ // Stream to notify listeners when device limit status changes
221
+ final _deviceLimitController = StreamController<bool>.broadcast();
222
+ Stream<bool> get deviceLimitStream => _deviceLimitController.stream;
223
+
224
+ // Constants for device connection limits
225
+ static const String _connectionCountKey = 'aitube_connection_count';
226
+ static const String _connectionIdKey = 'aitube_connection_id';
227
+ static const int _maxDeviceConnections = 3; // Maximum number of tabs/connections per device
228
+ static const Duration _connectionHeartbeatInterval = Duration(seconds: 10);
229
+ Timer? _connectionHeartbeatTimer;
230
+ String? _connectionId;
231
+
232
+ // Function to check and register device connection (web only)
233
+ bool _checkAndRegisterDeviceConnection() {
234
+ if (!kIsWeb) return true; // Only apply on web platform
235
+
236
+ try {
237
+ // Generate a unique ID for this connection instance
238
+ if (_connectionId == null) {
239
+ _connectionId = const Uuid().v4();
240
+
241
+ // Store connection ID in localStorage
242
+ html.window.localStorage[_connectionIdKey] = _connectionId!;
243
+ }
244
+
245
+ // Get current connection count from localStorage
246
+ final countJson = html.window.localStorage[_connectionCountKey];
247
+ Map<String, dynamic> connections = {};
248
+
249
+ if (countJson != null && countJson.isNotEmpty) {
250
+ try {
251
+ connections = json.decode(countJson) as Map<String, dynamic>;
252
+ } catch (e) {
253
+ debugPrint('Error parsing connection count: $e');
254
+ connections = {};
255
+ }
256
+ }
257
+
258
+ // Clean up stale connections (older than 30 seconds)
259
+ final now = DateTime.now().millisecondsSinceEpoch;
260
+ connections.removeWhere((key, value) {
261
+ if (value is! int) return true;
262
+ return now - value > 30000; // 30 seconds timeout
263
+ });
264
+
265
+ // Add/update this connection
266
+ connections[_connectionId!] = now;
267
+
268
+ // Store back to localStorage
269
+ html.window.localStorage[_connectionCountKey] = json.encode(connections);
270
+
271
+ // Check if we're exceeding the limit, but only for non-anonymous users
272
+ // For anonymous users, we rely on the server-side IP check
273
+ if (_userRole != 'anon' && connections.length > _maxDeviceConnections) {
274
+ debugPrint('Device connection limit exceeded: ${connections.length} connections for ${_userRole} user');
275
+ return false;
276
+ }
277
+
278
+ return true;
279
+ } catch (e) {
280
+ debugPrint('Error checking device connections: $e');
281
+ return true; // Default to allowing connection on error
282
+ }
283
+ }
284
+
285
+ // Function to update the connection heartbeat
286
+ void _updateConnectionHeartbeat() {
287
+ if (!kIsWeb || _connectionId == null) return;
288
+
289
+ try {
290
+ // Get current connection count
291
+ final countJson = html.window.localStorage[_connectionCountKey];
292
+ Map<String, dynamic> connections = {};
293
+
294
+ if (countJson != null && countJson.isNotEmpty) {
295
+ try {
296
+ connections = json.decode(countJson) as Map<String, dynamic>;
297
+ } catch (e) {
298
+ debugPrint('Error parsing connection count: $e');
299
+ connections = {};
300
+ }
301
+ }
302
+
303
+ // Update timestamp for this connection
304
+ final now = DateTime.now().millisecondsSinceEpoch;
305
+ connections[_connectionId!] = now;
306
+
307
+ // Store back to localStorage
308
+ html.window.localStorage[_connectionCountKey] = json.encode(connections);
309
+ } catch (e) {
310
+ debugPrint('Error updating connection heartbeat: $e');
311
+ }
312
+ }
313
+
314
+ // Function to unregister this connection
315
+ void _unregisterDeviceConnection() {
316
+ if (!kIsWeb || _connectionId == null) return;
317
+
318
+ try {
319
+ // Get current connection count
320
+ final countJson = html.window.localStorage[_connectionCountKey];
321
+ Map<String, dynamic> connections = {};
322
+
323
+ if (countJson != null && countJson.isNotEmpty) {
324
+ try {
325
+ connections = json.decode(countJson) as Map<String, dynamic>;
326
+ } catch (e) {
327
+ debugPrint('Error parsing connection count: $e');
328
+ connections = {};
329
+ }
330
+ }
331
+
332
+ // Remove this connection
333
+ connections.remove(_connectionId);
334
+
335
+ // Store back to localStorage
336
+ html.window.localStorage[_connectionCountKey] = json.encode(connections);
337
+
338
+ // Stop the heartbeat timer
339
+ _connectionHeartbeatTimer?.cancel();
340
+ _connectionHeartbeatTimer = null;
341
+ } catch (e) {
342
+ debugPrint('Error unregistering device connection: $e');
343
+ }
344
+ }
345
+
346
+ // Start the connection heartbeat timer
347
+ void _startConnectionHeartbeat() {
348
+ if (!kIsWeb) return;
349
+
350
+ _connectionHeartbeatTimer?.cancel();
351
+ _connectionHeartbeatTimer = Timer.periodic(_connectionHeartbeatInterval, (timer) {
352
+ _updateConnectionHeartbeat();
353
+ });
354
+ }
355
+
356
  Future<void> connect() async {
357
  if (_disposed) {
358
  throw Exception('WebSocketApiService has been disposed');
359
  }
360
 
361
+ // Reset limit exceeded statuses on connection attempt
362
+ _isAnonLimitExceeded = false;
363
+ _isDeviceLimitExceeded = false;
364
+
365
+ // Check device connection limit (for web only, but only after determining user role)
366
+ // We'll check again after getting the actual role, this is just to prevent excessive connections
367
+ if (kIsWeb) {
368
+ final connectionAllowed = _checkAndRegisterDeviceConnection();
369
+ if (!connectionAllowed) {
370
+ _isDeviceLimitExceeded = true;
371
+ _deviceLimitController.add(true);
372
+ _setStatus(ConnectionStatus.error);
373
+ throw Exception('Device connection limit exceeded');
374
+ }
375
+ }
376
+
377
  // Prevent multiple simultaneous connection attempts
378
  return _connectionLock.synchronized(() async {
379
  if (_status == ConnectionStatus.connecting ||
 
486
  } catch (e) {
487
  debugPrint('WebSocketApiService: Connection failed: $e');
488
 
489
+ String errorMessage = e.toString();
490
+
491
+ // Check for anonymous user connection limit exceeded
492
+ if (errorMessage.contains('429') && (errorMessage.contains('anon_limit_exceeded') ||
493
+ errorMessage.contains('Anonymous user limit exceeded'))) {
494
+ debugPrint('WebSocketApiService: Anonymous user connection limit exceeded');
495
+
496
+ // Try to extract the error message from the response
497
+ String errorMsg = 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!';
498
+
499
+ try {
500
+ // Extract JSON content from the error message if available
501
+ final match = RegExp(r'\{.*\}').firstMatch(errorMessage);
502
+ if (match != null) {
503
+ final jsonStr = match.group(0);
504
+ if (jsonStr != null) {
505
+ final errorData = json.decode(jsonStr);
506
+ if (errorData['message'] != null) {
507
+ errorMsg = errorData['message'];
508
+ }
509
+ }
510
+ }
511
+ } catch (_) {
512
+ // If parsing fails, use the default message
513
+ }
514
+
515
+ _setStatus(ConnectionStatus.error);
516
+ _isAnonLimitExceeded = true;
517
+ _anonLimitMessage = errorMsg;
518
+ _anonLimitController.add(true);
519
+
520
+ // We don't rethrow here - we want to handle this specific error differently
521
+ return;
522
+ }
523
+
524
  // If server sent a 503 response with maintenance mode indication
525
+ if (errorMessage.contains('503') && errorMessage.contains('maintenance')) {
526
  debugPrint('WebSocketApiService: Server is in maintenance mode');
527
  _setStatus(ConnectionStatus.maintenance);
528
  return;
 
547
  },
548
  );
549
  } catch (retryError) {
550
+ // Check again for maintenance mode or anonymous limit
551
+ final retryErrorMsg = retryError.toString();
552
+
553
+ if (retryErrorMsg.contains('429') && (retryErrorMsg.contains('anon_limit_exceeded') ||
554
+ retryErrorMsg.contains('Anonymous user limit exceeded'))) {
555
+ debugPrint('WebSocketApiService: Anonymous user connection limit exceeded on retry');
556
+
557
+ // Try to extract the error message from the response
558
+ String errorMsg = 'Anonymous users can enjoy 1 stream per IP address. If you are on a shared IP please enter your HF token, thank you!';
559
+
560
+ try {
561
+ // Extract JSON content from the error message if available
562
+ final match = RegExp(r'\{.*\}').firstMatch(retryErrorMsg);
563
+ if (match != null) {
564
+ final jsonStr = match.group(0);
565
+ if (jsonStr != null) {
566
+ final errorData = json.decode(jsonStr);
567
+ if (errorData['message'] != null) {
568
+ errorMsg = errorData['message'];
569
+ }
570
+ }
571
+ }
572
+ } catch (_) {
573
+ // If parsing fails, use the default message
574
+ }
575
+
576
+ _setStatus(ConnectionStatus.error);
577
+ _isAnonLimitExceeded = true;
578
+ _anonLimitMessage = errorMsg;
579
+ _anonLimitController.add(true);
580
+ return;
581
+ }
582
+
583
+ if (retryErrorMsg.contains('503') && retryErrorMsg.contains('maintenance')) {
584
+ debugPrint('WebSocketApiService: Server is in maintenance mode on retry');
585
  _setStatus(ConnectionStatus.maintenance);
586
  return;
587
  }
 
603
  );
604
 
605
  _startHeartbeat();
606
+ // Start the device connection heartbeat for web (we'll only apply limits to VIP users)
607
+ if (kIsWeb) {
608
+ _startConnectionHeartbeat();
609
+ }
610
+
611
  _setStatus(ConnectionStatus.connected);
612
  _reconnectAttempts = 0;
613
+
614
+ // Clear limit flags if we successfully connected
615
+ if (_isAnonLimitExceeded) {
616
+ _isAnonLimitExceeded = false;
617
+ _anonLimitController.add(false);
618
+ }
619
+
620
+ if (_isDeviceLimitExceeded) {
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')) {
 
1229
  _disposed = true;
1230
  _initialized = false;
1231
 
1232
+ // Unregister device connection (web only)
1233
+ _unregisterDeviceConnection();
1234
+
1235
  // Cancel timers
1236
  _heartbeatTimer?.cancel();
1237
  _reconnectTimer?.cancel();
1238
+ _connectionHeartbeatTimer?.cancel();
1239
 
1240
  // Clear all pending requests
1241
  _cancelPendingRequests('Service is being disposed');
 
1255
  await _searchController.close();
1256
  await _chatController.close();
1257
  await _userRoleController.close();
1258
+ await _anonLimitController.close();
1259
+ await _deviceLimitController.close();
1260
 
1261
  _activeSearches.clear();
1262
  _channel = null;
lib/widgets/null_html.dart DELETED
@@ -1,31 +0,0 @@
1
- // Mock implementations for non-web platforms
2
-
3
- class Window {
4
- final document = Document();
5
- final history = History();
6
- final location = Location();
7
-
8
- Stream<dynamic> get onBeforeUnload =>
9
- Stream.fromIterable([]).asBroadcastStream();
10
- }
11
-
12
- class Document {
13
- String get visibilityState => 'visible';
14
-
15
- Stream<dynamic> get onVisibilityChange =>
16
- Stream.fromIterable([]).asBroadcastStream();
17
- }
18
-
19
- class History {
20
- void pushState(dynamic data, String title, String url) {
21
- // No-op for non-web platforms
22
- }
23
- }
24
-
25
- class Location {
26
- String get href => '';
27
- }
28
-
29
- // Exported instances
30
- final window = Window();
31
- final document = Document();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/widgets/web_utils.dart CHANGED
@@ -2,7 +2,7 @@ import 'dart:async';
2
  import 'package:flutter/foundation.dart';
3
 
4
  // Platform-specific imports handling
5
- import 'package:universal_html/html.dart' if (dart.library.io) 'package:aitube2/widgets/null_html.dart' as html;
6
 
7
  /// Get URL parameters from the current URL (web only)
8
  Map<String, String> getUrlParameters() {
@@ -46,35 +46,5 @@ void removeUrlParameter(String key) {
46
  html.window.history.pushState(null, '', newUri.toString());
47
  }
48
 
49
- /// Fallback implementation for non-web platforms
50
- class NullHtml {
51
- // Mock objects to prevent build errors
52
- final window = Window();
53
- final document = Document();
54
- }
55
-
56
- // Mock implementation for html.window
57
- class Window {
58
- final document = Document();
59
- final History history = History();
60
-
61
- Stream<dynamic> get onBeforeUnload =>
62
- Stream.fromIterable([]).asBroadcastStream();
63
-
64
- String get location => '';
65
- }
66
-
67
- // Mock implementation for html.History
68
- class History {
69
- void pushState(dynamic data, String title, String url) {
70
- // No-op for non-web platforms
71
- }
72
- }
73
-
74
- // Mock implementation for html.document
75
- class Document {
76
- String get visibilityState => 'visible';
77
-
78
- Stream<dynamic> get onVisibilityChange =>
79
- Stream.fromIterable([]).asBroadcastStream();
80
- }
 
2
  import 'package:flutter/foundation.dart';
3
 
4
  // Platform-specific imports handling
5
+ import 'package:universal_html/html.dart' if (dart.library.io) 'package:aitube2/services/html_stub.dart' as html;
6
 
7
  /// Get URL parameters from the current URL (web only)
8
  Map<String, String> getUrlParameters() {
 
46
  html.window.history.pushState(null, '', newUri.toString());
47
  }
48
 
49
+ // We now use the comprehensive html_stub.dart for non-web platforms
50
+ // All mock classes are now consolidated there