Spaces:
Running
feat: complete mobile-first redesign with video streaming and on-screen keyboard
Browse filesMajor UI Redesign:
- Complete mobile-first responsive CSS redesign
- Modern card-based layout optimized for touch
- Smooth animations and transitions
- Better visual hierarchy and spacing
- Sticky on-screen keyboard for mobile typing
- Toggle switches instead of checkboxes
- Progress bars for usage statistics
- Connection status indicators with pulse animation
- Responsive tables with horizontal scroll
Video Streaming:
- MJPEG streaming endpoint (/portal/{provider}/stream)
- Quality selector (Low/Medium/High) for bandwidth control
- Frame rate control (fps parameter)
- Compressed JPEG frames for smooth streaming
- Screenshot endpoint with quality parameter
- Real-time video-like experience at 2fps
On-Screen Keyboard:
- Full QWERTY layout optimized for mobile
- Space, backspace, enter keys
- Shift toggle support
- Stays visible while browser active
- Close to actual browser window
Mobile Optimizations:
- Touch-friendly buttons (min 40px)
- Larger tap targets
- Simplified navigation
- Better scrolling on mobile
- Viewport meta tags for proper scaling
- Prevent zoom on inputs
Browser Portal Enhancements:
- Streamlined toolbar with navigation
- Address bar always visible
- Quality toggle integrated in toolbar
- Message input at bottom with keyboard toggle
- Visual click coordinates display
- Loading spinner during navigation
Dependencies:
- Added Pillow for image processing
- Image compression and resizing support
- JPEG/PNG format conversion
- admin_router.py +99 -4
- requirements.txt +2 -1
- static/qaz.html +0 -0
|
@@ -488,10 +488,12 @@ async def start_unified_portal(req: PortalProviderRequest):
|
|
| 488 |
raise HTTPException(status_code=500, detail=str(e))
|
| 489 |
|
| 490 |
@router.get("/portal/{provider}/screenshot")
|
| 491 |
-
async def get_unified_portal_screenshot(provider: str):
|
| 492 |
-
"""Get screenshot from any provider portal."""
|
| 493 |
import os
|
| 494 |
-
from fastapi.responses import FileResponse
|
|
|
|
|
|
|
| 495 |
|
| 496 |
try:
|
| 497 |
from browser_portal import get_portal_manager, PortalProvider
|
|
@@ -507,7 +509,100 @@ async def get_unified_portal_screenshot(provider: str):
|
|
| 507 |
if not os.path.exists(portal.screenshot_path):
|
| 508 |
raise HTTPException(status_code=404, detail="Screenshot not available")
|
| 509 |
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
except HTTPException:
|
| 512 |
raise
|
| 513 |
except Exception as e:
|
|
|
|
| 488 |
raise HTTPException(status_code=500, detail=str(e))
|
| 489 |
|
| 490 |
@router.get("/portal/{provider}/screenshot")
|
| 491 |
+
async def get_unified_portal_screenshot(provider: str, quality: float = 1.0, format: str = "png"):
|
| 492 |
+
"""Get screenshot from any provider portal with optional quality/compression."""
|
| 493 |
import os
|
| 494 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 495 |
+
from PIL import Image
|
| 496 |
+
import io
|
| 497 |
|
| 498 |
try:
|
| 499 |
from browser_portal import get_portal_manager, PortalProvider
|
|
|
|
| 509 |
if not os.path.exists(portal.screenshot_path):
|
| 510 |
raise HTTPException(status_code=404, detail="Screenshot not available")
|
| 511 |
|
| 512 |
+
# If quality is 1.0 and format is png, return as-is
|
| 513 |
+
if quality >= 1.0 and format == "png":
|
| 514 |
+
return FileResponse(portal.screenshot_path, media_type="image/png")
|
| 515 |
+
|
| 516 |
+
# Otherwise, compress/process the image
|
| 517 |
+
img = Image.open(portal.screenshot_path)
|
| 518 |
+
|
| 519 |
+
# Resize if quality < 1.0
|
| 520 |
+
if quality < 1.0:
|
| 521 |
+
new_size = (int(img.width * quality), int(img.height * quality))
|
| 522 |
+
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
| 523 |
+
|
| 524 |
+
# Convert to desired format
|
| 525 |
+
img_io = io.BytesIO()
|
| 526 |
+
if format == "jpeg" or format == "jpg":
|
| 527 |
+
img = img.convert("RGB")
|
| 528 |
+
img.save(img_io, format="JPEG", quality=int(quality * 100) if quality < 1 else 85)
|
| 529 |
+
media_type = "image/jpeg"
|
| 530 |
+
else:
|
| 531 |
+
img.save(img_io, format="PNG")
|
| 532 |
+
media_type = "image/png"
|
| 533 |
+
|
| 534 |
+
img_io.seek(0)
|
| 535 |
+
return StreamingResponse(img_io, media_type=media_type)
|
| 536 |
+
|
| 537 |
+
except HTTPException:
|
| 538 |
+
raise
|
| 539 |
+
except Exception as e:
|
| 540 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
# MJPEG Streaming endpoint for video-like experience
|
| 544 |
+
@router.get("/portal/{provider}/stream")
|
| 545 |
+
async def stream_portal_video(provider: str, quality: float = 0.5, fps: int = 2):
|
| 546 |
+
"""Stream the portal as MJPEG for video-like experience."""
|
| 547 |
+
import os
|
| 548 |
+
import asyncio
|
| 549 |
+
from fastapi.responses import StreamingResponse
|
| 550 |
+
from PIL import Image
|
| 551 |
+
import io
|
| 552 |
+
|
| 553 |
+
try:
|
| 554 |
+
from browser_portal import get_portal_manager, PortalProvider
|
| 555 |
+
|
| 556 |
+
prov = PortalProvider(provider.lower())
|
| 557 |
+
portal = get_portal_manager().get_portal(prov)
|
| 558 |
+
|
| 559 |
+
if not portal.is_running():
|
| 560 |
+
raise HTTPException(status_code=400, detail=f"{provider} portal not running")
|
| 561 |
+
|
| 562 |
+
async def generate_frames():
|
| 563 |
+
"""Generate MJPEG stream."""
|
| 564 |
+
frame_delay = 1.0 / fps
|
| 565 |
+
|
| 566 |
+
while portal.is_running():
|
| 567 |
+
try:
|
| 568 |
+
# Take screenshot
|
| 569 |
+
await portal.take_screenshot()
|
| 570 |
+
|
| 571 |
+
if os.path.exists(portal.screenshot_path):
|
| 572 |
+
# Process image
|
| 573 |
+
img = Image.open(portal.screenshot_path)
|
| 574 |
+
|
| 575 |
+
if quality < 1.0:
|
| 576 |
+
new_size = (int(img.width * quality), int(img.height * quality))
|
| 577 |
+
img = img.resize(new_size, Image.Resampling.LANCZOS)
|
| 578 |
+
|
| 579 |
+
# Convert to JPEG for smaller size
|
| 580 |
+
img = img.convert("RGB")
|
| 581 |
+
img_io = io.BytesIO()
|
| 582 |
+
img.save(img_io, format="JPEG", quality=70)
|
| 583 |
+
img_io.seek(0)
|
| 584 |
+
|
| 585 |
+
frame_data = img_io.getvalue()
|
| 586 |
+
|
| 587 |
+
# Yield MJPEG frame
|
| 588 |
+
yield (
|
| 589 |
+
b'--frame\r\n'
|
| 590 |
+
b'Content-Type: image/jpeg\r\n'
|
| 591 |
+
b'Content-Length: ' + str(len(frame_data)).encode() + b'\r\n'
|
| 592 |
+
b'\r\n' + frame_data + b'\r\n'
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
await asyncio.sleep(frame_delay)
|
| 596 |
+
|
| 597 |
+
except Exception as e:
|
| 598 |
+
print(f"Stream error: {e}")
|
| 599 |
+
await asyncio.sleep(frame_delay)
|
| 600 |
+
|
| 601 |
+
return StreamingResponse(
|
| 602 |
+
generate_frames(),
|
| 603 |
+
media_type="multipart/x-mixed-replace;boundary=frame"
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
except HTTPException:
|
| 607 |
raise
|
| 608 |
except Exception as e:
|
|
@@ -5,10 +5,11 @@ duckduckgo-ai-chat>=0.0.7
|
|
| 5 |
httpx>=0.25.0
|
| 6 |
pydantic>=2.0
|
| 7 |
supabase>=2.0.0
|
|
|
|
| 8 |
# Search Engine
|
| 9 |
requests>=2.31.0
|
| 10 |
beautifulsoup4>=4.12.0
|
| 11 |
lxml>=5.1.0
|
| 12 |
duckduckgo_search>=5.0.0
|
| 13 |
|
| 14 |
-
# Force Rebuild: v2.0
|
|
|
|
| 5 |
httpx>=0.25.0
|
| 6 |
pydantic>=2.0
|
| 7 |
supabase>=2.0.0
|
| 8 |
+
Pillow>=10.0.0
|
| 9 |
# Search Engine
|
| 10 |
requests>=2.31.0
|
| 11 |
beautifulsoup4>=4.12.0
|
| 12 |
lxml>=5.1.0
|
| 13 |
duckduckgo_search>=5.0.0
|
| 14 |
|
| 15 |
+
# Force Rebuild: v2.1.0 - Mobile UI + Video Streaming
|
|
The diff for this file is too large to render.
See raw diff
|
|
|