Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Commit
·
6c71972
1
Parent(s):
83f2577
protection against gpu skimmers, free-compute scammers and other grifters
Browse files- api.py +40 -0
- api_config.py +26 -37
- api_core.py +101 -66
- build/web/assets/assets/config/aitube_low.yaml +3 -3
- build/web/assets/fonts/MaterialIcons-Regular.otf +0 -0
- build/web/flutter_bootstrap.js +1 -1
- build/web/flutter_service_worker.js +4 -4
- build/web/main.dart.js +0 -0
- lib/screens/home_screen.dart +179 -0
- lib/screens/video_screen.dart +183 -1
- lib/services/{null_html.dart → html_stub.dart} +40 -4
- lib/services/websocket_api_service.dart +293 -6
- lib/widgets/null_html.dart +0 -31
- lib/widgets/web_utils.dart +3 -33
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":
|
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":
|
101 |
|
102 |
"min_num_frames": 9, # 8 + 1
|
103 |
"default_num_frames": 65, # 8*8 + 1
|
104 |
-
"max_num_frames": 65,
|
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.
|
111 |
-
"default_clip_playback_speed": 0.
|
112 |
-
"max_clip_playback_speed": 0.
|
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 |
-
"
|
136 |
-
|
|
|
137 |
"min_num_frames": 9, # 8 + 1
|
138 |
-
"default_num_frames":
|
139 |
-
"max_num_frames":
|
140 |
|
141 |
-
"min_clip_duration_seconds": 1,
|
142 |
"default_clip_duration_seconds": 2,
|
143 |
-
"max_clip_duration_seconds":
|
144 |
|
145 |
-
"min_clip_playback_speed": 0.
|
146 |
-
"default_clip_playback_speed": 0.
|
147 |
-
"max_clip_playback_speed": 0.
|
148 |
|
149 |
"min_clip_framerate": 8,
|
150 |
"default_clip_framerate": 25,
|
151 |
-
"max_clip_framerate":
|
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":
|
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 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
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 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
|
|
325 |
)
|
326 |
-
)
|
327 |
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
result
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
352 |
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
thumbnail = ""
|
361 |
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
|
371 |
-
|
372 |
-
|
373 |
|
374 |
-
|
375 |
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
|
381 |
-
|
382 |
-
|
383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
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:
|
11 |
|
12 |
# start playback as soon as we have 1 video over 3 (25%)
|
13 |
-
minimum_buffer_percent_to_start_playback:
|
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:
|
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: "
|
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": "
|
7 |
"version.json": "b5eaae4fc120710a3c35125322173615",
|
8 |
"index.html": "f34c56fffc6b38f62412a5db2315dec8",
|
9 |
"/": "f34c56fffc6b38f62412a5db2315dec8",
|
10 |
-
"main.dart.js": "
|
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": "
|
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": "
|
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.
|
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 |
-
///
|
2 |
-
/// This file is imported
|
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 |
-
|
146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
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 |
-
|
319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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/
|
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 |
-
|
50 |
-
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|