feat: security hardening, streaming overhaul, design polish, tests

Security:
- Replace unsalted SHA-256 password hashing with bcrypt (lazy rehash on login)
- Add authenticated /api/xtream-api gateway: Xtream credentials are injected
  server-side and never sent to the frontend; /api/playlists no longer
  returns passwords
- Redact credentials from all logs (login body, proxy/FFmpeg/scheduler URLs)
- Add auth to recordings, EPG, season-passes and streaming routes
  (HttpOnly session cookie for hls.js; loopback bypass for local FFmpeg)
- Lock player postMessage to same-origin in both directions
- Vendor and pin hls.js 1.6.7 / mpegts.js 1.7.3 (drop CDN @latest)
- Fix rate limiter (client IP was never resolved), add login rate limit,
  restrict CORS, add CSP Report-Only, block private-IP SSRF targets,
  fix path traversal in recording log retrieval, chmod 777 -> 770
- Remove dead HiveService (seeded admin/admin into IndexedDB with SHA-256)
- Fix authMiddleware not populating 'user' context (getPlaylist ignored the
  logged-in user; admin purge always returned 403)

Streaming:
- New FfmpegSessionManager: process registry, idle reaper (4 min live /
  15 min VOD), orphan cleanup at startup, clean SIGTERM shutdown,
  fast-fail with stderr instead of 30 s timeout
- Quality selection (source/high/medium/low) for live and VOD; source mode
  streams with -c:v copy (zero transcoding); selector wired into the player
- Concurrent recordings (MAX_CONCURRENT_RECORDINGS, default 2); conflicts
  retry on the next tick instead of silently failing
- Lower live latency (HLS window 20 -> 10 segments, liveSync 10 -> 3)
- Fix recording log lookup (.mp4 vs .mkv mismatch)

Design:
- Replace hardcoded colors with AppColors tokens (12 files)
- web/theme.css syncs HTML players with the Flutter palette
- DPAD/keyboard navigation (arrow-key focus, player shortcuts)
- Tooltips on player icon buttons, Semantics on content cards
- Remove 7 dead widgets broken since the Stitch merge

Quality:
- bin/test/: 21 unit tests (bcrypt, redaction, traversal, SSRF, recording
  conflicts) plus a quality-selector widget test
- GitHub Actions CI (analyze + test + build web)
- Archive stale status docs into docs/archive/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-06-10 10:07:18 +02:00
1 parent cb5eca7547
commit 60d3f42901
107 files changed
+4489 -3779

No files matched your search

@@ -0,0 +1,16 @@
---
name: "source-command-bmad-core-tasks-index-docs"
description: "Generates or updates an index.md of all documents in the specified directory"
---
# source-command-bmad-core-tasks-index-docs
Use this skill when the user asks to run the migrated source command `bmad-core-tasks-index-docs`.
## Command Template
# Index Docs
LOAD and execute the task at: _bmad/core/tasks/index-docs.xml
Follow all instructions in the task file exactly as written.
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build web --release
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: bin
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart pub get
- run: dart analyze
- run: dart test
+133
View File
@@ -0,0 +1,133 @@
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (90-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Codex sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to AGENTS.md
rtk init --global # Add RTK to ~/.Codex/AGENTS.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+34
View File
@@ -1,5 +1,39 @@
# 📝 Changelog - XtremFlow Optimisations # 📝 Changelog - XtremFlow Optimisations
## Version 1.2 - Security, Streaming & Design Overhaul (10 Juin 2026)
### 🔐 Sécurité
- Hachage des mots de passe en **bcrypt** (migration lazy depuis SHA-256 au login)
- Les credentials Xtream ne quittent plus jamais le serveur : nouvelle passerelle authentifiée `/api/xtream-api` (injection côté serveur), `/api/playlists` ne renvoie plus les mots de passe
- Redaction des credentials dans tous les logs (proxy, FFmpeg, scheduler, login)
- Cookie de session HttpOnly + auth sur les routes de streaming, recordings, EPG, season-passes
- postMessage des players verrouillé sur same-origin (plus de wildcard `*`)
- hls.js 1.6.7 / mpegts.js 1.7.3 vendorisés et figés (`web/vendor/`, plus de CDN `@latest`)
- Rate limiter réparé (IP réelle via X-Forwarded-For) + limite login 10/min/IP
- CORS restreint (plus de wildcard), CSP en Report-Only, anti-SSRF (IP privées bloquées), fix path-traversal sur les logs d'enregistrement, `chmod 770` sur /app/recordings
- Suppression du code mort HiveService (seed admin SHA-256 en IndexedDB)
### 📺 Streaming
- **FfmpegSessionManager** : registre des process FFmpeg, reaper d'inactivité (4 min live / 15 min VOD), purge des orphelins au démarrage, arrêt propre SIGTERM, échec rapide avec stderr (fini le timeout 30 s)
- **Sélection de qualité** : `source | high | medium | low` (live + VOD), `source` = `-c:v copy` zéro transcodage ; sélecteur dans le player
- **Enregistrements simultanés** (MAX_CONCURRENT_RECORDINGS, défaut 2) : les conflits réessaient au lieu d'échouer
- Latence live réduite : fenêtre HLS 20→10 segments, `liveSyncDurationCount` 10→3
- Fix : récupération des logs d'enregistrement (cherchait `.mp4`, fichiers en `.mkv`)
- Fix : `authMiddleware` ne peuplait pas `user` → getPlaylist retombait toujours sur le 1er utilisateur, purge admin toujours 403
### 🎨 Design
- Sweep des couleurs hardcodées → tokens `AppColors` (24 occurrences, 12 fichiers)
- `web/theme.css` : variables CSS synchronisées avec le thème Flutter pour les 3 players HTML
- Navigation DPAD/clavier : flèches = focus, raccourcis player (espace, ←/→ seek/zap, M mute, Échap)
- Tooltips sur tous les boutons icône du player, `Semantics` sur les cartes chaînes/films/séries
- Suppression de 7 widgets morts cassés depuis la fusion Stitch
### 🧪 Qualité
- Tests backend (`bin/test/`) : bcrypt, redaction, path-traversal, SSRF, logique de conflit d'enregistrement — 21 tests
- Test widget du sélecteur de qualité
- CI GitHub Actions (analyze + test + build web)
- Docs périmées archivées dans `docs/archive/`
## Version 1.1 - Optimizations Release (26 Mars 2026) ## Version 1.1 - Optimizations Release (26 Mars 2026)
### 🆕 New Features ### 🆕 New Features
+133
View File
@@ -0,0 +1,133 @@
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (90-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>XtremFlow - Admin Settings</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600;700;900&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-variant": "#343439",
"primary-fixed": "#d8e2ff",
"surface-bright": "#38393d",
"on-tertiary-container": "#282a2a",
"error-container": "#93000a",
"on-secondary": "#2f3037",
"on-primary-fixed-variant": "#004493",
"tertiary-container": "#909191",
"surface-container-lowest": "#0d0e12",
"inverse-on-surface": "#2f3035",
"surface-container-highest": "#343439",
"surface": "#121317",
"secondary-fixed-dim": "#c6c5cf",
"on-tertiary": "#2f3131",
"surface-dim": "#121317",
"on-secondary-fixed": "#1a1b22",
"on-primary": "#002e69",
"primary": "#adc6ff",
"primary-fixed-dim": "#adc6ff",
"outline": "#8b90a0",
"secondary": "#c6c5cf",
"surface-tint": "#adc6ff",
"surface-container-high": "#292a2e",
"on-tertiary-fixed-variant": "#454747",
"error": "#ffb4ab",
"on-surface": "#e3e2e7",
"secondary-container": "#4a4b53",
"on-primary-fixed": "#001a41",
"tertiary-fixed": "#e2e2e2",
"on-secondary-container": "#bcbbc4",
"on-secondary-fixed-variant": "#46464e",
"inverse-primary": "#005bc1",
"primary-container": "#4b8eff",
"tertiary": "#c6c6c7",
"inverse-surface": "#e3e2e7",
"on-error-container": "#ffdad6",
"surface-container": "#1f1f24",
"on-tertiary-fixed": "#1a1c1c",
"surface-container-low": "#1a1b20",
"on-error": "#690005",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-fixed": "#e3e1eb",
"on-surface-variant": "#c1c6d7",
"on-background": "#e3e2e7",
"outline-variant": "#414755",
"background": "#121317",
"on-primary-container": "#00285c"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"base": "8px",
"xl": "80px",
"lg": "48px",
"md": "24px",
"sm": "12px",
"xs": "4px",
"margin": "32px",
"gutter": "24px"
},
"fontFamily": {
"body-md": ["Inter"],
"headline-lg": ["Space Grotesk"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"headline-md": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "1.5", "fontWeight": "400"}],
"headline-lg": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600"}],
"label-lg": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600"}],
"headline-xl": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"label-sm": ["12px", {"lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500"}],
"body-lg": ["18px", {"lineHeight": "1.6", "fontWeight": "400"}],
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "500"}]
}
}
}
}
</script>
<style>
/* Custom Glassmorphism and Utility Styles not easily covered by Tailwind config alone */
.glass-panel {
background-color: rgba(31, 31, 36, 0.6); /* surface-container with opacity */
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-panel:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.glow-text {
text-shadow: 0 0 10px rgba(173, 198, 255, 0.5); /* primary color glow */
}
.progress-glow {
box-shadow: 0 0 12px rgba(75, 142, 255, 0.6); /* primary-container glow */
}
/* Custom Scrollbar for sleek look */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #414755; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #8b90a0; }
</style>
</head>
<body class="bg-background text-on-background font-body-md text-body-md antialiased overflow-x-hidden selection:bg-primary-container selection:text-white">
<div class="flex min-h-screen w-full relative">
<!-- SideNavBar -->
<nav class="fixed left-0 top-0 h-full w-64 border-r border-white/10 shadow-2xl bg-slate-950/80 backdrop-blur-2xl flex flex-col py-6 z-50">
<div class="px-6 mb-10 flex items-center gap-sm">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-container to-[#005bc1] flex items-center justify-center shadow-[0_0_15px_rgba(75,142,255,0.4)]">
<span class="material-symbols-outlined text-white text-[24px]">stream</span>
</div>
<div>
<h1 class="text-xl font-black text-white font-headline-md tracking-tight leading-none">XtremFlow</h1>
<span class="text-xs text-slate-400 font-label-sm uppercase tracking-widest mt-1 block">Media Control</span>
</div>
</div>
<div class="flex-1 overflow-y-auto px-2 space-y-1">
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 hover:bg-white/5 hover:text-slate-100 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2" href="#">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
Dashboard
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 hover:bg-white/5 hover:text-slate-100 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2" href="#">
<span class="material-symbols-outlined text-[20px]">video_library</span>
Library
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 hover:bg-white/5 hover:text-slate-100 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2" href="#">
<span class="material-symbols-outlined text-[20px]">insights</span>
Analytics
</a>
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 hover:bg-white/5 hover:text-slate-100 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2" href="#">
<span class="material-symbols-outlined text-[20px]">cloud_upload</span>
Media Manager
</a>
<!-- Active Item -->
<a class="flex items-center gap-3 px-4 py-3 text-blue-400 border-l-4 border-blue-500 bg-blue-500/10 active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-r-lg ml-2 mr-2" href="#">
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings: 'FILL' 1;">admin_panel_settings</span>
Admin Settings
</a>
</div>
<div class="px-4 mt-auto space-y-1">
<a class="flex items-center gap-3 px-4 py-3 text-slate-400 hover:bg-white/5 hover:text-slate-100 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2" href="#">
<span class="material-symbols-outlined text-[20px]">help</span>
Support
</a>
<a class="flex items-center gap-3 px-4 py-3 text-error hover:bg-error/10 transition-colors active:translate-x-1 duration-150 font-['Space_Grotesk'] font-medium rounded-lg mx-2 mt-2" href="#">
<span class="material-symbols-outlined text-[20px]">logout</span>
Logout
</a>
</div>
</nav>
<!-- Main Content Area -->
<main class="ml-64 flex-1 flex flex-col min-h-screen relative w-[calc(100%-16rem)]">
<!-- TopNavBar -->
<header class="docked full-width top-0 sticky z-40 border-b border-white/10 shadow-[0_0_20px_rgba(0,0,0,0.5)] bg-slate-950/50 backdrop-blur-xl flex justify-between items-center w-full px-6 py-3 font-['Space_Grotesk'] text-sm tracking-tight text-blue-500">
<div class="flex items-center gap-6 flex-1">
<div class="relative w-full max-w-md group">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-[18px] group-focus-within:text-blue-400 transition-colors">search</span>
<input class="w-full bg-[#181920] border border-outline-variant/50 rounded-full pl-10 pr-4 py-2 text-on-surface focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:bg-[#1f2029] transition-all placeholder:text-slate-500 font-body-md text-sm" placeholder="Search system resources, users, or settings..." type="text"/>
</div>
</div>
<div class="flex items-center gap-sm ml-auto">
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/5 hover:text-white transition-all active:scale-95 duration-200 relative text-slate-400">
<span class="material-symbols-outlined">notifications</span>
<span class="absolute top-2 right-2 w-2 h-2 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.8)]"></span>
</button>
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/5 hover:text-white transition-all active:scale-95 duration-200 text-slate-400">
<span class="material-symbols-outlined">settings</span>
</button>
<div class="w-px h-6 bg-white/10 mx-2"></div>
<button class="flex items-center gap-2 hover:bg-white/5 p-1 pr-3 rounded-full transition-all border border-transparent hover:border-white/10">
<img alt="User profile avatar" class="w-8 h-8 rounded-full border border-white/20 object-cover" data-alt="Portrait of a male user with glasses and short dark hair against a light background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDba2WTdK70Rwv4MbN3nX7oK51wv0COSzCNRPk7ZjzoVkwQHRY1f9Bj-SHsmp8AEdOfovgsacwFO_lDg9MyaqRQyUm_VRuVACWugh---Z_lhHUjs0rO-OUvz72Evds-I7XmIVszJsYbdvBUmUx41xf4TgBcIULjOdM8o8Gqd9sErK33Mqb7FS55FC-Xw-ryt18A7UH4iH215IRWQVYUQ3hFZRm1QGb0oKKoXTRBk5_LnAKgeh4LkTTmKnUjywfNDzFh05E42-Jv8HY"/>
<span class="text-slate-200 font-medium text-sm hidden lg:block">Admin</span>
<span class="material-symbols-outlined text-[16px] text-slate-400">expand_more</span>
</button>
</div>
</header>
<!-- Page Content -->
<div class="p-margin flex-1 flex flex-col gap-lg max-w-[1600px] w-full mx-auto">
<!-- Page Header -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-md">
<div>
<h2 class="font-headline-xl text-headline-xl text-white tracking-tight mb-xs">System Administration</h2>
<p class="font-body-md text-body-md text-on-surface-variant max-w-2xl">Monitor server performance, manage user access levels, and configure global environment variables for the XtremFlow network.</p>
</div>
<div class="flex gap-sm">
<button class="px-md py-sm rounded-lg font-label-lg text-label-lg border border-outline-variant bg-surface-container/30 text-on-surface hover:bg-surface-container/50 hover:border-outline transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">download</span> Export Logs
</button>
<button class="bg-gradient-to-r from-[#007AFF] to-[#00c6ff] text-white px-md py-sm rounded-lg font-label-lg text-label-lg shadow-[0_4px_15px_rgba(0,122,255,0.3)] hover:shadow-[0_6px_20px_rgba(0,122,255,0.5)] hover:scale-[1.02] transition-all flex items-center gap-2 border border-blue-400/30">
<span class="material-symbols-outlined text-[18px]">person_add</span> Add User
</button>
</div>
</div>
<!-- Server Performance Bento Grid -->
<section class="grid grid-cols-1 lg:grid-cols-12 gap-gutter">
<!-- CPU Node -->
<div class="col-span-1 lg:col-span-4 glass-panel rounded-xl p-md flex flex-col relative overflow-hidden group">
<div class="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-[40px] -mr-10 -mt-10 pointer-events-none"></div>
<div class="flex justify-between items-start mb-lg relative z-10">
<div>
<h3 class="font-headline-md text-sm text-on-surface-variant uppercase tracking-wider mb-1">Core Processing</h3>
<div class="font-headline-xl text-3xl text-white glow-text">78%</div>
</div>
<div class="w-10 h-10 rounded bg-blue-500/20 border border-blue-500/30 flex items-center justify-center text-blue-400">
<span class="material-symbols-outlined text-[20px]">memory</span>
</div>
</div>
<div class="mt-auto relative z-10">
<div class="flex justify-between text-xs text-on-surface-variant mb-2">
<span>Load Average: 2.14, 2.05, 1.98</span>
<span class="text-blue-400">Stable</span>
</div>
<div class="h-2 w-full bg-surface-container-highest/80 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-600 to-[#00A3FF] w-[78%] rounded-full progress-glow relative">
<div class="absolute top-0 right-0 bottom-0 w-4 bg-white/20 blur-[2px]"></div>
</div>
</div>
</div>
</div>
<!-- Memory Node -->
<div class="col-span-1 lg:col-span-4 glass-panel rounded-xl p-md flex flex-col relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-purple-500/10 rounded-full blur-[40px] -mr-10 -mt-10 pointer-events-none"></div>
<div class="flex justify-between items-start mb-lg relative z-10">
<div>
<h3 class="font-headline-md text-sm text-on-surface-variant uppercase tracking-wider mb-1">Active Memory</h3>
<div class="font-headline-xl text-3xl text-white">42<span class="text-lg text-slate-400 ml-1">GB</span></div>
</div>
<div class="w-10 h-10 rounded bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400">
<span class="material-symbols-outlined text-[20px]">dns</span>
</div>
</div>
<div class="mt-auto relative z-10">
<div class="flex justify-between text-xs text-on-surface-variant mb-2">
<span>64 GB Total Capacity</span>
<span class="text-purple-400">65% Utilized</span>
</div>
<div class="h-2 w-full bg-surface-container-highest/80 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-purple-600 to-purple-400 w-[65%] rounded-full shadow-[0_0_10px_rgba(168,85,247,0.5)]"></div>
</div>
</div>
</div>
<!-- Network Stats (List) -->
<div class="col-span-1 lg:col-span-4 glass-panel rounded-xl p-md flex flex-col">
<div class="flex items-center gap-2 mb-md">
<span class="material-symbols-outlined text-tertiary-container text-[20px]">swap_vert</span>
<h3 class="font-headline-md text-sm text-white uppercase tracking-wider">Network I/O</h3>
</div>
<div class="space-y-4 flex-1 flex flex-col justify-center">
<div class="flex justify-between items-center bg-surface-container-low/50 p-3 rounded-lg border border-white/5">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-green-400 text-[18px]">south_west</span>
<span class="text-sm text-slate-300">Inbound Traffic</span>
</div>
<span class="font-mono text-white text-sm">2.4 GB/s</span>
</div>
<div class="flex justify-between items-center bg-surface-container-low/50 p-3 rounded-lg border border-white/5">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-blue-400 text-[18px]">north_east</span>
<span class="text-sm text-slate-300">Outbound Traffic</span>
</div>
<span class="font-mono text-white text-sm">8.1 GB/s</span>
</div>
</div>
</div>
</section>
<!-- Two Column Layout: Users & Settings -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-gutter items-start">
<!-- User Management Table (Spans 2 columns) -->
<div class="xl:col-span-2 glass-panel rounded-xl flex flex-col overflow-hidden">
<div class="p-md border-b border-white/10 flex justify-between items-center bg-surface-container/40">
<div>
<h3 class="font-headline-md text-lg text-white">Active Personnel</h3>
<p class="text-xs text-on-surface-variant mt-1">Manage admin and moderator access levels.</p>
</div>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
<span class="text-xs text-slate-300 font-medium">12 Online</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-white/5 bg-surface-container-lowest/50 text-xs text-on-surface-variant uppercase tracking-wider font-label-sm">
<th class="py-4 px-md font-medium">User Profile</th>
<th class="py-4 px-md font-medium">Role Level</th>
<th class="py-4 px-md font-medium">Last Access</th>
<th class="py-4 px-md font-medium text-right">Actions</th>
</tr>
</thead>
<tbody class="text-sm font-body-md divide-y divide-white/5">
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="py-3 px-md">
<div class="flex items-center gap-3">
<img alt="Sarah Connor" class="w-8 h-8 rounded-full border border-white/10 object-cover" data-alt="Portrait of a young woman with dark hair smiling gently, studio lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDOSTUzzDMCE_BuwwZxnh8dIkjg2kUSd10fIxwI1A24MEqroG8fpglAh7e9CVVvvGdgXiWDiCdBqAzBJB6xhGGboZZr0eyj6j2VEUuqXKJxmXAqJeIkKEuGSRhx3nM4ONT9pPRIo8GuVT7YHKKFksXBNiHsjcfpSz1YWLX5P7dTpwBe1XoeNOQG9JpK7Xv1oGs5P3VL3Tkjn8pIlSjywUkfSQntepl2FggphhNkfv1Xp2yVrZts4mnnWlT5kvtlO93VWy7I0e-N_DA"/>
<div>
<div class="text-slate-200 font-medium">Sarah Connor</div>
<div class="text-xs text-slate-500 font-mono">s.connor@xtremflow.io</div>
</div>
</div>
</td>
<td class="py-3 px-md">
<span class="inline-flex items-center px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-400 border border-blue-500/20">Super Admin</span>
</td>
<td class="py-3 px-md text-slate-400 font-mono text-xs">
2 mins ago<br/><span class="text-[10px] text-slate-500">192.168.1.104</span>
</td>
<td class="py-3 px-md text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="p-1.5 rounded bg-surface-container hover:bg-surface-bright text-slate-300 hover:text-white transition-colors border border-white/5" title="Edit User">
<span class="material-symbols-outlined text-[16px]">edit</span>
</button>
<button class="p-1.5 rounded bg-error-container/20 hover:bg-error-container/40 text-error hover:text-red-400 transition-colors border border-error/20" title="Revoke Access">
<span class="material-symbols-outlined text-[16px]">block</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="py-3 px-md">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400 text-xs font-bold">JD</div>
<div>
<div class="text-slate-200 font-medium">John Doe</div>
<div class="text-xs text-slate-500 font-mono">j.doe@xtremflow.io</div>
</div>
</div>
</td>
<td class="py-3 px-md">
<span class="inline-flex items-center px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">Moderator</span>
</td>
<td class="py-3 px-md text-slate-400 font-mono text-xs">
1 hour ago<br/><span class="text-[10px] text-slate-500">10.0.0.42</span>
</td>
<td class="py-3 px-md text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="p-1.5 rounded bg-surface-container hover:bg-surface-bright text-slate-300 hover:text-white transition-colors border border-white/5">
<span class="material-symbols-outlined text-[16px]">edit</span>
</button>
<button class="p-1.5 rounded bg-error-container/20 hover:bg-error-container/40 text-error hover:text-red-400 transition-colors border border-error/20">
<span class="material-symbols-outlined text-[16px]">block</span>
</button>
</div>
</td>
</tr>
<tr class="hover:bg-white/[0.02] transition-colors group">
<td class="py-3 px-md">
<div class="flex items-center gap-3">
<img alt="Michael Chen" class="w-8 h-8 rounded-full border border-white/10 object-cover" data-alt="Portrait of a mature man looking off-camera with a serious expression, dark cinematic lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBXZoQNsetCsvQtmZGvMHjWgkp5jAXgOVpRJalasyuj5OwOB97vYPjvGVFVCKHTXnOaLbGh92sRbKIevyGpcYwTXorOfXPC7oXVNWLulLBRaNDX8CIMU53geIOehL1nZu6d8TmrdmdTcGaVLNu9Mj7jwsLtkXQhnT_-B83c9aW8CdLANPTxCfWzETp1uBvWIGt-7m1NsZCj5wz0Noc2BAAaTZgieX_HFKXVOSEVw43h962mbE4AfMtzeV-Ea78aGuFD1u5KRLq3a60"/>
<div>
<div class="text-slate-200 font-medium">Michael Chen</div>
<div class="text-xs text-slate-500 font-mono">m.chen@xtremflow.io</div>
</div>
</div>
</td>
<td class="py-3 px-md">
<span class="inline-flex items-center px-2 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-slate-500/10 text-slate-400 border border-slate-500/20">Viewer</span>
</td>
<td class="py-3 px-md text-slate-400 font-mono text-xs">
Yesterday<br/><span class="text-[10px] text-slate-500">172.16.0.5</span>
</td>
<td class="py-3 px-md text-right">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="p-1.5 rounded bg-surface-container hover:bg-surface-bright text-slate-300 hover:text-white transition-colors border border-white/5">
<span class="material-symbols-outlined text-[16px]">edit</span>
</button>
<button class="p-1.5 rounded bg-error-container/20 hover:bg-error-container/40 text-error hover:text-red-400 transition-colors border border-error/20">
<span class="material-symbols-outlined text-[16px]">block</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="p-3 border-t border-white/5 bg-surface-container-lowest/30 flex justify-center">
<button class="text-xs text-blue-400 hover:text-blue-300 font-medium transition-colors flex items-center gap-1">View All Users <span class="material-symbols-outlined text-[14px]">arrow_forward</span></button>
</div>
</div>
<!-- System Settings Form -->
<div class="glass-panel rounded-xl p-md flex flex-col h-full">
<div class="flex items-center gap-3 mb-6">
<span class="material-symbols-outlined text-slate-300">tune</span>
<h3 class="font-headline-md text-lg text-white">Environment Config</h3>
</div>
<form class="space-y-5 flex-1 flex flex-col">
<div>
<label class="block text-xs font-label-sm text-slate-400 mb-1.5 uppercase tracking-wider">Streaming Endpoint</label>
<input class="w-full bg-[#121317] border border-outline-variant/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 font-mono transition-all" type="text" value="rtmp://ingest.xtremflow.io/live"/>
</div>
<div>
<label class="block text-xs font-label-sm text-slate-400 mb-1.5 uppercase tracking-wider">Max Concurrent Streams</label>
<div class="relative">
<input class="w-full bg-[#121317] border border-outline-variant/60 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-mono" type="number" value="1000"/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-500">connections</span>
</div>
</div>
<div class="flex items-center justify-between p-3 rounded-lg bg-surface-container-low/50 border border-white/5 mt-2">
<div>
<div class="text-sm text-slate-200 font-medium">Auto-Scaling</div>
<div class="text-xs text-slate-500">Deploy additional nodes on high load</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input checked="" class="sr-only peer" type="checkbox" value=""/>
<div class="w-9 h-5 bg-surface-container-highest rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-500 border border-white/10 shadow-[inset_0_2px_4px_rgba(0,0,0,0.4)]"></div>
</label>
</div>
<div class="mt-auto pt-6 flex gap-3">
<button class="flex-1 py-2 rounded-lg text-sm font-medium text-slate-300 bg-surface-container hover:bg-surface-bright border border-white/10 transition-colors" type="button">Discard</button>
<button class="flex-1 py-2 rounded-lg text-sm font-medium text-on-primary-container bg-primary-container hover:bg-blue-400 transition-colors shadow-[0_0_10px_rgba(75,142,255,0.2)]" type="button">Apply Config</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

+169
View File
@@ -0,0 +1,169 @@
---
name: Cyber-Cinematic Glass
colors:
surface: '#121317'
surface-dim: '#121317'
surface-bright: '#38393d'
surface-container-lowest: '#0d0e12'
surface-container-low: '#1a1b20'
surface-container: '#1f1f24'
surface-container-high: '#292a2e'
surface-container-highest: '#343439'
on-surface: '#e3e2e7'
on-surface-variant: '#c1c6d7'
inverse-surface: '#e3e2e7'
inverse-on-surface: '#2f3035'
outline: '#8b90a0'
outline-variant: '#414755'
surface-tint: '#adc6ff'
primary: '#adc6ff'
on-primary: '#002e69'
primary-container: '#4b8eff'
on-primary-container: '#00285c'
inverse-primary: '#005bc1'
secondary: '#c6c5cf'
on-secondary: '#2f3037'
secondary-container: '#4a4b53'
on-secondary-container: '#bcbbc4'
tertiary: '#c6c6c7'
on-tertiary: '#2f3131'
tertiary-container: '#909191'
on-tertiary-container: '#282a2a'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#d8e2ff'
primary-fixed-dim: '#adc6ff'
on-primary-fixed: '#001a41'
on-primary-fixed-variant: '#004493'
secondary-fixed: '#e3e1eb'
secondary-fixed-dim: '#c6c5cf'
on-secondary-fixed: '#1a1b22'
on-secondary-fixed-variant: '#46464e'
tertiary-fixed: '#e2e2e2'
tertiary-fixed-dim: '#c6c6c7'
on-tertiary-fixed: '#1a1c1c'
on-tertiary-fixed-variant: '#454747'
background: '#121317'
on-background: '#e3e2e7'
surface-variant: '#343439'
typography:
headline-xl:
fontFamily: Space Grotesk
fontSize: 48px
fontWeight: '700'
lineHeight: '1.1'
letterSpacing: -0.02em
headline-lg:
fontFamily: Space Grotesk
fontSize: 32px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: -0.01em
headline-md:
fontFamily: Space Grotesk
fontSize: 24px
fontWeight: '500'
lineHeight: '1.3'
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: '400'
lineHeight: '1.6'
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: '1.5'
label-lg:
fontFamily: Inter
fontSize: 14px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: 0.05em
label-sm:
fontFamily: Inter
fontSize: 12px
fontWeight: '500'
lineHeight: '1.2'
letterSpacing: 0.02em
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
base: 8px
xs: 4px
sm: 12px
md: 24px
lg: 48px
xl: 80px
gutter: 24px
margin: 32px
---
## Brand & Style
The design system is built for a high-performance media environment where immersion is paramount. It targets power users who value speed, technical precision, and a premium aesthetic. The style is a sophisticated blend of **Glassmorphism** and **Futuristic Minimalism**.
The UI should feel like a high-tech command center—unobtrusive when consuming content but sharp and responsive during management tasks. By utilizing deep-space charcoals and translucent layers, the system creates a sense of infinite depth. The emotional response should be one of "effortless control" and "high-fidelity quality," achieved through high-contrast accents against an ultra-dark backdrop.
## Colors
The palette is optimized for OLED displays and low-light environments. The primary color is a vibrant neon blue, used sparingly for critical actions and active states to guide the eye without causing fatigue.
The background hierarchy uses `#0F1014` for the base canvas and `#181920` for elevated surfaces and containers. Grays are used exclusively for metadata and inactive iconography, ensuring they recede behind primary content. Accent gradients should transition from the primary blue into a deep cyan to simulate a "glowing" light source within the interface.
## Typography
This design system utilizes a dual-font strategy to balance technical aesthetics with readability. **Space Grotesk** is used for headlines and hero sections to provide a sharp, geometric, and futuristic "tech" feel. Its distinctive letterforms reinforce the platform's advanced capabilities.
**Inter** is employed for all functional UI elements, body text, and labels. Its neutral, systematic nature ensures that dense media metadata remains legible at small sizes. All labels should be treated with a slight tracking increase to enhance clarity against dark backgrounds.
## Layout & Spacing
The layout philosophy follows a **12-column fluid grid** for desktop, transitioning to a flexible single-column layout for mobile. A strict 8px rhythmic system ensures visual consistency across all components.
Spacing is used to create "visual islands," grouping related media controls while leaving generous margins around content to maintain a premium, airy feel. Components should utilize dynamic padding that scales based on the container size, ensuring the interface never feels cramped, even when managing large libraries.
## Elevation & Depth
Depth in this design system is achieved through **Glassmorphism** and layering rather than traditional drop shadows. Surfaces are defined by three distinct tiers:
1. **Base (Level 0):** Pure `#0F1014` background.
2. **Surface (Level 1):** Translucent layers with a `backdrop-filter: blur(20px)` and a 1px border at 10% white opacity.
3. **Floating (Level 2):** High-blur containers with a subtle inner glow (1px, top-left) in the primary accent color at 20% opacity.
Shadows, when used, are extra-diffused and tinted with the primary blue or charcoal to maintain the "light-from-within" aesthetic.
## Shapes
The design system employs a **Rounded** shape language to soften the futuristic edge and make the platform feel more approachable.
- **Cards and Modals:** Use `rounded-xl` (1.5rem) to emphasize the glass container effect.
- **Buttons and Inputs:** Use `rounded-lg` (1rem) for a modern, tactile feel.
- **Media Thumbnails:** Use a consistent 0.5rem radius to prevent the UI from feeling too sharp or aggressive.
## Components
### Buttons
Primary buttons use a solid gradient of `#007AFF` to a slightly lighter cyan, featuring a subtle outer glow on hover. Secondary buttons should be "Ghost" style with a 1px border and a glass background.
### Cards
Media cards are the core component. They must feature a dark semi-transparent overlay at the bottom for metadata, utilizing the backdrop-blur effect. On hover, the border opacity should increase from 10% to 40%.
### Input Fields
Inputs are dark-filled containers (`#181920`) with a 1px border that glows blue upon focus. Placeholder text should be a soft gray to maintain low visual noise.
### Chips & Badges
Used for genres or status indicators. They should be pill-shaped with a low-opacity background tint of the primary color and high-contrast white text.
### Progress Bars
Streaming progress bars use the primary accent color with a subtle neon glow effect (`box-shadow`). The "track" behind the progress should be a dark charcoal with 50% opacity.
### Navigation Sidebar
A vertical glass panel on the left with a constant backdrop blur. Active states are indicated by a vertical blue "light bar" on the left edge of the menu item.
+258
View File
@@ -0,0 +1,258 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Live TV Categories - AuraStream</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&amp;family=Inter:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#e2e2e2",
"inverse-surface": "#e3e2e7",
"on-secondary-fixed-variant": "#46464e",
"on-secondary": "#2f3037",
"inverse-on-surface": "#2f3035",
"primary": "#adc6ff",
"on-primary": "#002e69",
"primary-fixed-dim": "#adc6ff",
"on-primary-fixed-variant": "#004493",
"on-error-container": "#ffdad6",
"tertiary": "#c6c6c7",
"on-background": "#e3e2e7",
"on-surface": "#e3e2e7",
"surface": "#121317",
"outline-variant": "#414755",
"on-primary-container": "#00285c",
"error": "#ffb4ab",
"surface-container-low": "#1a1b20",
"outline": "#8b90a0",
"on-tertiary-container": "#282a2a",
"surface-tint": "#adc6ff",
"on-primary-fixed": "#001a41",
"on-tertiary-fixed": "#1a1c1c",
"inverse-primary": "#005bc1",
"background": "#121317",
"surface-bright": "#38393d",
"error-container": "#93000a",
"surface-dim": "#121317",
"surface-container-highest": "#343439",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-container": "#4a4b53",
"primary-fixed": "#d8e2ff",
"on-surface-variant": "#c1c6d7",
"surface-container": "#1f1f24",
"secondary-fixed": "#e3e1eb",
"tertiary-container": "#909191",
"secondary-fixed-dim": "#c6c5cf",
"secondary": "#c6c5cf",
"surface-variant": "#343439",
"on-secondary-container": "#bcbbc4",
"on-tertiary": "#2f3131",
"on-error": "#690005",
"primary-container": "#4b8eff",
"on-tertiary-fixed-variant": "#454747",
"surface-container-lowest": "#0d0e12",
"on-secondary-fixed": "#1a1b22",
"surface-container-high": "#292a2e"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"md": "24px",
"sm": "12px",
"margin": "32px",
"gutter": "24px",
"base": "8px",
"xs": "4px",
"lg": "48px",
"xl": "80px"
},
"fontFamily": {
"body-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"headline-md": ["Space Grotesk"],
"headline-lg": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", { "lineHeight": "1.5", "fontWeight": "400" }],
"label-sm": ["12px", { "lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500" }],
"body-lg": ["18px", { "lineHeight": "1.6", "fontWeight": "400" }],
"label-lg": ["14px", { "lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600" }],
"headline-xl": ["48px", { "lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"headline-md": ["24px", { "lineHeight": "1.3", "fontWeight": "500" }],
"headline-lg": ["32px", { "lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600" }]
}
}
}
}
</script>
<style>
.glass-card {
background: rgba(31, 31, 36, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: rgba(75, 142, 255, 0.4);
background: rgba(41, 42, 46, 0.6);
transform: translateY(-2px);
box-shadow: 0 10px 30px -10px rgba(75, 142, 255, 0.15);
}
.glow-icon {
text-shadow: 0 0 15px rgba(75, 142, 255, 0.5);
}
</style>
</head>
<body class="bg-background text-on-background font-body-md min-h-screen flex overflow-x-hidden">
<!-- SideNavBar -->
<nav class="hidden md:flex flex-col h-screen fixed left-0 top-0 w-64 border-r border-white/10 bg-[#0F1014]/80 backdrop-blur-2xl shadow-[10px_0_30px_-15px_rgba(0,122,255,0.2)] py-8 z-50">
<div class="px-6 mb-8 flex items-center gap-3">
<span class="material-symbols-outlined text-primary text-3xl glow-icon" style="font-variation-settings: 'FILL' 1;">dataset</span>
<div>
<h1 class="font-headline-md text-headline-md text-on-surface">AuraStream</h1>
<p class="font-label-sm text-label-sm text-outline">Premium Tier</p>
</div>
</div>
<div class="flex-1 flex flex-col gap-2 mt-4">
<a class="bg-blue-500/10 text-blue-400 border-l-4 border-blue-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium" href="#">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">live_tv</span>
Live TV
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">movie</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Movies</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">theaters</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Series</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">fiber_manual_record</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Recordings</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">settings</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Settings</span>
</a>
</div>
<div class="mt-auto flex flex-col gap-2">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">help</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Support</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 font-['Space_Grotesk'] text-sm font-medium hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 0;">logout</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Logout</span>
</a>
</div>
</nav>
<!-- Main Content Canvas -->
<main class="flex-1 md:ml-64 p-margin min-h-screen flex flex-col relative">
<!-- Ambient background glow -->
<div class="absolute top-0 left-1/4 w-1/2 h-1/2 bg-primary/5 blur-[120px] rounded-full pointer-events-none"></div>
<!-- Header -->
<header class="flex justify-between items-end mb-10 pb-6 border-b border-white/5 relative z-10">
<div>
<h2 class="font-headline-lg text-headline-lg text-on-surface mb-2">Live TV Categories</h2>
<p class="font-body-md text-body-md text-outline">Select a category to browse channels</p>
</div>
<div class="flex gap-4">
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline">search</span>
<input class="bg-surface-container border border-outline-variant rounded-full py-2 pl-12 pr-4 font-body-md text-body-md text-on-surface focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary w-64 transition-all placeholder:text-outline/50" placeholder="Search categories..." type="text"/>
</div>
</div>
</header>
<!-- Bento Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 relative z-10">
<!-- Category Card 1 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">FRANCE HEVC</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">142 Channels</p>
</div>
</a>
<!-- Category Card 2 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">FRANCE SPORTS</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">45 Channels</p>
</div>
</a>
<!-- Category Card 3 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">CINEMA</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">89 Channels</p>
</div>
</a>
<!-- Category Card 4 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">DOCUMENTARY</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">34 Channels</p>
</div>
</a>
<!-- Category Card 5 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">KIDS</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">28 Channels</p>
</div>
</a>
<!-- Category Card 6 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group" href="#">
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">folder</span>
</div>
<div class="mt-2">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">NEWS INT</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">62 Channels</p>
</div>
</a>
<!-- Category Card 7 -->
<a class="glass-card rounded-xl p-6 flex flex-col gap-4 group col-span-1 sm:col-span-2 lg:col-span-2 relative overflow-hidden" href="#">
<!-- Decorative element for wide card -->
<div class="absolute right-0 bottom-0 w-32 h-32 bg-primary/10 rounded-tl-full blur-2xl"></div>
<div class="w-12 h-12 rounded-lg bg-surface-container-high flex items-center justify-center border border-white/5 group-hover:bg-primary/10 transition-colors relative z-10">
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors" style="font-variation-settings: 'FILL' 0;">star</span>
</div>
<div class="mt-2 relative z-10">
<h3 class="font-headline-md text-headline-md text-on-surface group-hover:text-primary-fixed transition-colors">FAVORITES</h3>
<p class="font-label-sm text-label-sm text-outline mt-1 uppercase tracking-wider">Your curated collection</p>
</div>
</a>
</div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>XtremFlow - Login</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"surface-variant": "#343439",
"primary-fixed": "#d8e2ff",
"surface-bright": "#38393d",
"on-tertiary-container": "#282a2a",
"error-container": "#93000a",
"on-secondary": "#2f3037",
"on-primary-fixed-variant": "#004493",
"tertiary-container": "#909191",
"surface-container-lowest": "#0d0e12",
"inverse-on-surface": "#2f3035",
"surface-container-highest": "#343439",
"surface": "#121317",
"secondary-fixed-dim": "#c6c5cf",
"on-tertiary": "#2f3131",
"surface-dim": "#121317",
"on-secondary-fixed": "#1a1b22",
"on-primary": "#002e69",
"primary": "#adc6ff",
"primary-fixed-dim": "#adc6ff",
"outline": "#8b90a0",
"secondary": "#c6c5cf",
"surface-tint": "#adc6ff",
"surface-container-high": "#292a2e",
"on-tertiary-fixed-variant": "#454747",
"error": "#ffb4ab",
"on-surface": "#e3e2e7",
"secondary-container": "#4a4b53",
"on-primary-fixed": "#001a41",
"tertiary-fixed": "#e2e2e2",
"on-secondary-container": "#bcbbc4",
"on-secondary-fixed-variant": "#46464e",
"inverse-primary": "#005bc1",
"primary-container": "#4b8eff",
"tertiary": "#c6c6c7",
"inverse-surface": "#e3e2e7",
"on-error-container": "#ffdad6",
"surface-container": "#1f1f24",
"on-tertiary-fixed": "#1a1c1c",
"surface-container-low": "#1a1b20",
"on-error": "#690005",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-fixed": "#e3e1eb",
"on-surface-variant": "#c1c6d7",
"on-background": "#e3e2e7",
"outline-variant": "#414755",
"background": "#121317",
"on-primary-container": "#00285c"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"base": "8px",
"xl": "80px",
"lg": "48px",
"md": "24px",
"sm": "12px",
"xs": "4px",
"margin": "32px",
"gutter": "24px"
},
"fontFamily": {
"body-md": ["Inter"],
"headline-lg": ["Space Grotesk"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"headline-md": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "1.5", "fontWeight": "400"}],
"headline-lg": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600"}],
"label-lg": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600"}],
"headline-xl": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"label-sm": ["12px", {"lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500"}],
"body-lg": ["18px", {"lineHeight": "1.6", "fontWeight": "400"}],
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "500"}]
}
}
}
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.fill-icon {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
</style>
</head>
<body class="bg-background text-on-background min-h-screen flex items-center justify-center relative overflow-hidden font-body-md text-body-md antialiased">
<!-- Subtle Tech Gradient Background Elements -->
<div class="absolute inset-0 z-0">
<div class="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-primary-container/10 rounded-full blur-[120px]"></div>
<div class="absolute bottom-[-20%] right-[-10%] w-[60%] h-[60%] bg-inverse-primary/10 rounded-full blur-[150px]"></div>
<!-- Grid overlay for tech feel -->
<div class="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:40px_40px] opacity-20 pointer-events-none"></div>
</div>
<!-- Login Card Canvas -->
<main class="relative z-10 w-full max-w-[420px] mx-margin">
<!-- Glassmorphism Container -->
<div class="bg-surface-container/40 backdrop-blur-2xl border border-white/10 rounded-xl shadow-[0_0_40px_rgba(0,0,0,0.8)] p-lg overflow-hidden relative">
<!-- Subtle Inner Glow -->
<div class="absolute top-0 left-0 w-full h-full border border-primary-container/20 rounded-xl pointer-events-none" style="clip-path: polygon(0 0, 100% 0, 100% 1px, 0 1px);"></div>
<div class="absolute top-0 left-0 w-full h-full border border-primary-container/20 rounded-xl pointer-events-none" style="clip-path: polygon(0 0, 1px 0, 1px 100%, 0 100%);"></div>
<!-- Brand Header -->
<div class="flex flex-col items-center mb-lg">
<div class="w-12 h-12 bg-primary-container text-on-primary-container rounded-lg flex items-center justify-center mb-sm shadow-[0_0_15px_rgba(75,142,255,0.3)]">
<span class="material-symbols-outlined fill-icon text-[28px]" data-icon="hub">hub</span>
</div>
<h1 class="font-headline-lg text-headline-lg text-on-background tracking-tighter">XtremFlow</h1>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-xs tracking-widest uppercase">System Access</p>
</div>
<!-- Login Form -->
<form class="flex flex-col gap-md">
<!-- Username / Email Field -->
<div class="flex flex-col gap-xs relative">
<label class="font-label-sm text-label-sm text-on-surface-variant ml-1" for="username">Username or Email</label>
<div class="relative flex items-center">
<span class="material-symbols-outlined absolute left-sm text-outline-variant pointer-events-none" data-icon="person">person</span>
<input class="w-full bg-surface-container-highest border border-outline-variant/30 text-on-surface font-body-md text-body-md rounded-lg py-sm pl-[44px] pr-sm focus:border-primary-container focus:ring-1 focus:ring-primary-container transition-all placeholder:text-outline-variant/50 outline-none" id="username" placeholder="Enter your credentials" type="text"/>
</div>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-xs relative">
<div class="flex justify-between items-center ml-1">
<label class="font-label-sm text-label-sm text-on-surface-variant" for="password">Password</label>
<a class="font-label-sm text-label-sm text-primary-fixed hover:text-primary transition-colors" href="#">Forgot?</a>
</div>
<div class="relative flex items-center">
<span class="material-symbols-outlined absolute left-sm text-outline-variant pointer-events-none" data-icon="lock">lock</span>
<input class="w-full bg-surface-container-highest border border-outline-variant/30 text-on-surface font-body-md text-body-md rounded-lg py-sm pl-[44px] pr-[44px] focus:border-primary-container focus:ring-1 focus:ring-primary-container transition-all placeholder:text-outline-variant/50 outline-none" id="password" placeholder="••••••••" type="password"/>
<button class="absolute right-sm text-outline-variant hover:text-on-surface transition-colors flex items-center justify-center" type="button">
<span class="material-symbols-outlined" data-icon="visibility">visibility</span>
</button>
</div>
</div>
<!-- Actions -->
<div class="mt-xs">
<button class="w-full bg-primary-container text-on-primary-container font-label-lg text-label-lg py-sm rounded-lg hover:bg-primary-fixed transition-all active:scale-[0.98] shadow-[0_4px_14px_rgba(75,142,255,0.2)] hover:shadow-[0_6px_20px_rgba(75,142,255,0.3)] flex justify-center items-center gap-2" type="submit">
Sign In
<span class="material-symbols-outlined text-[18px]" data-icon="login">login</span>
</button>
</div>
</form>
<!-- Footer -->
<div class="mt-lg text-center">
<p class="font-body-sm text-[14px] text-on-surface-variant">
Require access? <a class="text-primary-fixed hover:text-primary font-medium transition-colors" href="#">Contact Administrator</a>
</p>
</div>
</div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+309
View File
@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>AuraStream - Movies</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&amp;family=Inter:wght@400;500;600&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#e2e2e2",
"inverse-surface": "#e3e2e7",
"on-secondary-fixed-variant": "#46464e",
"on-secondary": "#2f3037",
"inverse-on-surface": "#2f3035",
"primary": "#adc6ff",
"on-primary": "#002e69",
"primary-fixed-dim": "#adc6ff",
"on-primary-fixed-variant": "#004493",
"on-error-container": "#ffdad6",
"tertiary": "#c6c6c7",
"on-background": "#e3e2e7",
"on-surface": "#e3e2e7",
"surface": "#121317",
"outline-variant": "#414755",
"on-primary-container": "#00285c",
"error": "#ffb4ab",
"surface-container-low": "#1a1b20",
"outline": "#8b90a0",
"on-tertiary-container": "#282a2a",
"surface-tint": "#adc6ff",
"on-primary-fixed": "#001a41",
"on-tertiary-fixed": "#1a1c1c",
"inverse-primary": "#005bc1",
"background": "#121317",
"surface-bright": "#38393d",
"error-container": "#93000a",
"surface-dim": "#121317",
"surface-container-highest": "#343439",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-container": "#4a4b53",
"primary-fixed": "#d8e2ff",
"on-surface-variant": "#c1c6d7",
"surface-container": "#1f1f24",
"secondary-fixed": "#e3e1eb",
"tertiary-container": "#909191",
"secondary-fixed-dim": "#c6c5cf",
"secondary": "#c6c5cf",
"surface-variant": "#343439",
"on-secondary-container": "#bcbbc4",
"on-tertiary": "#2f3131",
"on-error": "#690005",
"primary-container": "#4b8eff",
"on-tertiary-fixed-variant": "#454747",
"surface-container-lowest": "#0d0e12",
"on-secondary-fixed": "#1a1b22",
"surface-container-high": "#292a2e"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"md": "24px",
"sm": "12px",
"margin": "32px",
"gutter": "24px",
"base": "8px",
"xs": "4px",
"lg": "48px",
"xl": "80px"
},
"fontFamily": {
"body-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"headline-md": ["Space Grotesk"],
"headline-lg": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", {"lineHeight": "1.5", "fontWeight": "400"}],
"label-sm": ["12px", {"lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500"}],
"body-lg": ["18px", {"lineHeight": "1.6", "fontWeight": "400"}],
"label-lg": ["14px", {"lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600"}],
"headline-xl": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
"headline-md": ["24px", {"lineHeight": "1.3", "fontWeight": "500"}],
"headline-lg": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600"}]
}
}
}
}
</script>
<style>
body { background-color: #0F1014; color: #e3e2e7; margin: 0; padding: 0; font-family: 'Inter', sans-serif; }
.glass-panel { background: rgba(18, 19, 23, 0.4); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); }
.glass-floating { background: rgba(18, 19, 23, 0.6); backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); border-top: 1px solid rgba(0, 122, 255, 0.2); border-left: 1px solid rgba(0, 122, 255, 0.2); }
.neon-glow:hover { box-shadow: 0 0 15px rgba(0, 122, 255, 0.6); }
.text-glow { text-shadow: 0 0 10px rgba(0, 122, 255, 0.5); }
/* Hide scrollbar for clean horizontal scroll */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
</head>
<body class="flex h-screen overflow-hidden bg-background">
<!-- TopNavBar (Mobile Only) -->
<nav class="md:hidden flex justify-between items-center px-8 py-4 w-full bg-slate-950/50 backdrop-blur-xl border-b border-white/10 shadow-2xl shadow-blue-500/10 sticky top-0 z-50">
<div class="text-2xl font-bold bg-gradient-to-r from-blue-500 to-cyan-400 bg-clip-text text-transparent font-headline-md text-headline-md">AuraStream</div>
<div class="flex gap-4">
<span class="material-symbols-outlined text-slate-400">search</span>
<span class="material-symbols-outlined text-slate-400">account_circle</span>
<span class="material-symbols-outlined text-slate-400">settings</span>
</div>
</nav>
<!-- SideNavBar (Desktop Only) -->
<nav class="hidden md:flex flex-col h-full py-8 bg-[#0F1014]/80 backdrop-blur-2xl border-r border-white/10 shadow-[10px_0_30px_-15px_rgba(0,122,255,0.2)] w-64 flex-shrink-0 z-40 relative">
<div class="px-6 mb-8">
<h1 class="text-blue-500 font-bold uppercase tracking-widest font-headline-md text-headline-md mb-2">AuraStream</h1>
<div class="flex items-center gap-3 mt-6">
<img alt="User Profile" class="w-10 h-10 rounded-full border border-white/20" data-alt="Close up portrait of a futuristic user profile with neon rim lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD1yLlcEt1CS5GdGEazVU-JfOAnUdK4OAP8qJ0dLcHuwvyfwAnF1ThzhD-nlIVaQWnA1fvCGrQVRm3nQe5bf0p-zuuYpk9NZ87wQF4qwhD847eO-CizIxL981JVzdec77qzgh0hnsNA9xTv-EO3LTjIyGWD-kK_Zva4tdZ07mSrfrPic0ZoY77Z4UBcqO5KRIlfHWoLCZ30XAPhs-dJ_4X5Xtk19Wlqb8AgIiGxPgpJPA85wPC5QL8dJXVts4AovPKbDrPyrGK7ZRo"/>
<div>
<div class="text-on-surface font-label-lg text-label-lg">Command Center</div>
<div class="text-on-surface-variant font-label-sm text-label-sm">Premium Tier</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto space-y-2 mt-4 font-['Space_Grotesk'] text-sm font-medium">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">live_tv</span>
<span>Live TV</span>
</a>
<a class="bg-blue-500/10 text-blue-400 border-l-4 border-blue-500 flex items-center gap-4 px-6 py-4 group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 1;">movie</span>
<span>Movies</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">theaters</span>
<span>Series</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">fiber_manual_record</span>
<span>Recordings</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">settings</span>
<span>Settings</span>
</a>
</div>
<div class="mt-auto pt-4 border-t border-white/10 font-['Space_Grotesk'] text-sm font-medium">
<a class="text-slate-500 flex items-center gap-4 px-6 py-3 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">help</span>
<span>Support</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-3 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">logout</span>
<span>Logout</span>
</a>
</div>
</nav>
<!-- Main Content Area -->
<main class="flex-1 h-full overflow-y-auto relative bg-background">
<!-- Hero Section -->
<div class="relative w-full h-[716px] min-h-[500px] flex items-end pb-xl px-lg md:px-xl" data-alt="Cinematic wide shot of a futuristic dystopian city with neon signs and a dramatic dark sky, high contrast, cinematic lighting" style="background-image: linear-gradient(to top, #0F1014 0%, rgba(15,16,20,0.4) 50%, transparent 100%), url('https://lh3.googleusercontent.com/aida-public/AB6AXuA5bf0eT94X4qeX5cXUw-H1cJerOLGgdL0tGa4xT2std6sithlBbKo117GZJwVr37YCP_LykZOSAxmXvaELEfK7eyvNZzP6wkw7SrUaJExXoUfQu9wLisZNNC4bpAuo7TzXyvhIpYNZUW_uN2gEXaus-u-9jZ5yDlUS_2A3xPBV-6jkYfo9yTrP06eWQg8AxLd_AY6JT9RtYCs9HVNTx9PQIdgPRDoDRVZVYWH3UlJHqCIWNyA888zdhIHfjFUOqMJzlxM_zyfuyA8'); background-size: cover; background-position: center;">
<div class="relative z-10 max-w-4xl glass-floating p-8 rounded-xl shadow-2xl">
<div class="flex items-center gap-4 mb-4">
<span class="bg-primary/20 text-on-surface font-label-sm text-label-sm px-3 py-1 rounded-full border border-primary/30">Sci-Fi Thriller</span>
<span class="text-tertiary font-label-sm text-label-sm flex items-center gap-1"><span class="material-symbols-outlined text-sm" style="font-variation-settings: 'FILL' 1;">star</span> 4.9</span>
<span class="text-tertiary font-label-sm text-label-sm border border-outline-variant px-2 rounded">PG-13</span>
<span class="text-tertiary font-label-sm text-label-sm">2h 15m</span>
</div>
<h2 class="font-headline-xl text-headline-xl text-on-surface mb-2 text-glow">The Mother and the Bear (2026)</h2>
<p class="font-body-lg text-body-lg text-on-surface-variant mb-8 max-w-2xl">In a neon-drenched future where memories are currency, a rogue detective must unravel a conspiracy that threatens to erase humanity's past before her own mind is wiped clean.</p>
<div class="flex gap-4">
<button class="bg-gradient-to-r from-[#007AFF] to-cyan-500 text-white font-label-lg text-label-lg px-8 py-3 rounded-lg flex items-center gap-2 neon-glow transition-all duration-300">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Play Now
</button>
<button class="glass-panel text-on-surface font-label-lg text-label-lg px-8 py-3 rounded-lg flex items-center gap-2 hover:bg-white/10 transition-all duration-300">
<span class="material-symbols-outlined">info</span>
More Info
</button>
</div>
</div>
</div>
<!-- Featured Section (Bento Grid Style) -->
<div class="px-lg md:px-xl py-lg">
<div class="flex justify-between items-end mb-6">
<h3 class="font-headline-md text-headline-md text-on-surface">Trending Now</h3>
<div class="flex gap-2">
<button class="glass-panel p-2 rounded-full hover:bg-white/10 transition-colors"><span class="material-symbols-outlined">chevron_left</span></button>
<button class="glass-panel p-2 rounded-full hover:bg-white/10 transition-colors"><span class="material-symbols-outlined">chevron_right</span></button>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
<!-- Card 1 (Large) -->
<div class="col-span-2 row-span-2 relative rounded-xl overflow-hidden group cursor-pointer border border-white/10 hover:border-primary/40 transition-all duration-500">
<img alt="Movie Poster" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" data-alt="Abstract cinematic explosion of color and light in deep space, high fidelity rendering" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCsqlExaOiee-H9lUU2oabuN0rln0QbBDmDBV0TTZNFz45WnVVVfV6oYSCNRo5R4REIiQfACd36D6OpkYsVXL_C9pDSxaxGwhW19mx7BJo2-3LDON2s8PDhO7pqEf_E5Ha-Wg0bCqH7UPXHcf5GJkjAo-S0dcYOOIskCTgOPTKrXo_8ijsyHb5QVBtvU5BgQuvkY4lpBNWWX4zxlkCoq4eUYUboVryMUkIBfFSnjMIqKk_5vR9DecVKUumuqhqb00qceBHP7W7SRsg"/>
<div class="absolute inset-0 bg-gradient-to-t from-[#0F1014] via-[#0F1014]/50 to-transparent opacity-80 group-hover:opacity-90 transition-opacity duration-300"></div>
<div class="absolute bottom-0 left-0 w-full p-6 glass-panel translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
<h4 class="font-headline-md text-headline-md text-on-surface mb-1 text-glow">Quantum Paradox</h4>
<div class="flex items-center gap-3 text-tertiary font-label-sm text-label-sm">
<span class="text-primary flex items-center gap-1"><span class="material-symbols-outlined text-[14px]" style="font-variation-settings: 'FILL' 1;">star</span> 4.8</span>
<span>Sci-Fi</span>
<span>2025</span>
</div>
</div>
</div>
<!-- Card 2 -->
<div class="relative rounded-xl overflow-hidden group cursor-pointer border border-white/10 hover:border-primary/40 transition-all duration-500 h-64 md:h-auto">
<img alt="Movie Poster" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" data-alt="Dark moody portrait of a mysterious figure in a rainy cyberpunk alleyway, blue and pink neon reflections" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDU_tshT9jmch027T8j9cESQtIlQonKSBsa9foyaOpCkqd4Mt_MSmfpo7_WeMfMCvkfz3vSdbBueqHuxWK8WozdDtx5GsdZ8MMifux_KO4RiK1t_vAoXdQQsRjXVKpsG28A38Cpi_1GDLkCYFPNAlPnnbmR0nGwIuQuZuaiZaEL45vFTS6MoAV8pQ7p_fNZbcu8mDflb-QEf32MGWrn-NCpZ12e9QOsQmrDe19eJQ4GxCn6DSqgydsb3kUShLgJbGR4GKSHSbwlqeg"/>
<div class="absolute bottom-0 left-0 w-full p-4 glass-panel bg-surface-container/80 backdrop-blur-md">
<h4 class="font-label-lg text-label-lg text-on-surface truncate">Neon Rain</h4>
<div class="text-tertiary font-label-sm text-label-sm mt-1">Action • 4.5</div>
</div>
</div>
<!-- Card 3 -->
<div class="relative rounded-xl overflow-hidden group cursor-pointer border border-white/10 hover:border-primary/40 transition-all duration-500 h-64 md:h-auto">
<img alt="Movie Poster" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" data-alt="Epic landscape of an alien planet with multiple moons in the sky, highly detailed matte painting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuATh2INviHPBKNvU83N2AXadQqMJ2GK0z24kY1bRoKIbB1W_61k4zNxSZ6CioBWtSq7UFz9WFQnb6FtEi_slvhCpAYAZroHKvrI5LSHaIFvk8Eak20rJVoJHgSQlqQXk3CDY4txACeWBpTt7QRBqu2_HvA6ojnFvlFWecfhvZ66EQXp5X7P5KGhfyTLQvt527YxtRtaLyQrXafyNQ8SgTgqx1eO_y-lUnwJE6XNTMCKUecm6GMgVkohWrpdXmPa3Y3Rb7mXvIZ8MO0"/>
<div class="absolute bottom-0 left-0 w-full p-4 glass-panel bg-surface-container/80 backdrop-blur-md">
<h4 class="font-label-lg text-label-lg text-on-surface truncate">Beyond Horizon</h4>
<div class="text-tertiary font-label-sm text-label-sm mt-1">Adventure • 4.7</div>
</div>
</div>
<!-- Card 4 -->
<div class="relative rounded-xl overflow-hidden group cursor-pointer border border-white/10 hover:border-primary/40 transition-all duration-500 h-64 md:h-auto">
<img alt="Movie Poster" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" data-alt="Close up of a futuristic glowing artifact floating above a pedestal in a dark room, glowing blue accents" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDmxuG0jdK5b-WeYSNXL2FdnqPbeFPeEy2TLvYGGV-vQvRQbk5FRtsmqbWU26DY7dGFo8EjAymHdj0xTvH6voSOaMpnMfjJToLNTsugJhNL9YLq_o6cz-Wo5BGm_qPV5XpDN_WYitMkG7krZ9NlS_EPEcepCKWtQcazoRdadqq1G4wLrSw7vn-ZyyQXZm6lTkSTNbeXNFXLZzRkc88e2qB97j2Wmt0MDS7o51DahlGwjW2bnwglbh8oIQKVzcsDgrv80wpzoO9mTFo"/>
<div class="absolute bottom-0 left-0 w-full p-4 glass-panel bg-surface-container/80 backdrop-blur-md">
<h4 class="font-label-lg text-label-lg text-on-surface truncate">The Artifact</h4>
<div class="text-tertiary font-label-sm text-label-sm mt-1">Mystery • 4.3</div>
</div>
</div>
<!-- Card 5 -->
<div class="relative rounded-xl overflow-hidden group cursor-pointer border border-white/10 hover:border-primary/40 transition-all duration-500 h-64 md:h-auto">
<img alt="Movie Poster" class="w-full h-full object-cover transform group-hover:scale-105 transition-transform duration-700" data-alt="Abstract smooth gradient flowing from deep navy to soft lavender with subtle grain texture" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBt9ABx9UMABF66XfrbKo0yaW1SZ9NAi2yfHBtwtd61ohhasYpN9Ua18A6bF1j88oXCZ3HziOn386C-CfCXmZcBZiK5KBfiXjI9ds7szgXZIjxaOyBohqZKN2X_qtYYyMdMfYlcf1lKMjsgjx5M3NIPQjdkVfdOPGcd6GPPkgFSVTjMAbIRO71EnKIejfUumxiYTvpH5i_Pa2ZCQNc0_ZkKnX5r7d5tynss9Hm4chGAupgKDTDJeqKEhq32MTREm5on3y2Z-W0t3N4"/>
<div class="absolute bottom-0 left-0 w-full p-4 glass-panel bg-surface-container/80 backdrop-blur-md">
<h4 class="font-label-lg text-label-lg text-on-surface truncate">Echoes of Void</h4>
<div class="text-tertiary font-label-sm text-label-sm mt-1">Thriller • 4.6</div>
</div>
</div>
</div>
</div>
<!-- Horizontal Scroll Section -->
<div class="px-lg md:px-xl py-lg mb-xl">
<h3 class="font-headline-md text-headline-md text-on-surface mb-6">Continue Watching</h3>
<div class="flex overflow-x-auto gap-4 md:gap-6 no-scrollbar pb-4">
<!-- Scroll Item 1 -->
<div class="flex-none w-72 relative rounded-xl overflow-hidden border border-white/10 hover:border-primary/30 transition-all duration-300">
<div class="relative h-40 w-full">
<img alt="Scene" class="w-full h-full object-cover" data-alt="Cinematic shot of an old vintage movie camera on a set with dramatic lighting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBHeS-ADRVCbXPzfi9NVwbUF8-QmD7kIwlpN49wNz6nM97Veb61zOYvTWbmtiDKRkBU69pFaJy4o18LmcW2J0XVWFqn8GgK1Js3fJHT3Fmv7ulVwXXEnUjgJchbJCC4uJiZJXUqZQAGN354FDf0AAt_U0duCq9kcElpI6q4_vfCyCKd97_gqdIMufoignAn2cw5qHMByNah5qfDS2C8zX8EkdTZNMVUh2ZFa1EvJYSCjDwJpBxYExVY8VocpuUaMAmnKLcwuaMupio"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-300 cursor-pointer">
<span class="material-symbols-outlined text-white text-4xl" style="font-variation-settings: 'FILL' 1;">play_circle</span>
</div>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 w-full h-1 bg-surface-highest">
<div class="h-full bg-primary shadow-[0_0_8px_rgba(173,198,255,0.8)]" style="width: 65%;"></div>
</div>
</div>
<div class="p-4 glass-panel">
<h4 class="font-label-lg text-label-lg text-on-surface truncate mb-1">Director's Cut</h4>
<div class="text-tertiary font-label-sm text-label-sm">1h 12m remaining</div>
</div>
</div>
<!-- Scroll Item 2 -->
<div class="flex-none w-72 relative rounded-xl overflow-hidden border border-white/10 hover:border-primary/30 transition-all duration-300">
<div class="relative h-40 w-full">
<img alt="Scene" class="w-full h-full object-cover" data-alt="A vintage movie clapperboard sitting on a wooden surface with a shallow depth of field" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA5uTzakxjr5j0E70zJKh7p5aELPN_pq93luMuygnshg4qvzbqfRI8NnYV2mk_5lM_S4pRcvVV79lQP2pZQlZ3Rz7kzbfOVUhgMJWrOrgkF8_xxkxNKy7rCOPA2yRGj0Hh2sDEgtmaSQXntyxcN7q-ZcNoUUJe9zrAIC_QGiSDvKL_rh8JVW4jahav62LrazlvWK8Uni_LHEhGS1sij_vh-qwP89qw6YmRJjlvfwjsyFRMUV6XCHMNqDXpNmOw6n9ZmvhIkSVpqRM4"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-300 cursor-pointer">
<span class="material-symbols-outlined text-white text-4xl" style="font-variation-settings: 'FILL' 1;">play_circle</span>
</div>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 w-full h-1 bg-surface-highest">
<div class="h-full bg-primary shadow-[0_0_8px_rgba(173,198,255,0.8)]" style="width: 25%;"></div>
</div>
</div>
<div class="p-4 glass-panel">
<h4 class="font-label-lg text-label-lg text-on-surface truncate mb-1">The Final Act</h4>
<div class="text-tertiary font-label-sm text-label-sm">2h 05m remaining</div>
</div>
</div>
<!-- Scroll Item 3 -->
<div class="flex-none w-72 relative rounded-xl overflow-hidden border border-white/10 hover:border-primary/30 transition-all duration-300">
<div class="relative h-40 w-full">
<img alt="Scene" class="w-full h-full object-cover" data-alt="Empty cinema seats lit by the glowing light of a projector screen in a dark room" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAkrCeck81LSgXP4eslCwBeRpjUOUfTqeP3kdg1dkqCsfbfwbgCzTP5K8b8daFM2qXzdEXIfMGqE5tRnmv0hSA-FoCSYnfuDOjAqTxoRy7OhR3gEj8vmk4AbSPuzRhecfWU0wjJLxyINLWDSN8i7CnAFFZHySF9DtD0yRd1-ovpSK7G7iNIN7fFfNYW2YUngF545-dfIUGVC61NEvEiD1CHIuczUHe6aYuNyIIpHz0QOn82h-0GODsL261CdivS_sXsJg86ZaryZA8"/>
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-300 cursor-pointer">
<span class="material-symbols-outlined text-white text-4xl" style="font-variation-settings: 'FILL' 1;">play_circle</span>
</div>
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 w-full h-1 bg-surface-highest">
<div class="h-full bg-primary shadow-[0_0_8px_rgba(173,198,255,0.8)]" style="width: 85%;"></div>
</div>
</div>
<div class="p-4 glass-panel">
<h4 class="font-label-lg text-label-lg text-on-surface truncate mb-1">Silent Shadows</h4>
<div class="text-tertiary font-label-sm text-label-sm">15m remaining</div>
</div>
</div>
</div>
</div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,292 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Enregistrements - XtremFlow</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#e2e2e2",
"inverse-surface": "#e3e2e7",
"on-secondary-fixed-variant": "#46464e",
"on-secondary": "#2f3037",
"inverse-on-surface": "#2f3035",
"primary": "#adc6ff",
"on-primary": "#002e69",
"primary-fixed-dim": "#adc6ff",
"on-primary-fixed-variant": "#004493",
"on-error-container": "#ffdad6",
"tertiary": "#c6c6c7",
"on-background": "#e3e2e7",
"on-surface": "#e3e2e7",
"surface": "#121317",
"outline-variant": "#414755",
"on-primary-container": "#00285c",
"error": "#ffb4ab",
"surface-container-low": "#1a1b20",
"outline": "#8b90a0",
"on-tertiary-container": "#282a2a",
"surface-tint": "#adc6ff",
"on-primary-fixed": "#001a41",
"on-tertiary-fixed": "#1a1c1c",
"inverse-primary": "#005bc1",
"background": "#121317",
"surface-bright": "#38393d",
"error-container": "#93000a",
"surface-dim": "#121317",
"surface-container-highest": "#343439",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-container": "#4a4b53",
"primary-fixed": "#d8e2ff",
"on-surface-variant": "#c1c6d7",
"surface-container": "#1f1f24",
"secondary-fixed": "#e3e1eb",
"tertiary-container": "#909191",
"secondary-fixed-dim": "#c6c5cf",
"secondary": "#c6c5cf",
"surface-variant": "#343439",
"on-secondary-container": "#bcbbc4",
"on-tertiary": "#2f3131",
"on-error": "#690005",
"primary-container": "#4b8eff",
"on-tertiary-fixed-variant": "#454747",
"surface-container-lowest": "#0d0e12",
"on-secondary-fixed": "#1a1b22",
"surface-container-high": "#292a2e"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"md": "24px",
"sm": "12px",
"margin": "32px",
"gutter": "24px",
"base": "8px",
"xs": "4px",
"lg": "48px",
"xl": "80px"
},
"fontFamily": {
"body-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"headline-md": ["Space Grotesk"],
"headline-lg": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", { "lineHeight": "1.5", "fontWeight": "400" }],
"label-sm": ["12px", { "lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500" }],
"body-lg": ["18px", { "lineHeight": "1.6", "fontWeight": "400" }],
"label-lg": ["14px", { "lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600" }],
"headline-xl": ["48px", { "lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"headline-md": ["24px", { "lineHeight": "1.3", "fontWeight": "500" }],
"headline-lg": ["32px", { "lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600" }]
}
}
}
}
</script>
<style>
.glass-panel {
background: rgba(18, 19, 23, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glow-hover:hover {
box-shadow: 0 0 20px rgba(173, 198, 255, 0.2);
border-color: rgba(173, 198, 255, 0.4);
}
</style>
</head>
<body class="bg-background text-on-background min-h-screen flex overflow-hidden">
<!-- SideNavBar from JSON -->
<nav class="h-screen fixed left-0 top-0 w-64 border-r border-white/10 shadow-[10px_0_30px_-15px_rgba(0,122,255,0.2)] bg-[#0F1014]/80 backdrop-blur-2xl flex flex-col h-full py-8 z-50">
<div class="px-6 mb-8 flex items-center gap-4">
<img alt="User Profile" class="w-12 h-12 rounded-full border border-outline/30 object-cover" data-alt="Close up portrait of a young man with a neutral expression, dark background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBUHb3yUjf89D-0v8UESRGpC6at8VoDc4DJ8V5D6pAcW0J_gCgg1ZvJzmVj6V5HmLHFwpBPwvjCuwZP1BZDk1DRiEynAfto0aD2RETGHJ1G_0d7xGOEDqrywl0eqrYWNwD9l81jN0vl0OolwiQElFflOs3lRuEkdK5goIGKwnJ3Rq-DiFevgKRHijZ5IFhTyCNEABGs5Ird48gsslPKtvgLpi3jgC5AQ8HS5CjUOA1mThnHj_y0Hh9VPt0u10IV1QAjX_kMZjSFqDo"/>
<div>
<h2 class="font-headline-md text-headline-md text-on-surface">Command Center</h2>
<p class="font-label-sm text-label-sm text-on-surface-variant">Premium Tier</p>
</div>
</div>
<div class="flex-1 px-0 space-y-2">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">live_tv</span>
<span class="font-label-lg text-label-lg">Live TV</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">movie</span>
<span class="font-label-lg text-label-lg">Movies</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">theaters</span>
<span class="font-label-lg text-label-lg">Series</span>
</a>
<a class="bg-blue-500/10 text-blue-400 border-l-4 border-blue-500 flex items-center gap-4 px-6 py-4" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium" style="font-variation-settings: 'FILL' 1;">fiber_manual_record</span>
<span class="font-label-lg text-label-lg">Recordings</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">settings</span>
<span class="font-label-lg text-label-lg">Settings</span>
</a>
</div>
<div class="mt-auto px-0 space-y-2 pt-8 border-t border-white/5 mx-6">
<a class="text-slate-500 flex items-center gap-4 py-2 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">help</span>
<span class="font-label-lg text-label-lg">Support</span>
</a>
<a class="text-slate-500 flex items-center gap-4 py-2 hover:bg-white/5 hover:text-white transition-colors group-hover:translate-x-1 transition-transform duration-200" href="#">
<span class="material-symbols-outlined font-['Space_Grotesk'] text-sm font-medium">logout</span>
<span class="font-label-lg text-label-lg">Logout</span>
</a>
</div>
</nav>
<!-- Main Content Canvas -->
<main class="ml-64 flex-1 flex flex-col h-screen overflow-y-auto bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-surface-container-high via-background to-background">
<header class="px-lg py-margin sticky top-0 z-40 bg-background/80 backdrop-blur-xl border-b border-white/5">
<div class="flex justify-between items-end">
<div>
<h1 class="font-headline-xl text-headline-xl text-on-surface">Enregistrements</h1>
<p class="font-body-md text-body-md text-on-surface-variant mt-2">Gérez vos programmes sportifs enregistrés.</p>
</div>
<div class="flex gap-4">
<button class="glass-panel px-4 py-2 rounded-lg font-label-lg text-label-lg text-on-surface hover:bg-surface-variant transition-colors flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">filter_list</span>
Filtrer
</button>
<button class="bg-gradient-to-r from-[#007AFF] to-[#00C6FF] text-white px-4 py-2 rounded-lg font-label-lg text-label-lg hover:shadow-[0_0_15px_rgba(0,122,255,0.4)] transition-all flex items-center gap-2">
<span class="material-symbols-outlined text-[18px]">add</span>
Nouveau
</button>
</div>
</div>
</header>
<div class="p-lg flex-1">
<div class="flex flex-col gap-4">
<!-- Recording Item 1 -->
<div class="glass-panel rounded-xl p-4 flex items-center justify-between glow-hover transition-all duration-300 group cursor-pointer">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-lg bg-surface-container overflow-hidden flex items-center justify-center relative">
<img alt="Football Pitch" class="w-full h-full object-cover opacity-60" data-alt="Abstract green football pitch with stadium lights glowing in the dark background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBlIKTXIkr4UrfaRj-jlUL3val7L2ARMwvoQY3BqX4xJYOXbXB1etTrgWjfDrA2fk0Z1ZeV6e4IcTyveGm6C-5VUOzEmHrWwIHwG-8_gPA7SB6sJZRer0zqeCv8w0xgb9g0u028K4wdxGuf0CnvSGwvCuAe5tyhXdBIxeHI-d1PjsxO7yz5UMn3qltsmYGnxbPDEh-Oxn794vwEHVPWtVzBw8RXcbYwdRTQNncgysXXBgwasF88jT5NFwPH-4GTLm66QmThDWAcSSA"/>
<span class="material-symbols-outlined absolute text-primary text-[32px] drop-shadow-lg">sports_soccer</span>
</div>
<div>
<h3 class="font-headline-lg text-[20px] text-on-surface mb-1">Ligue 1: PSG vs OM</h3>
<div class="flex items-center gap-3 font-label-sm text-label-sm text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">calendar_today</span> 12 Oct 2023</span>
<span>•</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> 21:00 (120 min)</span>
<span>•</span>
<span class="text-primary-fixed-dim">BEIN SPORTS 1</span>
</div>
</div>
</div>
<div class="flex items-center gap-8">
<div class="px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 font-label-sm text-label-sm flex items-center gap-2 shadow-[0_0_10px_rgba(52,211,153,0.2)]">
<div class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Terminé
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors">
<span class="material-symbols-outlined">info</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-error hover:border-error/50 hover:bg-error/10 transition-colors">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
</div>
<!-- Recording Item 2 -->
<div class="glass-panel rounded-xl p-4 flex items-center justify-between glow-hover transition-all duration-300 group cursor-pointer">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-lg bg-surface-container overflow-hidden flex items-center justify-center relative">
<img alt="Basketball Court" class="w-full h-full object-cover opacity-60" data-alt="Dark dramatic lighting on an indoor basketball court wooden floor" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBuAxQ5d1F_AjiDesX_IOUWZ-7MUrbKFeRtf0r6JHVXuIfKUyNrwTO8XXQtrzjqTSoUO2uYBjsRavJCN8zuZt9NNirkUxJ_BC_HEDNKR0j1ZJq7whnX4IXQRXkRa_gse1Ck7HBHlaY4AROlDVSNvEYZe8mphvkqq9TmOodiNoH4iWCLr0R9RuIsYJk2qjDcMdwW41w_EpmTpyAXXOzWpNkBEB11M_n5CnDNYVPZ7VwtpMFwI8sgDsZ5zYkMjo0PJzAVbpApa0F1dh8"/>
<span class="material-symbols-outlined absolute text-primary text-[32px] drop-shadow-lg">sports_basketball</span>
</div>
<div>
<h3 class="font-headline-lg text-[20px] text-on-surface mb-1">NBA Finals: Game 6</h3>
<div class="flex items-center gap-3 font-label-sm text-label-sm text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">calendar_today</span> 10 Oct 2023</span>
<span>•</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> 03:00 (180 min)</span>
<span>•</span>
<span class="text-primary-fixed-dim">BEIN SPORTS 2</span>
</div>
</div>
</div>
<div class="flex items-center gap-8">
<div class="px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/30 text-amber-400 font-label-sm text-label-sm flex items-center gap-2 shadow-[0_0_10px_rgba(245,158,11,0.2)]">
<div class="w-2 h-2 rounded-full bg-amber-400"></div>
Échoué
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors opacity-50 cursor-not-allowed">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors">
<span class="material-symbols-outlined">info</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-error hover:border-error/50 hover:bg-error/10 transition-colors">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
</div>
<!-- Recording Item 3 -->
<div class="glass-panel rounded-xl p-4 flex items-center justify-between glow-hover transition-all duration-300 group cursor-pointer">
<div class="flex items-center gap-6">
<div class="w-16 h-16 rounded-lg bg-surface-container overflow-hidden flex items-center justify-center relative">
<img alt="Tennis Ball" class="w-full h-full object-cover opacity-60" data-alt="Close up of a yellow tennis ball on a dark green hard court surface" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBhEXEQs08c5_awogBJrV1sNwKxQVEQhjEZjuyBh_e9mQmYMfH_boI9ZrYiCjccoAE3Fa1O7ouqRqVlToqxH14hcW38IHY0VZrIepn2ekBT9pWt4OPWIODRY2bZ0AhUpU2e46Q06qin0foVozgerAPRpFcPvSToJrlICAyg6AGESZHWZQ8PA4-Y2ysGo6xA52vS2bO7V7LX9-zJhpuzhSoUePqbKd0EoBe2dSvqnKYVNaSyzyWyWVvBHisGV9ZKsXe4UmvBGanvu7A"/>
<span class="material-symbols-outlined absolute text-primary text-[32px] drop-shadow-lg">sports_tennis</span>
</div>
<div>
<h3 class="font-headline-lg text-[20px] text-on-surface mb-1">Roland Garros: Finale H</h3>
<div class="flex items-center gap-3 font-label-sm text-label-sm text-on-surface-variant">
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">calendar_today</span> 05 Oct 2023</span>
<span>•</span>
<span class="flex items-center gap-1"><span class="material-symbols-outlined text-[14px]">schedule</span> 15:00 (240 min)</span>
<span>•</span>
<span class="text-primary-fixed-dim">BEIN SPORTS 1</span>
</div>
</div>
</div>
<div class="flex items-center gap-8">
<div class="px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 font-label-sm text-label-sm flex items-center gap-2 shadow-[0_0_10px_rgba(52,211,153,0.2)]">
<div class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Terminé
</div>
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-primary hover:border-primary/50 transition-colors">
<span class="material-symbols-outlined">info</span>
</button>
<button class="w-10 h-10 rounded-full glass-panel flex items-center justify-center text-on-surface hover:text-error hover:border-error/50 hover:bg-error/10 transition-colors">
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

+394
View File
@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>XtremFlow - TV Series</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#e2e2e2",
"inverse-surface": "#e3e2e7",
"on-secondary-fixed-variant": "#46464e",
"on-secondary": "#2f3037",
"inverse-on-surface": "#2f3035",
"primary": "#adc6ff",
"on-primary": "#002e69",
"primary-fixed-dim": "#adc6ff",
"on-primary-fixed-variant": "#004493",
"on-error-container": "#ffdad6",
"tertiary": "#c6c6c7",
"on-background": "#e3e2e7",
"on-surface": "#e3e2e7",
"surface": "#121317",
"outline-variant": "#414755",
"on-primary-container": "#00285c",
"error": "#ffb4ab",
"surface-container-low": "#1a1b20",
"outline": "#8b90a0",
"on-tertiary-container": "#282a2a",
"surface-tint": "#adc6ff",
"on-primary-fixed": "#001a41",
"on-tertiary-fixed": "#1a1c1c",
"inverse-primary": "#005bc1",
"background": "#121317",
"surface-bright": "#38393d",
"error-container": "#93000a",
"surface-dim": "#121317",
"surface-container-highest": "#343439",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-container": "#4a4b53",
"primary-fixed": "#d8e2ff",
"on-surface-variant": "#c1c6d7",
"surface-container": "#1f1f24",
"secondary-fixed": "#e3e1eb",
"tertiary-container": "#909191",
"secondary-fixed-dim": "#c6c5cf",
"secondary": "#c6c5cf",
"surface-variant": "#343439",
"on-secondary-container": "#bcbbc4",
"on-tertiary": "#2f3131",
"on-error": "#690005",
"primary-container": "#4b8eff",
"on-tertiary-fixed-variant": "#454747",
"surface-container-lowest": "#0d0e12",
"on-secondary-fixed": "#1a1b22",
"surface-container-high": "#292a2e"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"md": "24px",
"sm": "12px",
"margin": "32px",
"gutter": "24px",
"base": "8px",
"xs": "4px",
"lg": "48px",
"xl": "80px"
},
"fontFamily": {
"body-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"label-lg": [
"Inter"
],
"headline-xl": [
"Space Grotesk"
],
"headline-md": [
"Space Grotesk"
],
"headline-lg": [
"Space Grotesk"
]
},
"fontSize": {
"body-md": [
"16px",
{
"lineHeight": "1.5",
"fontWeight": "400"
}
],
"label-sm": [
"12px",
{
"lineHeight": "1.2",
"letterSpacing": "0.02em",
"fontWeight": "500"
}
],
"body-lg": [
"18px",
{
"lineHeight": "1.6",
"fontWeight": "400"
}
],
"label-lg": [
"14px",
{
"lineHeight": "1.2",
"letterSpacing": "0.05em",
"fontWeight": "600"
}
],
"headline-xl": [
"48px",
{
"lineHeight": "1.1",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-md": [
"24px",
{
"lineHeight": "1.3",
"fontWeight": "500"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "1.2",
"letterSpacing": "-0.01em",
"fontWeight": "600"
}
]
}
},
},
}
</script>
</head>
<body class="bg-background text-on-background min-h-screen flex selection:bg-primary-container selection:text-on-primary-container">
<!-- SideNavBar (from JSON) -->
<nav class="hidden md:flex h-screen fixed left-0 top-0 w-64 border-r border-white/10 bg-[#0F1014]/80 backdrop-blur-2xl shadow-[10px_0_30px_-15px_rgba(0,122,255,0.2)] flex-col h-full py-8 z-50">
<!-- Header -->
<div class="px-6 mb-8 flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-surface-variant flex items-center justify-center overflow-hidden border border-white/10">
<img alt="User Profile" class="w-full h-full object-cover" data-alt="Portrait of a young male user, professional lighting, dark background, avatar style" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBeZC4RgDut3l9e8Yhvtszoa8oVpVwSxh9TcyzDgjyNQTK9cl1kgDoEnWmrd3Bw9IsBYB9eY1zQc7A_hlFwHhuB7OGx2VSMdhvt_s8XB374y34XLUt1B7L6ufCoEf6ju2f90Z_zS4IttLXA9DITjLtxCzqKUfHJtuHamik1PlBkM-SqNF6NJfiLRDJ2OLRMyAf4qQczW-03ZSnuGp5gKh0KqXOirYxZ6542-TXF7rtucQiEefYlwVUCyPr7qYVMfEkfnqnFs8rVruw"/>
</div>
<div>
<h2 class="font-headline-md text-headline-md text-blue-500 font-bold uppercase tracking-widest text-sm leading-tight">Command Center</h2>
<p class="font-label-sm text-label-sm text-on-surface-variant">Premium Tier</p>
</div>
</div>
<!-- Main Navigation -->
<div class="flex-1 flex flex-col gap-2 font-['Space_Grotesk'] text-sm font-medium">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">live_tv</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Live TV</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">movie</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Movies</span>
</a>
<!-- Active State -->
<a class="bg-blue-500/10 text-blue-400 border-l-4 border-blue-500 flex items-center gap-4 px-6 py-4 group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">theaters</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Series</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">fiber_manual_record</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Recordings</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">settings</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Settings</span>
</a>
</div>
<!-- Footer Navigation -->
<div class="mt-auto flex flex-col gap-2 font-['Space_Grotesk'] text-sm font-medium border-t border-white/5 pt-4">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">help</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Support</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined text-lg group-hover:translate-x-1 transition-transform duration-200">logout</span>
<span class="group-hover:translate-x-1 transition-transform duration-200">Logout</span>
</a>
</div>
</nav>
<!-- Main Content Area -->
<main class="flex-1 md:ml-64 relative overflow-y-auto">
<!-- Hero Section: L'affaire Moloch -->
<section class="relative h-[716px] min-h-[600px] w-full flex items-end pb-xl pt-32 px-md md:px-xl overflow-hidden">
<!-- Background Image -->
<div class="absolute inset-0 z-0">
<img alt="L'affaire Moloch Background" class="w-full h-full object-cover opacity-60" data-alt="Dark, moody cinematic shot of a mysterious figure in a trench coat standing in a foggy, dimly lit European street, neon reflections on wet cobblestone" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCz9SytGaUq4pVuS4xyPqRE9mwnqySR4Hj_xQSzNDUmtjNYm3lZnmtMS5--st-JXJP9GGmvcbTnBlv2PBGZ-wX4hH63QUdZLPpPkalsGDWGlfxK_oZlEhDpAGKUiU2o44Jzpa1ibnmVP9XlNnBmnKxI-fM1BmoJEiJaS4iJSBFmtQoxa-u-Fd1RffEUlDyIcOWbqjJrc_SdjtUjisCfnjjRVPRWUKmgJ5cw101wyudgzg_BfqcYz_GEkagax1kZIawTMloUiZSE4Y8"/>
<!-- Gradient Overlays for integration -->
<div class="absolute inset-0 bg-gradient-to-t from-[#0F1014] via-[#0F1014]/60 to-transparent"></div>
<div class="absolute inset-0 bg-gradient-to-r from-[#0F1014] via-transparent to-transparent"></div>
</div>
<!-- Top Search/Profile (Mobile only mostly, since nav is fixed) -->
<div class="absolute top-0 right-0 p-md flex gap-4 z-20 md:hidden">
<button class="w-10 h-10 rounded-full bg-surface/50 backdrop-blur-xl border border-white/10 flex items-center justify-center text-on-surface hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined">search</span>
</button>
</div>
<!-- Hero Content -->
<div class="relative z-10 max-w-4xl flex flex-col gap-sm">
<!-- Badges -->
<div class="flex items-center gap-3 mb-2">
<span class="px-3 py-1 rounded-full bg-primary/20 border border-primary/30 text-primary font-label-sm text-label-sm backdrop-blur-md">NEW EPISODE</span>
<span class="px-3 py-1 rounded-full bg-surface-variant/50 border border-white/10 text-on-surface-variant font-label-sm text-label-sm backdrop-blur-md">THRILLER</span>
<span class="px-3 py-1 rounded-full bg-surface-variant/50 border border-white/10 text-on-surface-variant font-label-sm text-label-sm backdrop-blur-md">4K HDR</span>
</div>
<h1 class="font-headline-xl text-headline-xl text-on-surface drop-shadow-2xl">L'affaire Moloch</h1>
<p class="font-body-lg text-body-lg text-on-surface-variant max-w-2xl mt-4 line-clamp-3">
A disgraced detective and an ambitious journalist uncover a web of corruption spanning decades in a sleepy coastal town. As they dig deeper, the lines between truth and paranoia begin to blur.
</p>
<div class="flex items-center gap-4 mt-8">
<!-- Primary Button -->
<button class="h-12 px-8 rounded-lg bg-gradient-to-r from-[#007AFF] to-cyan-400 text-white font-label-lg text-label-lg flex items-center gap-2 hover:shadow-[0_0_20px_rgba(0,122,255,0.4)] transition-all hover:scale-105 active:scale-95">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
Watch S1 E4
</button>
<!-- Secondary Button -->
<button class="h-12 px-8 rounded-lg bg-surface/30 backdrop-blur-xl border border-white/10 text-on-surface font-label-lg text-label-lg flex items-center gap-2 hover:bg-white/10 transition-all active:scale-95">
<span class="material-symbols-outlined">add</span>
My List
</button>
</div>
</div>
</section>
<!-- Continue Watching Grid -->
<section class="px-md md:px-xl py-lg relative z-20 -mt-12">
<h2 class="font-headline-md text-headline-md text-on-surface mb-6 flex items-center gap-2">
<span class="material-symbols-outlined text-primary">history</span>
Continue Watching
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Card 1 -->
<div class="group relative rounded-xl overflow-hidden bg-surface-container border border-white/5 hover:border-white/20 transition-all duration-300 cursor-pointer shadow-lg">
<div class="aspect-video w-full relative">
<img alt="Stranger Things" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" data-alt="Retro 80s synthwave style neon lights in a dark room with a bicycle silhouette, mysterious sci-fi mood" src="https://lh3.googleusercontent.com/aida-public/AB6AXuB5pj6QM6rWAdzTeoo0zLsW7WKIM34Uu0OUh_KuGxsagRq5y7RzVlXJbQdYYF2vFCaIGBxt3Okd7FiT-wxFyDJG058rTAN-pMH1R_eJPqRID4Oqrk4m7f91ODnp-QPgZed5HQ1p2e_kfwINhh5E4NNpVsD8Iosr-wQYfvFzNLa2IAxDHhHR6rwBsBwcl3LdkUCh-E78isFCuUAjBmrTpyG45zMVvNfz4vlpTfJgGIoYuF8dl6bAegQ1QTK6d8AHZItypxrjiTaV7hI"/>
<div class="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent opacity-80"></div>
<!-- Play Overlay -->
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 backdrop-blur-sm">
<div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-on-primary shadow-[0_0_15px_rgba(173,198,255,0.5)]">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</div>
</div>
</div>
<div class="p-4 relative bg-surface/80 backdrop-blur-md">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Stranger Things</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1">S4:E8 • "Papa"</p>
<!-- Progress Bar -->
<div class="w-full h-1 bg-surface-variant rounded-full mt-3 overflow-hidden">
<div class="h-full bg-primary shadow-[0_0_8px_rgba(173,198,255,0.8)] w-[75%]"></div>
</div>
</div>
</div>
<!-- Card 2 -->
<div class="group relative rounded-xl overflow-hidden bg-surface-container border border-white/5 hover:border-white/20 transition-all duration-300 cursor-pointer shadow-lg">
<div class="aspect-video w-full relative">
<img alt="The Crown" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" data-alt="Elegant royal palace interior, dimly lit with ornate gold details and a solitary figure looking out a window" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDaLMl7_348o1rCV-iPOJn0UEHKzHnnz60qvDMQ4W7yUEmzdu4V7LUec_s_R_t7PcVIUPlkjRMSA8-BwNvhozmV5Y8tHpFvWKY6oSU8yPC03rHVSWtqyWEiIboxNPmqqSVTrrvzKZqqlP3K4LTNX8bBlKI-WMeeNOWnVFnTTyGz-Z6MTjriX_bewWRfI2H8z5LQun4tzvd90H78tAQC1rT6eaPmYPigHWWmEp2CEQIk6YyUiZGwH85Q8WLLHECf-30XGJD2oCJKrG0"/>
<div class="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent opacity-80"></div>
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40 backdrop-blur-sm">
<div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-on-primary shadow-[0_0_15px_rgba(173,198,255,0.5)]">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">play_arrow</span>
</div>
</div>
</div>
<div class="p-4 relative bg-surface/80 backdrop-blur-md">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">The Crown</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1">S5:E2 • "The System"</p>
<div class="w-full h-1 bg-surface-variant rounded-full mt-3 overflow-hidden">
<div class="h-full bg-primary shadow-[0_0_8px_rgba(173,198,255,0.8)] w-[30%]"></div>
</div>
</div>
</div>
</div>
</section>
<!-- Trending Series Grid -->
<section class="px-md md:px-xl py-lg">
<div class="flex items-center justify-between mb-6">
<h2 class="font-headline-md text-headline-md text-on-surface flex items-center gap-2">
<span class="material-symbols-outlined text-primary">local_fire_department</span>
Trending Now
</h2>
<button class="font-label-sm text-label-sm text-primary hover:text-primary-fixed transition-colors flex items-center gap-1">
View All <span class="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<!-- Poster 1 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3]">
<img alt="Reykjavik Fusion" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="Icy bleak landscape in Iceland, stark contrast, dark moody tones, isolated cabin in the snow" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAuXaoTwWSTHV_Cy0w5VPgZPGkjzMeJLLlSeqaHP-71xzmISlJlwLnmcjKzjO4MnU_AYBkQVl9vVvHqDHq2qiETmZ2ceVZxltzl2UadLkBqjHShoSHdfgwYdM_xA_R-eOodLSV5S5-loDjjHJfZyrTw4mFpA1mUvhVvx1NlKhNvDF0AX_7sPIQwTMA1o6SBpp9gFikVVepjVhoHEJZxS3th10PdPAtloeVR5Tl2GqsoQOGEmv9bx5tIO2dXihJCs_mRqKBfFHOIwTQ"/>
<!-- Rating Badge -->
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
9.2
</div>
<!-- Metadata Overlay -->
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Reykjavik Fusion</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Crime • 2 Seasons</p>
</div>
</div>
<!-- Poster 2 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3]">
<img alt="Neon Genesis" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="Cyberpunk city street at night, heavy rain, glowing pink and blue neon signs reflecting on wet pavement" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDG6oCPWDHZ4WJTQ1aXyii-WYsESpm9o1pT8J7LnJhlqQ9A2NPmJalFKTMlmsc3qvU0Swue_pbBE4HKmqQLJhoo97NQi98By6toE4Kq6vimNvp504CdQD0T3vybUzUcAxf6-B5qxi8dj4eBBKucNnQ23J0EBfyBLSrDg_fsBYVSDAGB6HrFJIoKzkLWcTPwuJn9DMa5uNJSs4yhs_QsAMxm1Pk9pxZZmiLhUVTvt6rAq_s4nX-NUABss95GadMWc8kVxV_2D24Ah-U"/>
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
8.8
</div>
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Neon Genesis</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Sci-Fi • 1 Season</p>
</div>
</div>
<!-- Poster 3 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3]">
<img alt="The Archive" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="Dusty, ancient library filled with glowing books, magical atmosphere, warm golden light rays piercing through small windows" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBMR_YyFPNgkyIHR-pKOvWTUvyf-HP-uzhcRa0TZ7MqKEFdIACiHXc_TTLZC_ZMYgDAw0mLZiFOa7ZbkyALrTFf-sQmeLY5hfwd_TYbXxyGxogB8jWi6F4FV05m4Y2F09p4XaRQMnXukXu2hGcp2fgdorhMh7MzntXHDY3mQyVYHEeiuIUrjlkonBrxjT1B-eMFhKOcHp8FncYgzB5GcFTq2NCaVlxiniHq6ZJmh93PhqXSa4e9-G5HHBbPtJhzYZpQpD3G_-4u2tQ"/>
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
9.5
</div>
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">The Archive</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Fantasy • 4 Seasons</p>
</div>
</div>
<!-- Poster 4 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3]">
<img alt="Apex Protocol" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="Sleek modern server room, cold blue lighting, sterile environment, rows of high-tech data racks" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCw2S19UFAInTxAS_xXzOHlAtZLiF4hMH4zbtK9kF0goTqcdAKzAr1uFfpNZmhSDJcly_7BvcfK7y2XuUCkhJI3R-EoERxVHuiRiJZN_lcxc2PFWQ73QsbdoiHoU5RWepbS-MLmft2T-vH_w6FRXZnok0-LO9XRySApsOawSTN8x6iQ-QLQAppLwc6JvCh62ICge0ROKI2P-3IiharZxs5AjGrZivO5yt3EqDV26QleiZ3lU9TtTqVWDLLJhFM34Jcw3Kv3lqtXnsk"/>
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
7.9
</div>
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Apex Protocol</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Action • 1 Season</p>
</div>
</div>
<!-- Poster 5 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3] hidden md:block">
<img alt="Whispers" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="Eerie dark forest trail covered in dense fog, moonlight barely piercing through tall pine trees, suspenseful mood" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAz4JFta70ndzciiYnKJA5Vr-aIJ_kXX-xWI4HCsrNQ_BUG5DKgS2AEHGrADq5MCaziw2p45G_vAwEnZSjsCSikPqgzkqjBKz3PWvigyPt2EhtAwK-orMWL_8AlyDz--3yusP5DCHtYH1gYurYPoSO7OopzNSEvYIRtD5DI157kJQIFoWujeQomFzBzFxMYOYtIO450dwAuCiBooGlQR0YK98VQg4uyav8Cc_U34zbRz3n4aZ3vXpw5ght_Iaqe2bop7gskJ_rUaBg"/>
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
8.4
</div>
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Whispers</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Horror • 3 Seasons</p>
</div>
</div>
<!-- Poster 6 -->
<div class="group relative rounded-lg overflow-hidden bg-surface border border-white/5 hover:border-white/40 transition-all duration-300 cursor-pointer shadow-lg hover:shadow-[0_0_30px_-5px_rgba(0,122,255,0.15)] aspect-[2/3] hidden xl:block">
<img alt="Stellar Drift" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" data-alt="View of Earth from space, bright glowing atmosphere, deep black starfield, cinematic majestic shot" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD9kroaEUwTmg6MfZT6TghSk3WRKsiNu4HpTj7_pOQbgmNKUyoITiRs9LpQvhGzWY2gbPV4YS52jo97vDEcfSesxEUGOIlzMy6w2xQyJIakCETs_8xNt4iFBUdz3gF4NhTbRI8S81mdCQ7QNbqND_OgsFWdTB5tlJxvDV63CGhRpOG6gFELjFfAUS-qwxCVs4TaUkAtHvMoRO314WIVnYa3-KURoWfnc1Dn1hWycv-IpEaf4PVW9Dn6ffJ6Ml2lvJobTkK4acgXfTU"/>
<div class="absolute top-2 right-2 px-2 py-1 bg-surface/80 backdrop-blur-md border border-white/10 rounded text-on-surface font-label-sm text-label-sm flex items-center gap-1">
<span class="material-symbols-outlined text-[14px] text-yellow-400" style="font-variation-settings: 'FILL' 1;">star</span>
9.0
</div>
<div class="absolute inset-x-0 bottom-0 p-4 bg-gradient-to-t from-background via-background/80 to-transparent translate-y-4 group-hover:translate-y-0 transition-transform duration-300 backdrop-blur-[2px]">
<h3 class="font-label-lg text-label-lg text-on-surface truncate">Stellar Drift</h3>
<p class="font-label-sm text-label-sm text-on-surface-variant mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-100">Sci-Fi • 5 Seasons</p>
</div>
</div>
</div>
</section>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html class="dark" lang="fr"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Paramètres - XtremFlow</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=Space+Grotesk:wght@500;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed": "#e2e2e2",
"inverse-surface": "#e3e2e7",
"on-secondary-fixed-variant": "#46464e",
"on-secondary": "#2f3037",
"inverse-on-surface": "#2f3035",
"primary": "#adc6ff",
"on-primary": "#002e69",
"primary-fixed-dim": "#adc6ff",
"on-primary-fixed-variant": "#004493",
"on-error-container": "#ffdad6",
"tertiary": "#c6c6c7",
"on-background": "#e3e2e7",
"on-surface": "#e3e2e7",
"surface": "#121317",
"outline-variant": "#414755",
"on-primary-container": "#00285c",
"error": "#ffb4ab",
"surface-container-low": "#1a1b20",
"outline": "#8b90a0",
"on-tertiary-container": "#282a2a",
"surface-tint": "#adc6ff",
"on-primary-fixed": "#001a41",
"on-tertiary-fixed": "#1a1c1c",
"inverse-primary": "#005bc1",
"background": "#121317",
"surface-bright": "#38393d",
"error-container": "#93000a",
"surface-dim": "#121317",
"surface-container-highest": "#343439",
"tertiary-fixed-dim": "#c6c6c7",
"secondary-container": "#4a4b53",
"primary-fixed": "#d8e2ff",
"on-surface-variant": "#c1c6d7",
"surface-container": "#1f1f24",
"secondary-fixed": "#e3e1eb",
"tertiary-container": "#909191",
"secondary-fixed-dim": "#c6c5cf",
"secondary": "#c6c5cf",
"surface-variant": "#343439",
"on-secondary-container": "#bcbbc4",
"on-tertiary": "#2f3131",
"on-error": "#690005",
"primary-container": "#4b8eff",
"on-tertiary-fixed-variant": "#454747",
"surface-container-lowest": "#0d0e12",
"on-secondary-fixed": "#1a1b22",
"surface-container-high": "#292a2e"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"md": "24px",
"sm": "12px",
"margin": "32px",
"gutter": "24px",
"base": "8px",
"xs": "4px",
"lg": "48px",
"xl": "80px"
},
"fontFamily": {
"body-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"label-lg": ["Inter"],
"headline-xl": ["Space Grotesk"],
"headline-md": ["Space Grotesk"],
"headline-lg": ["Space Grotesk"]
},
"fontSize": {
"body-md": ["16px", { "lineHeight": "1.5", "fontWeight": "400" }],
"label-sm": ["12px", { "lineHeight": "1.2", "letterSpacing": "0.02em", "fontWeight": "500" }],
"body-lg": ["18px", { "lineHeight": "1.6", "fontWeight": "400" }],
"label-lg": ["14px", { "lineHeight": "1.2", "letterSpacing": "0.05em", "fontWeight": "600" }],
"headline-xl": ["48px", { "lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"headline-md": ["24px", { "lineHeight": "1.3", "fontWeight": "500" }],
"headline-lg": ["32px", { "lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600" }]
}
}
}
}
</script>
<style>
body {
background-color: #0F1014; /* Base Level 0 */
color: #e3e2e7;
}
.glass-panel {
background: rgba(24, 25, 32, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-floating {
background: rgba(24, 25, 32, 0.6);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 1px 1px 0px 0px rgba(173, 198, 255, 0.2), 0 10px 30px -10px rgba(0, 0, 0, 0.5);
}
.neon-glow {
box-shadow: 0 0 15px rgba(0, 122, 255, 0.5);
}
.input-dark {
background-color: #181920;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e3e2e7;
}
.input-dark:focus {
border-color: #007AFF;
box-shadow: 0 0 10px rgba(0, 122, 255, 0.3);
outline: none;
}
.chip {
background-color: rgba(173, 198, 255, 0.1);
color: #ffffff;
border: 1px solid rgba(173, 198, 255, 0.3);
}
</style>
</head>
<body class="font-body-md text-body-md antialiased min-h-screen flex selection:bg-primary selection:text-on-primary">
<!-- SideNavBar Component -->
<nav class="hidden md:flex flex-col h-full py-8 h-screen fixed left-0 top-0 w-64 border-r border-white/10 shadow-[10px_0_30px_-15px_rgba(0,122,255,0.2)] bg-[#0F1014]/80 backdrop-blur-2xl z-40">
<div class="px-6 mb-8 flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-cyan-400 p-0.5">
<img alt="User Profile" class="w-full h-full rounded-full object-cover" data-alt="Close-up portrait of a young woman with natural lighting, subtle background blur, high-quality professional photography" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCe_G8mDdSMWIlLiW3aBKMRw0ljyLQN8lQuS0_yMFEhjzDIKoe33IJaWfOIOBts14PZb6rXQDhow4BGwoTgoTe6uFLWYfDtjuwow1CAY5UM6O9ZqLYVIbRMx2MmMqMVY6A0WRKOfIEkskYWuoU9dAWtQvIJvov34E6i5pCqhZBjcNL5bGjpsdotbd4skKJtxgUz8XhVkS-3hhZI9ChoZUOskixxOsI4mZgKbBjshcAZAeoAYoNBN1rwl-YzXm75MwvUKoQaDCDRHUg"/>
</div>
<div>
<h2 class="font-headline-md text-headline-md text-blue-500 font-bold uppercase tracking-widest text-sm">Command Center</h2>
<p class="font-label-sm text-label-sm text-slate-400">Premium Tier</p>
</div>
</div>
<div class="flex-1 overflow-y-auto space-y-1 mt-4">
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">live_tv</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Live TV</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">movie</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Movies</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">theaters</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Series</span>
</a>
<a class="text-slate-500 flex items-center gap-4 px-6 py-4 hover:bg-white/5 hover:text-white transition-colors group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">fiber_manual_record</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Recordings</span>
</a>
<a class="bg-blue-500/10 text-blue-400 border-l-4 border-blue-500 flex items-center gap-4 px-6 py-4 group" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200" style="font-variation-settings: 'FILL' 1;">settings</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Settings</span>
</a>
</div>
<div class="mt-auto px-6 space-y-1">
<a class="text-slate-500 flex items-center gap-4 py-3 hover:bg-white/5 hover:text-white transition-colors group rounded-lg px-4 -mx-4" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">help</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Support</span>
</a>
<a class="text-slate-500 flex items-center gap-4 py-3 hover:bg-white/5 hover:text-white transition-colors group rounded-lg px-4 -mx-4" href="#">
<span class="material-symbols-outlined group-hover:translate-x-1 transition-transform duration-200">logout</span>
<span class="font-['Space_Grotesk'] text-sm font-medium">Logout</span>
</a>
</div>
</nav>
<!-- Main Content Area -->
<main class="flex-1 md:ml-64 p-margin lg:p-lg min-h-screen flex flex-col gap-lg">
<!-- Header & Tabs -->
<header class="flex flex-col gap-md">
<div>
<h1 class="font-headline-xl text-headline-xl text-on-surface mb-xs">Paramètres</h1>
<p class="font-body-lg text-body-lg text-on-surface-variant">Configuration de votre expérience XtremFlow.</p>
</div>
<div class="glass-panel rounded-lg p-1 flex gap-2 w-fit">
<button class="bg-primary/20 text-primary font-label-lg text-label-lg px-6 py-2.5 rounded-md border border-primary/30 shadow-[0_0_15px_rgba(0,122,255,0.2)] transition-all">Filtres</button>
<button class="text-on-surface-variant hover:text-on-surface hover:bg-white/5 font-label-lg text-label-lg px-6 py-2.5 rounded-md transition-all">Streaming</button>
<button class="text-on-surface-variant hover:text-on-surface hover:bg-white/5 font-label-lg text-label-lg px-6 py-2.5 rounded-md transition-all">Apparence</button>
<button class="text-on-surface-variant hover:text-on-surface hover:bg-white/5 font-label-lg text-label-lg px-6 py-2.5 rounded-md transition-all">Administration</button>
</div>
</header>
<!-- Content Grid -->
<div class="grid grid-cols-1 xl:grid-cols-3 gap-gutter flex-1">
<!-- Filtre TV Live -->
<section class="glass-floating rounded-xl p-md flex flex-col gap-md">
<div class="flex items-center gap-3 pb-sm border-b border-white/10">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
<span class="material-symbols-outlined text-primary text-xl">live_tv</span>
</div>
<h2 class="font-headline-lg text-headline-md text-on-surface">Filtre TV Live</h2>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-2 uppercase tracking-wider">Rechercher une catégorie</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline">search</span>
<input class="w-full input-dark rounded-md py-2.5 pl-10 pr-4 font-body-md text-body-md transition-all" placeholder="Ex: France, Sport, Info..." type="text"/>
</div>
</div>
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-3 uppercase tracking-wider">Catégories Actives</label>
<div class="flex flex-wrap gap-2">
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">FRANCE</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">SPORT</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">DOCUMENTAIRE</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">MUSIQUE</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
</div>
</div>
</div>
</section>
<!-- Filtre Films -->
<section class="glass-floating rounded-xl p-md flex flex-col gap-md">
<div class="flex items-center gap-3 pb-sm border-b border-white/10">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
<span class="material-symbols-outlined text-primary text-xl">movie</span>
</div>
<h2 class="font-headline-lg text-headline-md text-on-surface">Filtre Films</h2>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-2 uppercase tracking-wider">Rechercher une catégorie</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline">search</span>
<input class="w-full input-dark rounded-md py-2.5 pl-10 pr-4 font-body-md text-body-md transition-all" placeholder="Ex: Action, VF, 4K..." type="text"/>
</div>
</div>
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-3 uppercase tracking-wider">Catégories Actives</label>
<div class="flex flex-wrap gap-2">
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">VF</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">ACTION</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">SCI-FI</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
</div>
</div>
</div>
</section>
<!-- Filtre Séries -->
<section class="glass-floating rounded-xl p-md flex flex-col gap-md">
<div class="flex items-center gap-3 pb-sm border-b border-white/10">
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
<span class="material-symbols-outlined text-primary text-xl">theaters</span>
</div>
<h2 class="font-headline-lg text-headline-md text-on-surface">Filtre Séries</h2>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-2 uppercase tracking-wider">Rechercher une catégorie</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline">search</span>
<input class="w-full input-dark rounded-md py-2.5 pl-10 pr-4 font-body-md text-body-md transition-all" placeholder="Ex: Netflix, VOSTFR, Drame..." type="text"/>
</div>
</div>
<div>
<label class="font-label-sm text-label-sm text-on-surface-variant block mb-3 uppercase tracking-wider">Catégories Actives</label>
<div class="flex flex-wrap gap-2">
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">NETFLIX</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">VOSTFR</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">DRAME</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
<div class="chip rounded-full px-4 py-1.5 flex items-center gap-2 cursor-pointer hover:bg-primary/20 transition-colors">
<span class="font-label-sm text-label-sm">HBO</span>
<span class="material-symbols-outlined text-[14px]">close</span>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- Action Buttons -->
<div class="flex justify-end gap-4 mt-auto pt-8 border-t border-white/10">
<button class="px-6 py-2.5 rounded-md border border-outline-variant text-on-surface hover:bg-surface-variant transition-colors font-label-lg text-label-lg glass-panel">Annuler</button>
<button class="bg-gradient-to-r from-[#007AFF] to-[#00A8FF] text-white px-8 py-2.5 rounded-md font-label-lg text-label-lg hover:shadow-[0_0_20px_rgba(0,122,255,0.4)] transition-all">Sauvegarder les filtres</button>
</div>
</main>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+12 -4
View File
@@ -23,7 +23,6 @@ class AuthHandler {
try { try {
print('[Auth] Login attempt received'); print('[Auth] Login attempt received');
final bodyStr = await request.readAsString(); final bodyStr = await request.readAsString();
print('[Auth] Request body: $bodyStr');
final payload = jsonDecode(bodyStr) as Map<String, dynamic>; final payload = jsonDecode(bodyStr) as Map<String, dynamic>;
final username = payload['username'] as String?; final username = payload['username'] as String?;
@@ -51,13 +50,19 @@ class AuthHandler {
print('[Auth] User verified, creating session for userId: ${user.id}'); print('[Auth] User verified, creating session for userId: ${user.id}');
// Create session // Create session
final session = db.createSession(user.id); final session = db.createSession(user.id);
print('[Auth] Session created with token: ${session.token.substring(0, 8)}...');
// HttpOnly session cookie so same-origin media requests (hls.js inside
// the player iframe cannot send Authorization headers) are authenticated.
final maxAge = session.expiresAt.difference(DateTime.now()).inSeconds;
return Response.ok(jsonEncode({ return Response.ok(jsonEncode({
'success': true, 'success': true,
'user': user.toJson(), 'user': user.toJson(),
'token': session.token, 'token': session.token,
}), headers: {'Content-Type': 'application/json'},); }), headers: {
'Content-Type': 'application/json',
'Set-Cookie':
'session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=$maxAge',
},);
} catch (e, stackTrace) { } catch (e, stackTrace) {
print('[Auth] ERROR during login: $e'); print('[Auth] ERROR during login: $e');
print('[Auth] Stack trace: $stackTrace'); print('[Auth] Stack trace: $stackTrace');
@@ -83,7 +88,10 @@ class AuthHandler {
return Response.ok(jsonEncode({ return Response.ok(jsonEncode({
'success': true, 'success': true,
}), headers: {'Content-Type': 'application/json'},); }), headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0',
},);
} catch (e) { } catch (e) {
return Response.internalServerError( return Response.internalServerError(
body: jsonEncode({'success': false, 'error': e.toString()}), body: jsonEncode({'success': false, 'error': e.toString()}),
+1 -3
View File
@@ -1,19 +1,17 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../database/database.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
/// API EPG — proxy vers Xtream avec cache 30 minutes /// API EPG — proxy vers Xtream avec cache 30 minutes
/// GET /api/epg/<channel_id>?days=1 /// GET /api/epg/<channel_id>?days=1
class EpgApi { class EpgApi {
final AppDatabase _db;
final Future<PlaylistConfig?> Function(Request) _getPlaylist; final Future<PlaylistConfig?> Function(Request) _getPlaylist;
// Cache simple en mémoire : channelId → {data, expiresAt} // Cache simple en mémoire : channelId → {data, expiresAt}
final Map<String, _CacheEntry> _cache = {}; final Map<String, _CacheEntry> _cache = {};
EpgApi(this._db, this._getPlaylist); EpgApi(this._getPlaylist);
Future<Response> handleGetEpg(Request request, String channelId) async { Future<Response> handleGetEpg(Request request, String channelId) async {
// Vérifier le cache // Vérifier le cache
+6 -1
View File
@@ -120,7 +120,12 @@ class PlaylistsHandler {
final name = payload['name'] as String? ?? existing.name; final name = payload['name'] as String? ?? existing.name;
final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl; final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl;
final username = payload['username'] as String? ?? existing.username; final username = payload['username'] as String? ?? existing.username;
final password = payload['password'] as String? ?? existing.password; // Empty password means "keep current": clients never receive the real
// password, so edit forms send it back blank.
final incomingPassword = payload['password'] as String?;
final password = (incomingPassword == null || incomingPassword.isEmpty)
? existing.password
: incomingPassword;
final dns = payload['dns'] as String? ?? existing.dns; final dns = payload['dns'] as String? ?? existing.dns;
final playlist = db.updatePlaylist( final playlist = db.updatePlaylist(
+37 -27
View File
@@ -1,14 +1,36 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
import '../database/database.dart'; import '../utils/log_redactor.dart';
/// Returns true when [host] must never be proxied (loopback, private LAN,
/// link-local/cloud-metadata ranges) — SSRF protection for asset URLs that
/// bypass the playlist-domain allowlist.
bool isForbiddenProxyHost(String host) {
final lower = host.toLowerCase();
if (lower == 'localhost' || lower == '::1') return true;
final ip = InternetAddress.tryParse(lower);
if (ip == null) return false; // Hostname: validated by domain allowlist path
if (ip.isLoopback || ip.isLinkLocal) return true;
if (ip.type == InternetAddressType.IPv4) {
final parts = ip.address.split('.').map(int.parse).toList();
if (parts[0] == 10) return true; // 10.0.0.0/8
if (parts[0] == 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] == 192 && parts[1] == 168) return true; // 192.168.0.0/16
if (parts[0] == 169 && parts[1] == 254) return true; // metadata/link-local
if (parts[0] == 0) return true;
}
return false;
}
/// Handler for the Xtream Proxy /// Handler for the Xtream Proxy
class ProxyHandler { class ProxyHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist; final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final AppDatabase _db;
final http.Client _client = http.Client(); final http.Client _client = http.Client();
final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {}; final Map<String, (PlaylistConfig, DateTime)> _playlistCache = {};
@@ -48,30 +70,7 @@ class ProxyHandler {
return playlist; return playlist;
} }
ProxyHandler(this._getPlaylist, this._db); ProxyHandler(this._getPlaylist);
/// Extract token from Authorization header or cookie
String? _extractToken(Request request) {
// Try Authorization header first
final authHeader = request.headers['authorization'];
if (authHeader != null && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Try cookie
final cookie = request.headers['cookie'];
if (cookie != null) {
final parts = cookie.split(';');
for (final part in parts) {
final trimmed = part.trim();
if (trimmed.startsWith('session=')) {
return trimmed.substring(8);
}
}
}
return null;
}
/// Create Xtream proxy handler with M3U8 URL rewriting support /// Create Xtream proxy handler with M3U8 URL rewriting support
Handler get handler { Handler get handler {
@@ -118,6 +117,17 @@ class ProxyHandler {
targetUrl = Uri.parse(fullUrl); targetUrl = Uri.parse(fullUrl);
// Only plain http(s) may be proxied
if (targetUrl.scheme != 'http' && targetUrl.scheme != 'https') {
return Response.forbidden('Unsupported URL scheme');
}
// Never proxy to loopback/private/link-local targets (SSRF)
if (isForbiddenProxyHost(targetUrl.host)) {
print('[Proxy] Blocked SSRF attempt to private host: ${targetUrl.host}');
return Response.forbidden('Access to this host is forbidden');
}
// SSRF Protection - but allow images/static assets from any host // SSRF Protection - but allow images/static assets from any host
// Xtream providers often use separate CDN servers for picons/images // Xtream providers often use separate CDN servers for picons/images
final isStaticAsset = targetUrl.path.endsWith('.png') || final isStaticAsset = targetUrl.path.endsWith('.png') ||
@@ -164,7 +174,7 @@ class ProxyHandler {
} }
try { try {
print('[Proxy] Forwarding to: $targetUrl'); print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}');
final proxyRequest = http.Request(request.method, targetUrl); final proxyRequest = http.Request(request.method, targetUrl);
// Forward safe request headers // Forward safe request headers
+26 -7
View File
@@ -1,9 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'package:path/path.dart' as p;
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database.dart'; import '../database/database.dart';
import '../models/user.dart';
import '../services/recording_scheduler.dart'; import '../services/recording_scheduler.dart';
import '../utils/safe_path.dart';
class RecordingsApi { class RecordingsApi {
final AppDatabase _db; final AppDatabase _db;
@@ -30,12 +32,24 @@ class RecordingsApi {
); );
} }
final logFilePath = recording.filePath!.replaceAll('.mp4', '.log'); // Les enregistrements sont écrits en .mkv avec un .log à côté
final logFile = File(logFilePath); // (l'ancien replaceAll('.mp4', '.log') ne trouvait jamais le fichier).
final logFilePath = p.setExtension(recording.filePath!, '.log');
// Anti path-traversal : le log doit rester dans /app/recordings
final safeLogPath = SafePath.resolveWithin('/app/recordings', logFilePath);
if (safeLogPath == null) {
return Response.forbidden(
json.encode({'error': 'Chemin de log invalide'}),
headers: {'Content-Type': 'application/json'},
);
}
final logFile = File(safeLogPath);
if (!await logFile.exists()) { if (!await logFile.exists()) {
return Response.notFound( return Response.notFound(
json.encode({'error': 'Le fichier de log est introuvable. Chemin: $logFilePath'}), json.encode({'error': 'Le fichier de log est introuvable.'}),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );
} }
@@ -47,9 +61,13 @@ class RecordingsApi {
); );
} }
/// GET /api/recordings — Liste tous les enregistrements /// GET /api/recordings — Liste les enregistrements de l'utilisateur
/// (tous les enregistrements pour un admin)
Response handleGetAll(Request request) { Response handleGetAll(Request request) {
final recordings = _db.getAllRecordings(); final user = request.context['user'] as User?;
final recordings = (user != null && !user.isAdmin)
? _db.getUserRecordings(user.id)
: _db.getAllRecordings();
return Response.ok( return Response.ok(
json.encode(recordings.map((r) => r.toMap()).toList()), json.encode(recordings.map((r) => r.toMap()).toList()),
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@@ -62,8 +80,9 @@ class RecordingsApi {
final payload = await request.readAsString(); final payload = await request.readAsString();
final data = json.decode(payload); final data = json.decode(payload);
final userId = request.context['userId'] as String? ?? 'dev_user_id';
final recording = _db.createRecording( final recording = _db.createRecording(
userId: 'dev_user_id', userId: userId,
channelId: data['channel_id'], channelId: data['channel_id'],
streamUrl: data['stream_url'], streamUrl: data['stream_url'],
title: data['title'] ?? 'Sans Titre', title: data['title'] ?? 'Sans Titre',
+320 -494
View File
@@ -1,20 +1,19 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../database/database.dart'; import '../database/database.dart';
import '../models/playlist_config.dart'; import '../models/playlist_config.dart';
import '../services/ffmpeg_session_manager.dart';
/// Active FFmpeg processes for VOD transcoding import '../utils/log_redactor.dart';
final Map<String, Process> _vodProcesses = {};
/// Directory for temporary HLS segments /// Directory for temporary HLS segments
final Directory _hlsTempDir = final Directory _hlsTempDir =
Directory('${Directory.systemTemp.path}/xtremflow_streams'); Directory('${Directory.systemTemp.path}/xtremflow_streams');
/// Global FFmpeg session registry (initialized in [initStreaming]).
final FfmpegSessionManager sessionManager = FfmpegSessionManager(_hlsTempDir);
/// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable) /// Helper to resolve FFmpeg path (SYSTEM PATH vs Portable)
String _getFFmpegPath() { String _getFFmpegPath() {
if (Platform.isWindows) { if (Platform.isWindows) {
@@ -35,201 +34,239 @@ bool _isNvidiaGpuEnabled() {
return envValue.toLowerCase() == 'true' || envValue == '1'; return envValue.toLowerCase() == 'true' || envValue == '1';
} }
/// Log GPU status on first use
bool _gpuStatusLogged = false;
void _logGpuStatus() {
if (!_gpuStatusLogged) {
if (_isNvidiaGpuEnabled()) {
print('[FFmpeg] NVIDIA GPU acceleration ENABLED (NVDEC/NVENC)');
} else {
print(
'[FFmpeg] Using CPU processing (set NVIDIA_GPU=true to enable GPU)',
);
}
_gpuStatusLogged = true;
}
}
/// Initialize streaming subsystem /// Initialize streaming subsystem
Future<void> initStreaming() async { Future<void> initStreaming() async {
if (!_hlsTempDir.existsSync()) { await sessionManager.init();
await _hlsTempDir.create(recursive: true); }
/// Supported quality presets. `source` skips video transcoding entirely
/// (`-c:v copy`) — a huge CPU win since most Xtream streams are already
/// H.264. Audio is always normalized to AAC for HLS compatibility.
const supportedQualities = {'source', 'high', 'medium', 'low'};
String _sanitizeQuality(String? quality) {
return supportedQualities.contains(quality) ? quality! : 'high';
}
bool _isValidStreamId(String id) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(id);
/// Video encoding args for the requested quality (live profile).
List<String> _liveVideoArgs(String quality, bool gpu) {
switch (quality) {
case 'source':
return ['-c:v', 'copy'];
case 'medium':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k',
'-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p',
'-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '3000k', '-maxrate', '4500k', '-bufsize', '6000k',
'-pix_fmt', 'yuv420p', '-g', '50',
];
case 'low':
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4',
'-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-b:v', '1500k', '-maxrate', '2000k', '-bufsize', '3000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '50',
];
case 'high':
default:
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k',
'-profile:v', 'high', '-level', '4.0', '-pix_fmt', 'yuv420p',
'-g', '50',
]
: [
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-pix_fmt', 'yuv420p', '-g', '50',
];
} }
} }
/// Helper to resolve HTTP redirects and get final URL /// Video encoding args for the requested quality (VOD profile).
/// Some IPTV servers return 302 redirects that FFmpeg doesn't follow properly List<String> _vodVideoArgs(String quality, bool gpu) {
Future<String> _resolveRedirects(String url, {int maxRedirects = 5}) async { switch (quality) {
String currentUrl = url; case 'source':
final client = http.Client(); return ['-c:v', 'copy'];
case 'medium':
try { return gpu
for (int i = 0; i < maxRedirects; i++) { ? [
final request = http.Request('GET', Uri.parse(currentUrl)); '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
request.headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'; '-rc', 'cbr', '-b:v', '2500k', '-maxrate', '3000k',
request.followRedirects = false; '-bufsize', '5000k', '-g', '48', '-bf', '2',
'-pix_fmt', 'yuv420p',
final response = await client.send(request).timeout( ]
const Duration(seconds: 30), : [
onTimeout: () => '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
throw TimeoutException('Redirect resolution timeout'), '-maxrate', '6000k', '-bufsize', '12000k',
); '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
// Check if it's a redirect (301, 302, 307, 308) case 'low':
if (response.statusCode >= 300 && response.statusCode < 400) { return gpu
final location = response.headers['location']; ? [
if (location != null && location.isNotEmpty) { '-c:v', 'h264_nvenc', '-preset', 'p4',
// Handle relative URLs '-rc', 'cbr', '-b:v', '1500k', '-maxrate', '2000k',
if (location.startsWith('/')) { '-bufsize', '3000k', '-vf', 'scale=-2:720',
final uri = Uri.parse(currentUrl); '-g', '48', '-pix_fmt', 'yuv420p',
currentUrl = '${uri.scheme}://${uri.host}:${uri.port}$location'; ]
} else { : [
currentUrl = location; '-c:v', 'libx264', '-preset', 'fast', '-crf', '26',
'-maxrate', '3000k', '-bufsize', '6000k',
'-vf', 'scale=-2:720',
'-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
case 'high':
default:
return gpu
? [
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-rc', 'cbr', '-b:v', '4000k', '-maxrate', '4500k',
'-bufsize', '8000k', '-g', '48', '-bf', '2',
'-pix_fmt', 'yuv420p',
]
: [
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-maxrate', '12000k', '-bufsize', '24000k',
'-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0',
];
} }
print('[Redirect] $i: $url -> $currentUrl');
await response.stream.drain();
continue;
}
}
// Not a redirect - we're done
await response.stream.drain();
break;
}
} catch (e) {
print('[Redirect] Error resolving redirects: $e');
// Return original URL if redirect resolution fails
} finally {
client.close();
}
return currentUrl;
} }
/// Helper to get current playlist configuration (mocked for now, implies single user) /// Audio args. Source mode keeps it simple (plain AAC); transcoded modes
/// In a real scenario, this should come from the request session/context /// keep the downmix + loudness normalization filter.
PlaylistConfig? _getCurrentPlaylist(Request request) { List<String> _audioArgs({required bool withFilters}) {
// TODO: Retrieve from DB/Session based on Auth return [
// For now, we assume the frontend sends enough info or we look up the active one '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
return null; // Implemented later in server.dart injection if (withFilters)
...['-af',
'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15']
else
...['-af', 'aresample=async=1'],
];
} }
Map<String, String> _hlsHeaders() => {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
};
Map<String, String> _segmentHeaders({int maxAge = 60}) => {
'Content-Type': 'video/mp2t',
'Cache-Control': 'max-age=$maxAge',
};
// ========================================== // ==========================================
// 1. LIVE TV HANDLER (Direct MPEG-TS Proxy) // 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy)
// ========================================== // ==========================================
/// Active FFmpeg processes for Live HLS transcoding
final Map<String, Process> _liveProcesses = {};
Handler createLiveStreamHandler( Handler createLiveStreamHandler(
Future<PlaylistConfig?> Function(Request) getPlaylist, { Future<PlaylistConfig?> Function(Request) getPlaylist, {
bool Function()? isGpuEnabled, bool Function()? isGpuEnabled,
}) { }) {
final router = Router(); final router = Router();
// Route: /api/live/{streamId}/playlist.m3u8 (Master playlist) Future<Response> servePlaylist(
router.get('/<streamId>/playlist.m3u8', Request request,
(Request request, String streamId) async { String streamId,
String quality,
) async {
if (!_isValidStreamId(streamId)) {
return Response.badRequest(body: 'Invalid stream ID');
}
final playlist = await getPlaylist(request); final playlist = await getPlaylist(request);
if (playlist == null) return Response.forbidden('No playlist'); if (playlist == null) return Response.forbidden('No playlist');
final streamDir = Directory('${_hlsTempDir.path}/live_$streamId');
// Start FFmpeg if not already running for this live stream
if (!_liveProcesses.containsKey(streamId)) {
if (streamDir.existsSync()) streamDir.deleteSync(recursive: true);
streamDir.createSync(recursive: true);
final targetUrl = final targetUrl =
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
print('[Live HLS] Starting for $streamId: $targetUrl');
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final ffmpegArgs = <String>[ final sessionId = 'live_${streamId}_$quality';
if (!sessionManager.contains(sessionId)) {
print(
'[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}',
);
}
final session = await sessionManager.getOrStart(
id: sessionId,
isLive: true,
ffmpegPath: _getFFmpegPath(),
argsBuilder: (dir) => [
'-hide_banner', '-loglevel', 'warning', '-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu) ...['-hwaccel', 'cuda'], if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
'-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
'-reconnect', '1', '-reconnect_streamed', '1', '-reconnect', '1', '-reconnect_streamed', '1',
'-reconnect_delay_max', '10', '-reconnect_delay_max', '10',
'-i', targetUrl, '-i', targetUrl,
// Video ..._liveVideoArgs(quality, useNvidiaGpu),
if (useNvidiaGpu) ...[ ..._audioArgs(withFilters: false),
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', // HLS sliding window: 10 x 2s segments (lower live latency than the
'hq', // previous 20-segment window while still safe for iOS)
'-b:v', '8000k', '-maxrate', '12000k', '-bufsize', '16000k',
'-profile:v', 'high', '-level', '4.0',
'-pix_fmt', 'yuv420p',
'-g', '50',
] else ...[
'-c:v', 'libx264', '-preset', 'medium', '-tune', 'zerolatency',
'-profile:v', 'high', '-level', '4.0',
'-b:v', '6000k', '-maxrate', '8000k', '-bufsize', '12000k',
'-pix_fmt', 'yuv420p',
'-g', '50',
],
// Audio: High quality + Sync filter
'-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000',
'-af', 'aresample=async=1',
// HLS Sliding Window — larger window so iOS never requests a deleted segment
'-f', 'hls', '-f', 'hls',
'-hls_time', '2', '-hls_time', '2',
'-hls_list_size', '20', '-hls_list_size', '10',
'-hls_flags', 'delete_segments+independent_segments', '-hls_flags', 'delete_segments+independent_segments',
'-hls_segment_type', 'mpegts', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', 'seg_%03d.ts', '-hls_segment_filename', 'seg_%03d.ts',
'playlist.m3u8', 'playlist.m3u8',
]; ],
final process = await Process.start(
_getFFmpegPath(),
ffmpegArgs,
workingDirectory: streamDir.path,
); );
_liveProcesses[streamId] = process;
process.stderr final result = await sessionManager.waitForPlaylist(session);
.transform(utf8.decoder) if (!result.ready) {
.listen((d) => print('[Live HLS $streamId] $d')); sessionManager.killSession(sessionId);
process.exitCode.then((_) => _liveProcesses.remove(streamId)); return Response(502, body: 'Live transcoder failed: ${result.error}');
}
// Wait for playlist AND at least one segment reference
final file = File('${streamDir.path}/playlist.m3u8');
int retries = 0;
while (retries < 60) {
if (file.existsSync()) {
final content = file.readAsStringSync();
// Live HLS needs at least 2 or 3 segments to be stable on iOS
// but we start as soon as we have one for speed
if (content.contains('.ts')) break;
}
await Future.delayed(const Duration(milliseconds: 500));
retries++;
}
if (!file.existsSync()) {
return Response.internalServerError(body: 'Timeout starting live HLS');
} }
session.touch();
return Response.ok( return Response.ok(
file.openRead(), File('${session.dir.path}/playlist.m3u8').openRead(),
headers: { headers: _hlsHeaders(),
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
'X-Content-Type-Options': 'nosniff',
},
); );
}
// Route: /api/live/{streamId}/{quality}/playlist.m3u8
router.get('/<streamId>/<quality>/playlist.m3u8',
(Request request, String streamId, String quality) {
return servePlaylist(request, streamId, _sanitizeQuality(quality));
}); });
// Route: /api/live/{streamId}.ts (Direct Proxy for internal recordings or raw playback) // Back-compat route: /api/live/{streamId}/playlist.m3u8 (?quality=...)
// Redirects so relative segment URLs resolve inside the quality path.
router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final quality =
_sanitizeQuality(request.url.queryParameters['quality']);
return Response.found('/api/live/$streamId/$quality/playlist.m3u8');
});
// Route: /api/live/{streamId}.ts (Direct Proxy for recordings/raw playback)
router.get('/<streamId>.ts', (Request request, String streamId) async { router.get('/<streamId>.ts', (Request request, String streamId) async {
final playlist = await getPlaylist(request); final playlist = await getPlaylist(request);
if (playlist == null) return Response.forbidden('No playlist'); if (playlist == null) return Response.forbidden('No playlist');
final targetUrl = final targetUrl =
'${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts';
print('[Live Proxy] Forwarding $streamId: $targetUrl'); print('[Live Proxy] Forwarding $streamId: ${LogRedactor.redactUrl(targetUrl)}');
final client = http.Client(); final client = http.Client();
final proxyRequest = http.Request('GET', Uri.parse(targetUrl)); final proxyRequest = http.Request('GET', Uri.parse(targetUrl));
@@ -243,313 +280,143 @@ Handler createLiveStreamHandler(
body: response.stream, body: response.stream,
headers: { headers: {
'Content-Type': 'video/mp2t', 'Content-Type': 'video/mp2t',
'Access-Control-Allow-Origin': '*',
'Connection': 'keep-alive', 'Connection': 'keep-alive',
}, },
); );
}); });
// Route: /api/live/{streamId}/{segment} // Route: /api/live/{streamId}/{quality}/{segment}
router.get('/<streamId>/<segment>', router.get('/<streamId>/<quality>/<segment>',
(Request request, String streamId, String segment) async { (Request request, String streamId, String quality, String segment) {
final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); if (!_isValidStreamId(streamId) || segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'live_${streamId}_${_sanitizeQuality(quality)}';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found'); if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok( return Response.ok(file.openRead(), headers: _segmentHeaders());
file.openRead(),
headers: {
'Content-Type': 'video/mp2t',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=60',
},
);
}); });
return router; return router.call;
} }
// ========================================== // ==========================================
// 2. VOD HANDLER (FFmpeg HLS Transcoding) // 2. VOD HANDLER (FFmpeg HLS Transcoding)
// ========================================== // ==========================================
// ==========================================
// 2. VOD HANDLER (FFmpeg HLS Transcoding)
// ==========================================
final _lastLogTime = <String, int>{};
Handler createVodStreamHandler( Handler createVodStreamHandler(
Future<PlaylistConfig?> Function(Request) getPlaylist, { Future<PlaylistConfig?> Function(Request) getPlaylist, {
bool Function()? isGpuEnabled, bool Function()? isGpuEnabled,
}) { }) {
final router = Router(); final router = Router();
// Route: /api/vod/{streamId}/playlist.m3u8 Future<Response> servePlaylist(
router.get('/<streamId>/playlist.m3u8', Request request,
(Request request, String streamId) async { String streamId,
String quality,
) async {
if (!_isValidStreamId(streamId)) {
return Response.badRequest(body: 'Invalid stream ID');
}
final playlist = await getPlaylist(request); final playlist = await getPlaylist(request);
if (playlist == null) { if (playlist == null) {
return Response.internalServerError(body: 'No playlist'); return Response.internalServerError(body: 'No playlist');
} }
// Validate streamId to prevent Path Traversal // ?type=series or ?type=movie selects the upstream path
if (!RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(streamId)) {
return Response.badRequest(body: 'Invalid stream ID');
}
final streamDir = Directory('${_hlsTempDir.path}/$streamId');
// Check if existing session is healthy
if (_vodProcesses.containsKey(streamId)) {
final existingProcess = _vodProcesses[streamId]!;
// Check if process is still running by checking if playlist exists and has content
final playlistFile = File('${streamDir.path}/playlist.m3u8');
if (playlistFile.existsSync()) {
final content = playlistFile.readAsStringSync();
// Check if playlist has segments (healthy transcoding)
if (content.contains('.ts')) {
// Only log once per second to avoid spam
final now = DateTime.now().millisecondsSinceEpoch;
if (!_lastLogTime.containsKey(streamId) ||
(now - _lastLogTime[streamId]! > 2000)) {
print('[VOD] Reusing existing session for $streamId');
_lastLogTime[streamId] = now;
}
// Serve existing playlist
return Response.ok(
playlistFile.openRead(),
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
},
);
}
}
// Playlist missing or empty - process likely failed, clean up
print('[VOD] Cleaning up stale session for $streamId');
try {
existingProcess.kill();
} catch (e) {
// Process may already be dead
}
_vodProcesses.remove(streamId);
// Clean up directory
if (streamDir.existsSync()) {
try {
streamDir.deleteSync(recursive: true);
} catch (e) {
print('[VOD] Failed to clean directory: $e');
}
}
}
// Build Upstream URL based on content type
// Check for type parameter in request URL (?type=series or ?type=movie)
final contentType = request.url.queryParameters['type'] ?? 'movie'; final contentType = request.url.queryParameters['type'] ?? 'movie';
final basePath = contentType == 'series' ? 'series' : 'movie'; final basePath = contentType == 'series' ? 'series' : 'movie';
// Only start FFmpeg if not already running
if (!_vodProcesses.containsKey(streamId)) {
// Prepare directory (Only for new session)
if (streamDir.existsSync()) {
streamDir.deleteSync(recursive: true);
}
streamDir.createSync(recursive: true);
final targetUrl = final targetUrl =
'${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv';
print('[VOD] Starting Transcode ($contentType): $targetUrl');
// Check if NVIDIA GPU is enabled (from database setting or env var fallback)
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final sessionId = 'vod_${streamId}_$quality';
if (useNvidiaGpu) { if (!sessionManager.contains(sessionId)) {
print('[VOD] Using NVIDIA GPU acceleration (NVENC)'); print(
'[VOD] Starting $sessionId ($contentType): ${LogRedactor.redactUrl(targetUrl)}',
);
} }
// Build FFmpeg arguments final session = await sessionManager.getOrStart(
final ffmpegArgs = <String>[ id: sessionId,
'-hide_banner', isLive: false,
'-loglevel', ffmpegPath: _getFFmpegPath(),
'warning', argsBuilder: (dir) => [
]; '-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'],
// NVIDIA GPU Hardware Acceleration '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n',
if (useNvidiaGpu) { '-reconnect', '1',
ffmpegArgs.addAll([ '-reconnect_at_eof', '1',
'-hwaccel', 'cuda', '-reconnect_streamed', '1',
// Note: Don't use hwaccel_output_format cuda - it can cause issues '-reconnect_delay_max', '10',
]); '-rw_timeout', '30000000',
} '-timeout', '30000000',
'-analyzeduration', '5000000',
// Input and robustness flags '-probesize', '10000000',
ffmpegArgs.addAll([ '-i', targetUrl,
'-headers', ..._vodVideoArgs(quality, useNvidiaGpu),
'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', ..._audioArgs(withFilters: quality != 'source'),
'-reconnect', '-f', 'hls',
'1', '-hls_time', '4',
'-reconnect_at_eof', '-hls_list_size', '0',
'1', '-hls_playlist_type', 'event',
'-reconnect_streamed', '-hls_allow_cache', '1',
'1', '-hls_flags', 'independent_segments',
'-reconnect_delay_max', '-hls_segment_type', 'mpegts',
'10', '-hls_segment_filename', 'segment_%03d.ts',
'-rw_timeout', '-start_number', '0',
'30000000',
'-timeout',
'30000000',
'-analyzeduration',
'5000000',
'-probesize',
'10000000',
'-i',
targetUrl,
]);
// Video encoding (GPU or CPU)
if (useNvidiaGpu) {
// NVIDIA NVENC - Hardware encoding (50x+ faster, much lower CPU)
ffmpegArgs.addAll([
'-c:v', 'h264_nvenc', // Use NVIDIA NVENC encoder
'-preset', 'p4', // Good quality/speed balance
'-tune', 'hq', // High quality mode
'-rc', 'cbr', // Constant bitrate for HLS
'-b:v', '4000k', // Target bitrate
'-maxrate', '4500k',
'-bufsize', '8000k',
'-g', '48', // Keyframe every 2 seconds
'-bf', '2', // B-frames for quality
'-pix_fmt', 'yuv420p',
]);
} else {
// CPU encoding (libx264)
ffmpegArgs.addAll([
'-c:v',
'libx264',
'-preset',
'medium', // Quality over raw speed for VOD
'-crf',
'18', // Near-native visual quality
'-maxrate',
'12000k',
'-bufsize',
'24000k',
'-pix_fmt',
'yuv420p',
'-g', '48',
'-threads',
'0',
]);
}
// Audio encoding (same for both)
ffmpegArgs.addAll([
'-c:a',
'aac',
'-b:a',
'192k',
'-ar',
'48000',
'-af',
'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15',
]);
// HLS output settings
ffmpegArgs.addAll([
'-f',
'hls',
'-hls_time',
'4',
'-hls_list_size',
'0',
'-hls_playlist_type',
'event',
'-hls_allow_cache',
'1',
'-hls_flags',
'independent_segments',
'-hls_segment_type',
'mpegts',
'-hls_segment_filename',
'segment_%03d.ts',
'-start_number',
'0',
'playlist.m3u8', 'playlist.m3u8',
]); ],
final ffmpegPath = _getFFmpegPath();
Process.start(
ffmpegPath,
ffmpegArgs,
workingDirectory: streamDir.path,
).then((process) {
_vodProcesses[streamId] = process;
process.stderr.transform(utf8.decoder).listen((data) {
// Log only major errors or startup info to keep logs clean(er)
// or keep verbose if debugging
print('[FFmpeg $streamId] $data');
});
process.exitCode.then((code) {
print('[VOD] FFmpeg exited with code $code');
_vodProcesses.remove(streamId);
});
});
}
// Wait for playlist AND at least one segment to be referenced
final playlistFile = File('${streamDir.path}/playlist.m3u8');
int retries = 0;
while (retries < 60) {
if (playlistFile.existsSync()) {
final content = playlistFile.readAsStringSync();
if (content.contains('.ts')) break; // At least one segment is ready
}
await Future.delayed(const Duration(milliseconds: 500));
retries++;
}
if (!playlistFile.existsSync()) {
return Response.internalServerError(
body: 'Timeout waiting for transcoder',
); );
final result = await sessionManager.waitForPlaylist(session);
if (!result.ready) {
sessionManager.killSession(sessionId);
return Response(502, body: 'VOD transcoder failed: ${result.error}');
} }
session.touch();
return Response.ok( return Response.ok(
playlistFile.openRead(), File('${session.dir.path}/playlist.m3u8').openRead(),
headers: { headers: _hlsHeaders(),
'Content-Type': 'application/vnd.apple.mpegurl', // Correct HLS Mime
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
},
); );
});
// Route: /api/vod/{streamId}/segment_{n}.ts (Serve segments)
router.get('/<streamId>/<segment>',
(Request request, String streamId, String segment) async {
final file = File('${_hlsTempDir.path}/$streamId/$segment');
if (!file.existsSync()) {
return Response.notFound('Segment not found');
} }
return Response.ok( // Route: /api/vod/{streamId}/{quality}/playlist.m3u8
file.openRead(), router.get('/<streamId>/<quality>/playlist.m3u8',
headers: { (Request request, String streamId, String quality) {
'Content-Type': 'video/mp2t', return servePlaylist(request, streamId, _sanitizeQuality(quality));
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=3600', // Cache segments
},
);
}); });
return router; // Back-compat route: /api/vod/{streamId}/playlist.m3u8 (?quality=...)
router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final quality =
_sanitizeQuality(request.url.queryParameters['quality']);
final query = request.url.queryParameters['type'] != null
? '?type=${request.url.queryParameters['type']}'
: '';
return Response.found('/api/vod/$streamId/$quality/playlist.m3u8$query');
});
// Route: /api/vod/{streamId}/{quality}/{segment}
router.get('/<streamId>/<quality>/<segment>',
(Request request, String streamId, String quality, String segment) {
if (!_isValidStreamId(streamId) || segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'vod_${streamId}_${_sanitizeQuality(quality)}';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
});
return router.call;
} }
// ========================================== // ==========================================
@@ -562,7 +429,8 @@ Handler createRecordingStreamHandler(
}) { }) {
final router = Router(); final router = Router();
router.get('/<streamId>/playlist.m3u8', (Request request, String streamId) async { router.get('/<streamId>/playlist.m3u8',
(Request request, String streamId) async {
final recording = db.getRecordingById(streamId); final recording = db.getRecordingById(streamId);
if (recording == null) { if (recording == null) {
return Response.notFound('Recording not found'); return Response.notFound('Recording not found');
@@ -573,121 +441,79 @@ Handler createRecordingStreamHandler(
return Response(202, body: 'Recording not yet started'); return Response(202, body: 'Recording not yet started');
} }
if (recording.status == 'failed') { if (recording.status == 'failed') {
return Response(422, body: 'Recording failed: ${recording.errorReason ?? 'unknown error'}'); return Response(422,
body: 'Recording failed: ${recording.errorReason ?? 'unknown error'}');
} }
if (recording.status != 'completed' && recording.status != 'recording') { if (recording.status != 'completed' && recording.status != 'recording') {
return Response.internalServerError(body: 'Invalid recording status: ${recording.status}'); return Response.internalServerError(
body: 'Invalid recording status: ${recording.status}');
} }
// Check file path and existence
if (recording.filePath == null) { if (recording.filePath == null) {
return Response.internalServerError(body: 'Recording file path not set'); return Response.internalServerError(body: 'Recording file path not set');
} }
final targetUrl = recording.filePath!; final targetUrl = recording.filePath!;
if (!File(targetUrl).existsSync()) { if (!File(targetUrl).existsSync()) {
return Response.internalServerError(body: 'Physical recording file not found: $targetUrl'); return Response.internalServerError(
body: 'Physical recording file not found');
} }
final streamDir = Directory('${_hlsTempDir.path}/rec_$streamId');
if (_vodProcesses.containsKey('rec_$streamId')) {
final existingProcess = _vodProcesses['rec_$streamId']!;
final playlistFile = File('${streamDir.path}/playlist.m3u8');
if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) {
return Response.ok(
playlistFile.openRead(),
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
},
);
}
try { existingProcess.kill(); } catch (_) {}
_vodProcesses.remove('rec_$streamId');
if (streamDir.existsSync()) streamDir.deleteSync(recursive: true);
}
if (!_vodProcesses.containsKey('rec_$streamId')) {
if (streamDir.existsSync()) streamDir.deleteSync(recursive: true);
streamDir.createSync(recursive: true);
final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled();
final sessionId = 'rec_$streamId';
final ffmpegArgs = <String>[ final session = await sessionManager.getOrStart(
id: sessionId,
isLive: false,
ffmpegPath: _getFFmpegPath(),
argsBuilder: (dir) => [
'-hide_banner', '-loglevel', 'warning', '-hide_banner', '-loglevel', 'warning',
if (useNvidiaGpu) ...['-hwaccel', 'cuda'], if (useNvidiaGpu) ...['-hwaccel', 'cuda'],
'-i', targetUrl, '-i', targetUrl,
]; if (useNvidiaGpu) ...[
if (useNvidiaGpu) {
ffmpegArgs.addAll([
'-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq',
'-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k', '-bufsize', '6000k', '-rc', 'cbr', '-b:v', '3000k', '-maxrate', '3500k',
'-bufsize', '6000k',
'-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p', '-g', '48', '-bf', '2', '-pix_fmt', 'yuv420p',
]); ] else ...[
} else {
ffmpegArgs.addAll([
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p', '-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p',
'-g', '48', '-threads', '0', '-g', '48', '-threads', '0',
]); ],
} ..._audioArgs(withFilters: true),
ffmpegArgs.addAll([
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
'-af', 'pan=stereo|FL=1.0*FL+0.707*FC+0.5*BL+0.5*SL+0.5*LFE|FR=1.0*FR+0.707*FC+0.5*BR+0.5*SR+0.5*LFE,dynaudnorm=f=150:g=15',
'-f', 'hls', '-hls_time', '4', '-hls_list_size', '0', '-f', 'hls', '-hls_time', '4', '-hls_list_size', '0',
'-hls_playlist_type', 'vod', '-hls_allow_cache', '1', '-hls_playlist_type', 'vod', '-hls_allow_cache', '1',
'-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0',
'playlist.m3u8', 'playlist.m3u8',
]); ],
);
Process.start(_getFFmpegPath(), ffmpegArgs, workingDirectory: streamDir.path).then((process) { final result = await sessionManager.waitForPlaylist(session);
_vodProcesses['rec_$streamId'] = process; if (!result.ready) {
process.stderr.transform(utf8.decoder).listen((data) => print('[FFmpeg Rec $streamId] $data')); sessionManager.killSession(sessionId);
process.exitCode.then((code) { return Response(502,
_vodProcesses.remove('rec_$streamId'); body: 'Recording transcoder failed: ${result.error}');
});
});
} }
final playlistFile = File('${streamDir.path}/playlist.m3u8'); session.touch();
int retries = 0;
while (retries < 60) {
if (playlistFile.existsSync() && playlistFile.readAsStringSync().contains('.ts')) break;
await Future.delayed(const Duration(milliseconds: 500));
retries++;
}
if (!playlistFile.existsSync()) return Response.internalServerError(body: 'Timeout waiting for transcoder');
return Response.ok( return Response.ok(
playlistFile.openRead(), File('${session.dir.path}/playlist.m3u8').openRead(),
headers: { headers: _hlsHeaders(),
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
},
); );
}); });
router.get('/<streamId>/<segment>', (Request request, String streamId, String segment) async { router.get('/<streamId>/<segment>',
final file = File('${_hlsTempDir.path}/rec_$streamId/$segment'); (Request request, String streamId, String segment) async {
if (segment.contains('..')) {
return Response.badRequest(body: 'Invalid request');
}
final sessionId = 'rec_$streamId';
sessionManager.touch(sessionId);
final file = File('${_hlsTempDir.path}/$sessionId/$segment');
if (!file.existsSync()) return Response.notFound('Segment not found'); if (!file.existsSync()) return Response.notFound('Segment not found');
return Response.ok( return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600));
file.openRead(),
headers: {
'Content-Type': 'video/mp2t',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=3600',
},
);
}); });
return router; return router.call;
} }
+78
View File
@@ -0,0 +1,78 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:http/http.dart' as http;
import '../models/playlist_config.dart';
import '../utils/log_redactor.dart';
/// Authenticated gateway to the Xtream `player_api.php` endpoint.
///
/// The frontend never sees the Xtream credentials: it calls
/// `GET /api/xtream-api?action=...` with its session token and the server
/// injects the username/password of the user's playlist before forwarding.
class XtreamApiHandler {
final Future<PlaylistConfig?> Function(Request) _getPlaylist;
final http.Client _client = http.Client();
XtreamApiHandler(this._getPlaylist);
/// Query parameters the client is allowed to pass through to Xtream.
static const _allowedParams = {
'action',
'category_id',
'stream_id',
'series_id',
'vod_id',
'limit',
'type',
};
Future<Response> handle(Request request) async {
final playlist = await _getPlaylist(request);
if (playlist == null) {
return Response.forbidden(
jsonEncode({'error': 'No playlist configured'}),
headers: {'Content-Type': 'application/json'},
);
}
final params = <String, String>{
'username': playlist.username,
'password': playlist.password,
};
request.url.queryParameters.forEach((key, value) {
if (_allowedParams.contains(key)) params[key] = value;
});
final base = Uri.parse('${playlist.dns}/player_api.php');
final targetUrl = base.replace(queryParameters: params);
try {
final proxyRequest = http.Request('GET', targetUrl)
..headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'
..headers['Accept'] = '*/*'
..followRedirects = true;
final response = await _client
.send(proxyRequest)
.timeout(const Duration(seconds: 90));
return Response(
response.statusCode,
body: response.stream,
headers: {
'Content-Type':
response.headers['content-type'] ?? 'application/json',
'Cache-Control': 'no-store',
},
);
} catch (e) {
print(
'[XtreamApi] Error forwarding to ${LogRedactor.redactUrl(targetUrl.toString())}: $e',
);
return Response.internalServerError(
body: jsonEncode({'error': 'Upstream Xtream API error'}),
headers: {'Content-Type': 'application/json'},
);
}
}
}
+18
View File
@@ -186,9 +186,27 @@ class AppDatabase {
return null; return null;
} }
// Lazy migration: rehash legacy SHA-256 hashes with bcrypt on
// successful login (the only moment the plaintext is available).
if (PasswordHasher.isLegacy(passwordHash)) {
updatePasswordHash(
result.first['id'] as String,
PasswordHasher.hash(password),
);
print('[Auth] Migrated legacy password hash for user: $username');
}
return User.fromMap(result.first); return User.fromMap(result.first);
} }
/// Replace a user's stored password hash (used by lazy bcrypt migration)
void updatePasswordHash(String userId, String newHash) {
_db.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[newHash, userId],
);
}
/// Create new user /// Create new user
User createUser(String username, String password, {bool isAdmin = false}) { User createUser(String username, String password, {bool isAdmin = false}) {
final userId = _uuid.v4(); final userId = _uuid.v4();
+34 -2
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import '../database/database.dart'; import '../database/database.dart';
@@ -16,16 +17,20 @@ Middleware authMiddleware(AppDatabase db) {
return Response(401, body: 'Unauthorized'); return Response(401, body: 'Unauthorized');
} }
// Verify session // Verify session (findSessionByToken enforces expires_at)
final session = db.findSessionByToken(token); final session = db.findSessionByToken(token);
if (session == null) { if (session == null) {
return Response(401, body: 'Invalid or expired session'); return Response(401, body: 'Invalid or expired session');
} }
// Add userId to context // Add user info to context. Both keys are populated because handlers
// are inconsistent: playlists_handler reads 'userId', while
// getPlaylist/admin routes read 'user'.
final user = db.findUserById(session.userId);
final updatedRequest = request.change(context: { final updatedRequest = request.change(context: {
...request.context, ...request.context,
'userId': session.userId, 'userId': session.userId,
if (user != null) 'user': user,
},); },);
return handler(updatedRequest); return handler(updatedRequest);
@@ -33,6 +38,33 @@ Middleware authMiddleware(AppDatabase db) {
}; };
} }
/// Auth middleware for streaming routes (HLS playlists/segments).
///
/// hls.js/mpegts.js inside the player iframe cannot send Authorization
/// headers, so these routes accept the HttpOnly session cookie instead.
/// Loopback requests without X-Forwarded-For are allowed through because
/// the recording scheduler's local FFmpeg fetches
/// `http://localhost:8089/api/live/<id>.ts` without credentials.
Middleware streamAuthMiddleware(AppDatabase db) {
return (Handler handler) {
return (Request request) async {
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
final isLoopback = connectionInfo?.remoteAddress.isLoopback ?? false;
final viaProxy = request.headers.containsKey('x-forwarded-for');
if (isLoopback && !viaProxy) {
return handler(request);
}
final token = _extractToken(request);
if (token == null || db.findSessionByToken(token) == null) {
return Response(401, body: 'Unauthorized');
}
return handler(request);
};
};
}
/// Extract token from Authorization header or cookie /// Extract token from Authorization header or cookie
String? _extractToken(Request request) { String? _extractToken(Request request) {
// Try Authorization header first // Try Authorization header first
+73 -10
View File
@@ -1,12 +1,27 @@
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'dart:async'; import 'dart:async';
import 'dart:io';
/// Security Middleware Collection /// Security Middleware Collection
/// ///
/// Includes: /// Includes:
/// - Honeypot Routes (Trap for bots) /// - Honeypot Routes (Trap for bots)
/// - Security Headers (HSTS, XSS Protection) /// - Security Headers (HSTS, XSS Protection, CSP Report-Only)
/// - Rate Limiting (Basic DoS protection) /// - Rate Limiting (Basic DoS protection)
/// - Login-specific rate limiting (brute-force protection)
/// Resolve the real client IP.
/// Honors the first hop of X-Forwarded-For when behind nginx, otherwise
/// falls back to the socket connection info.
String clientIpOf(Request request) {
final forwarded = request.headers['x-forwarded-for'];
if (forwarded != null && forwarded.isNotEmpty) {
return forwarded.split(',').first.trim();
}
final connectionInfo =
request.context['shelf.io.connection_info'] as HttpConnectionInfo?;
return connectionInfo?.remoteAddress.address ?? 'unknown';
}
/// 1. Security Headers Middleware /// 1. Security Headers Middleware
/// Adds standard security headers to every response. /// Adds standard security headers to every response.
@@ -17,12 +32,21 @@ Middleware securityHeadersMiddleware() {
return response.change(headers: { return response.change(headers: {
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN', // Changed from DENY to allow embedding player.html 'X-Frame-Options': 'SAMEORIGIN', // Allow embedding player.html iframes
'X-XSS-Protection': '1; mode=block', 'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
'Referrer-Policy': 'strict-origin-when-cross-origin', 'Referrer-Policy': 'strict-origin-when-cross-origin',
// Note: CSP is tricky with Flutter Web (requires 'unsafe-eval' for Dart), // Report-Only first: Flutter Web (CanvasKit/wasm) and google_fonts
// so we omit it here to avoid breaking the app, or use a permissive one. // can break under an enforcing CSP. Promote to enforcing only after
// a clean soak with no violations in browser consoles.
'Content-Security-Policy-Report-Only':
"default-src 'self'; "
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: blob: https:; "
"media-src 'self' blob:; "
"connect-src 'self' https://fonts.gstatic.com",
},); },);
}; };
}; };
@@ -52,7 +76,7 @@ Middleware honeypotMiddleware() {
// Check if path contains any honeypot target // Check if path contains any honeypot target
for (final trap in honeypotPaths) { for (final trap in honeypotPaths) {
if (path.contains(trap.replaceAll('/', ''))) { // Simple check if (path.contains(trap.replaceAll('/', ''))) { // Simple check
print('SECURITY ALERT: Honeypot triggered by ${request.context['clientIp'] ?? 'unknown IP'} on path: $path'); print('SECURITY ALERT: Honeypot triggered by ${clientIpOf(request)} on path: $path');
return Response.forbidden('Access Denied'); return Response.forbidden('Access Denied');
} }
} }
@@ -64,7 +88,7 @@ Middleware honeypotMiddleware() {
/// 3. Rate Limit Middleware (In-Memory) /// 3. Rate Limit Middleware (In-Memory)
/// Limits requests per IP address. /// Limits requests per IP address.
/// Default: 100 requests per minute per IP. /// Default: 200 requests per minute per IP.
Middleware rateLimitMiddleware({int requestsPerMinute = 200}) { Middleware rateLimitMiddleware({int requestsPerMinute = 200}) {
final clientRequests = <String, List<DateTime>>{}; final clientRequests = <String, List<DateTime>>{};
@@ -80,10 +104,7 @@ Middleware rateLimitMiddleware({int requestsPerMinute = 200}) {
return (Handler handler) { return (Handler handler) {
return (Request request) { return (Request request) {
// Identify client by IP (passed from main server or headers) final clientIp = clientIpOf(request);
// Note: In real prod behind Nginx, use X-Forwarded-For
// Here we assume direct or standard setup.
final clientIp = (request.context['clientIp'] as String?) ?? 'unknown';
if (clientIp != 'unknown' && clientIp != '127.0.0.1') { if (clientIp != 'unknown' && clientIp != '127.0.0.1') {
final now = DateTime.now(); final now = DateTime.now();
@@ -108,3 +129,45 @@ Middleware rateLimitMiddleware({int requestsPerMinute = 200}) {
}; };
}; };
} }
/// 4. Login Rate Limit Middleware
/// Strict per-IP limit on login attempts (brute-force protection),
/// plus a small delay on each attempt to slow credential stuffing.
Middleware loginRateLimitMiddleware({int attemptsPerMinute = 10}) {
final attempts = <String, List<DateTime>>{};
Timer.periodic(const Duration(minutes: 5), (_) {
final now = DateTime.now();
attempts.removeWhere((_, times) {
times.removeWhere((t) => now.difference(t).inMinutes > 1);
return times.isEmpty;
});
});
return (Handler handler) {
return (Request request) async {
// Only throttle the login POST; other auth routes pass through
if (!(request.method == 'POST' && request.url.path.endsWith('login'))) {
return handler(request);
}
final clientIp = clientIpOf(request);
final now = DateTime.now();
final history = attempts.putIfAbsent(clientIp, () => []);
history.removeWhere((t) => now.difference(t).inMinutes >= 1);
if (history.length >= attemptsPerMinute) {
print('SECURITY WARN: Login rate limit exceeded for $clientIp');
return Response(429, body: 'Too many login attempts. Try again later.');
}
history.add(now);
final response = await handler(request);
if (response.statusCode == 401) {
// Slow down brute-force attempts on failed logins
await Future.delayed(const Duration(milliseconds: 300));
}
return response;
};
};
}
+3 -1
View File
@@ -42,7 +42,9 @@ class Playlist {
'name': name, 'name': name,
'serverUrl': serverUrl, 'serverUrl': serverUrl,
'username': username, 'username': username,
'password': password, // Never expose the Xtream password to clients; the backend injects
// credentials server-side (see xtream_api_handler.dart).
'password': '',
'dns': dns, 'dns': dns,
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
+5
View File
@@ -14,6 +14,11 @@ dependencies:
args: ^2.4.2 args: ^2.4.2
sqlite3: ^2.4.0 sqlite3: ^2.4.0
crypto: ^3.0.3 crypto: ^3.0.3
bcrypt: ^1.1.3
path: ^1.9.0
uuid: ^4.3.3 uuid: ^4.3.3
hive: ^2.2.3 hive: ^2.2.3
equatable: ^2.0.5 equatable: ^2.0.5
dev_dependencies:
test: ^1.25.0
+102 -34
View File
@@ -5,7 +5,6 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_static/shelf_static.dart'; import 'package:shelf_static/shelf_static.dart';
import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_router/shelf_router.dart';
import 'package:http/http.dart' as http;
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'database/database.dart'; import 'database/database.dart';
import 'models/user.dart'; import 'models/user.dart';
@@ -20,6 +19,7 @@ import 'api/proxy_handler.dart';
import 'api/recordings_api.dart'; import 'api/recordings_api.dart';
import 'api/epg_api.dart'; import 'api/epg_api.dart';
import 'api/season_passes_api.dart'; import 'api/season_passes_api.dart';
import 'api/xtream_api_handler.dart';
import 'middleware/auth_middleware.dart'; import 'middleware/auth_middleware.dart';
import 'middleware/security_middleware.dart'; import 'middleware/security_middleware.dart';
import 'services/cleanup_service.dart'; import 'services/cleanup_service.dart';
@@ -113,15 +113,46 @@ void main(List<String> args) async {
final playlistsHandler = PlaylistsHandler(db); final playlistsHandler = PlaylistsHandler(db);
final usersHandler = UsersHandler(db); final usersHandler = UsersHandler(db);
final settingsHandler = SettingsHandler(db); final settingsHandler = SettingsHandler(db);
final proxyHandler = ProxyHandler(getPlaylist, db); final proxyHandler = ProxyHandler(getPlaylist);
final recordingsApi = RecordingsApi(db, recordingScheduler); final recordingsApi = RecordingsApi(db, recordingScheduler);
final epgApi = EpgApi(db, getPlaylist); final epgApi = EpgApi(getPlaylist);
final seasonPassesApi = SeasonPassesApi(db); final seasonPassesApi = SeasonPassesApi(db);
final xtreamApiHandler = XtreamApiHandler(getPlaylist);
// TV Recordings sub-router (wrapped with auth below).
// NOTE: /api/recordings/stream/* falls through this router (404) and is
// handled by streamingRouter further down the Cascade.
final recordingsRouter = Router()
..get('/', recordingsApi.handleGetAll)
..post('/', recordingsApi.handlePost)
..delete('/<id>', recordingsApi.handleDelete)
..post('/stop/<id>', recordingsApi.handleStop)
..get('/logs/<id>', recordingsApi.getLogHandler);
final epgRouter = Router()..get('/<channelId>', epgApi.handleGetEpg);
final seasonPassesRouter = Router()
..get('/', seasonPassesApi.handleGetAll)
..post('/', seasonPassesApi.handlePost)
..delete('/<id>', seasonPassesApi.handleDelete);
// Setup router // Setup router
final apiRouter = Router() final apiRouter = Router()
// Auth endpoints // Auth endpoints (with per-IP brute-force protection on login)
..mount('/api/auth', authHandler.router) ..mount(
'/api/auth',
const Pipeline()
.addMiddleware(loginRateLimitMiddleware())
.addHandler(authHandler.router.call),
)
// Xtream API gateway: injects credentials server-side so the
// frontend never sees them
..mount(
'/api/xtream-api',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(xtreamApiHandler.handle),
)
// Playlists endpoints // Playlists endpoints
..mount( ..mount(
'/api/playlists', '/api/playlists',
@@ -143,18 +174,27 @@ void main(List<String> args) async {
.addMiddleware(authMiddleware(db)) .addMiddleware(authMiddleware(db))
.addHandler(settingsHandler.router.call), .addHandler(settingsHandler.router.call),
) )
// TV Recordings - routes explicites // TV Recordings (auth required)
..get('/api/recordings', recordingsApi.handleGetAll) ..mount(
..post('/api/recordings', recordingsApi.handlePost) '/api/recordings',
..delete('/api/recordings/<id>', recordingsApi.handleDelete) const Pipeline()
..post('/api/recordings/stop/<id>', recordingsApi.handleStop) .addMiddleware(authMiddleware(db))
..get('/api/recordings/logs/<id>', recordingsApi.getLogHandler) .addHandler(recordingsRouter.call),
// EPG - guide TV )
..get('/api/epg/<channelId>', epgApi.handleGetEpg) // EPG - guide TV (auth required)
// Season Passes - enregistrements répétés ..mount(
..get('/api/season-passes', seasonPassesApi.handleGetAll) '/api/epg',
..post('/api/season-passes', seasonPassesApi.handlePost) const Pipeline()
..delete('/api/season-passes/<id>', seasonPassesApi.handleDelete); .addMiddleware(authMiddleware(db))
.addHandler(epgRouter.call),
)
// Season Passes - enregistrements répétés (auth required)
..mount(
'/api/season-passes',
const Pipeline()
.addMiddleware(authMiddleware(db))
.addHandler(seasonPassesRouter.call),
);
// NOTE: /api/xtream is handled by proxyHandler in the Cascade below // NOTE: /api/xtream is handled by proxyHandler in the Cascade below
// Do NOT mount here as it would intercept and block the actual proxy // Do NOT mount here as it would intercept and block the actual proxy
@@ -256,11 +296,17 @@ void main(List<String> args) async {
// This allows non-/api/xtream requests to fall through to static handler // This allows non-/api/xtream requests to fall through to static handler
final proxyPipeline = proxyHandler.handler; final proxyPipeline = proxyHandler.handler;
// Streaming routes accept the HttpOnly session cookie (hls.js cannot send
// Authorization headers); local FFmpeg loopback fetches bypass auth.
final protectedStreaming = const Pipeline()
.addMiddleware(streamAuthMiddleware(db))
.addHandler(streamingRouter.call);
// Main handler // Main handler
final handler = Cascade() final handler = Cascade()
.add(apiRouter.call) /* Standard API endpoints */ .add(apiRouter.call) /* Standard API endpoints */
.add(proxyPipeline) /* Xtream Proxy (auth inside handler) */ .add(proxyPipeline) /* Xtream Proxy (auth inside handler) */
.add(streamingRouter.call) /* Streaming endpoints */ .add(protectedStreaming) /* Streaming endpoints */
.add(staticHandler) .add(staticHandler)
.handler; .handler;
@@ -292,26 +338,48 @@ void main(List<String> args) async {
}); });
} }
/// CORS middleware to allow cross-origin requests /// CORS middleware.
///
/// The app is served same-origin by this server, so CORS headers are only
/// needed for development (Flutter dev server on another port) or an
/// explicitly configured external origin via the ALLOWED_ORIGIN env var.
/// The request Origin is echoed back only when it matches the allowlist —
/// never a wildcard.
Middleware _corsMiddleware() { Middleware _corsMiddleware() {
return (Handler handler) { final extraOrigin = Platform.environment['ALLOWED_ORIGIN'];
return (Request request) async {
// Handle preflight requests bool isAllowed(String origin) {
if (request.method == 'OPTIONS') { if (extraOrigin != null && extraOrigin.isNotEmpty && origin == extraOrigin) {
return Response.ok('', headers: _corsHeaders); return true;
}
// Local development origins (flutter run -d chrome, etc.)
final uri = Uri.tryParse(origin);
return uri != null && (uri.host == 'localhost' || uri.host == '127.0.0.1');
} }
// Process request and add CORS headers to response return (Handler handler) {
final response = await handler(request); return (Request request) async {
return response.change(headers: _corsHeaders); final origin = request.headers['origin'];
}; final headers = <String, String>{
}; if (origin != null && isAllowed(origin)) ...{
} 'Access-Control-Allow-Origin': origin,
final _corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Access-Control-Allow-Headers':
'Origin, Content-Type, Accept, Authorization, Range', 'Origin, Content-Type, Accept, Authorization, Range',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range', 'Access-Control-Expose-Headers': 'Content-Length, Content-Range',
}; 'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
},
};
// Handle preflight requests
if (request.method == 'OPTIONS') {
return Response.ok('', headers: headers);
}
// Process request and add CORS headers to response
final response = await handler(request);
return headers.isEmpty ? response : response.change(headers: headers);
};
};
}
+192
View File
@@ -0,0 +1,192 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
/// One running FFmpeg transcoding session (live, VOD or recording playback).
class FfmpegSession {
final String id;
final Process process;
final Directory dir;
final bool isLive;
DateTime lastAccess = DateTime.now();
bool exited = false;
int? exitCode;
/// Last stderr lines, kept for fast-fail diagnostics.
final List<String> recentStderr = [];
FfmpegSession({
required this.id,
required this.process,
required this.dir,
required this.isLive,
});
void touch() => lastAccess = DateTime.now();
}
/// Registry of FFmpeg transcoding processes.
///
/// Responsibilities:
/// - one process per session id (`live_{id}_{quality}`, `vod_{id}`, ...)
/// - reaper kills sessions idle beyond a TTL (no viewer fetching segments)
/// - startup wipe of the HLS temp dir (orphan dirs after a crash)
/// - SIGTERM/SIGINT hook so `docker stop` leaves no orphan ffmpeg
class FfmpegSessionManager {
final Directory baseDir;
final Map<String, FfmpegSession> _sessions = {};
Timer? _reaper;
static const liveIdleTimeout = Duration(minutes: 4);
static const vodIdleTimeout = Duration(minutes: 15);
FfmpegSessionManager(this.baseDir);
/// Wipe orphan session dirs and start the reaper. Call once at startup.
Future<void> init() async {
if (baseDir.existsSync()) {
try {
baseDir.deleteSync(recursive: true);
} catch (e) {
print('[FFmpegManager] Could not wipe temp dir: $e');
}
}
baseDir.createSync(recursive: true);
_reaper = Timer.periodic(const Duration(seconds: 60), (_) => _reap());
// Clean shutdown for docker stop / Ctrl+C
ProcessSignal.sigterm.watch().listen((_) {
killAll();
exit(0);
});
ProcessSignal.sigint.watch().listen((_) {
killAll();
exit(0);
});
}
FfmpegSession? get(String id) => _sessions[id];
/// Marks a session as recently used (call from playlist AND segment routes).
void touch(String id) => _sessions[id]?.touch();
bool contains(String id) => _sessions.containsKey(id);
/// Returns the existing healthy session or starts a new FFmpeg process.
///
/// [argsBuilder] receives the session working directory and returns the
/// FFmpeg argument list. The session directory is recreated for new
/// sessions.
Future<FfmpegSession> getOrStart({
required String id,
required bool isLive,
required String ffmpegPath,
required List<String> Function(Directory dir) argsBuilder,
}) async {
final existing = _sessions[id];
if (existing != null && !existing.exited) {
existing.touch();
return existing;
}
if (existing != null) {
// Process died: clean up before restarting
killSession(id);
}
final dir = Directory('${baseDir.path}/$id');
if (dir.existsSync()) dir.deleteSync(recursive: true);
dir.createSync(recursive: true);
final args = argsBuilder(dir);
final process = await Process.start(
ffmpegPath,
args,
workingDirectory: dir.path,
);
final session = FfmpegSession(
id: id,
process: process,
dir: dir,
isLive: isLive,
);
_sessions[id] = session;
process.stderr.transform(utf8.decoder).listen((data) {
session.recentStderr.add(data);
if (session.recentStderr.length > 20) session.recentStderr.removeAt(0);
print('[FFmpeg $id] $data');
});
process.exitCode.then((code) {
session.exited = true;
session.exitCode = code;
print('[FFmpegManager] Session $id exited with code $code');
});
return session;
}
/// Waits until the session's playlist references at least one segment.
/// Fails fast when the process dies before producing output, returning
/// the recent stderr for diagnostics.
Future<({bool ready, String? error})> waitForPlaylist(
FfmpegSession session, {
Duration timeout = const Duration(seconds: 30),
}) async {
final playlistFile = File('${session.dir.path}/playlist.m3u8');
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (session.exited && session.exitCode != 0) {
return (
ready: false,
error: 'FFmpeg exited (${session.exitCode}): '
'${session.recentStderr.join().trim()}'
);
}
if (playlistFile.existsSync() &&
playlistFile.readAsStringSync().contains('.ts')) {
return (ready: true, error: null);
}
await Future.delayed(const Duration(milliseconds: 500));
}
return (ready: false, error: 'Timeout waiting for transcoder');
}
void killSession(String id) {
final session = _sessions.remove(id);
if (session == null) return;
try {
session.process.kill(ProcessSignal.sigterm);
} catch (_) {}
try {
if (session.dir.existsSync()) session.dir.deleteSync(recursive: true);
} catch (e) {
print('[FFmpegManager] Could not delete dir for $id: $e');
}
}
void killAll() {
print('[FFmpegManager] Killing ${_sessions.length} session(s)');
for (final id in _sessions.keys.toList()) {
killSession(id);
}
_reaper?.cancel();
}
void _reap() {
final now = DateTime.now();
for (final session in _sessions.values.toList()) {
final timeout = session.isLive ? liveIdleTimeout : vodIdleTimeout;
if (session.exited || now.difference(session.lastAccess) > timeout) {
print(
'[FFmpegManager] Reaping idle session ${session.id} '
'(idle ${now.difference(session.lastAccess).inSeconds}s)',
);
killSession(session.id);
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/// Pure decision logic for the recording scheduler, extracted for testability.
enum RecordingAction {
/// Start the recording now.
start,
/// Capacity reached but the window is still open: retry on a later tick.
wait,
/// The end time passed before the recording could start.
fail,
/// Not yet due — nothing to do.
none,
}
/// Decides what to do with a scheduled recording on a scheduler tick.
RecordingAction decideRecordingAction({
required DateTime now,
required DateTime startTime,
required DateTime endTime,
required int activeCount,
required int maxConcurrent,
}) {
final nowUtc = now.toUtc();
final startUtc = startTime.toUtc();
final endUtc = endTime.toUtc();
if (nowUtc.isAfter(endUtc)) return RecordingAction.fail;
if (!nowUtc.isAfter(startUtc)) return RecordingAction.none;
if (activeCount >= maxConcurrent) return RecordingAction.wait;
return RecordingAction.start;
}
+70 -61
View File
@@ -4,17 +4,30 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../database/database.dart'; import '../database/database.dart';
import '../models/recording.dart'; import '../models/recording.dart';
import '../utils/log_redactor.dart';
import 'recording_decision.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
class _ActiveRecording {
final Recording recording;
final Process process;
_ActiveRecording(this.recording, this.process);
}
class RecordingScheduler { class RecordingScheduler {
final AppDatabase _db; final AppDatabase _db;
Timer? _timer; Timer? _timer;
Timer? _seasonPassTimer; Timer? _seasonPassTimer;
bool _isRunning = false; bool _isRunning = false;
// Stocker l'enregistrement actuellement en cours d'exécution /// Enregistrements actuellement en cours, indexés par id.
Recording? _currentRecording; final Map<String, _ActiveRecording> _active = {};
Process? _ffmpegProcess;
/// Nombre maximum d'enregistrements simultanés (env MAX_CONCURRENT_RECORDINGS).
final int maxConcurrent = int.tryParse(
Platform.environment['MAX_CONCURRENT_RECORDINGS'] ?? '',
) ??
2;
// Playlist config pour les appels EPG des season passes // Playlist config pour les appels EPG des season passes
// Rempli depuis server.dart après initialisation // Rempli depuis server.dart après initialisation
@@ -26,7 +39,8 @@ class RecordingScheduler {
void start() { void start() {
print( print(
'[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV', '[RecordingScheduler] Démarrage du planificateur d\'enregistrements TV '
'(max simultanés: $maxConcurrent)',
); );
// Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat) // Vérifier toutes les 10 secondes (pour un démarrage quasi-immédiat)
_timer = Timer.periodic( _timer = Timer.periodic(
@@ -45,7 +59,9 @@ class RecordingScheduler {
void stop() { void stop() {
_timer?.cancel(); _timer?.cancel();
_seasonPassTimer?.cancel(); _seasonPassTimer?.cancel();
_stopCurrentRecording(reason: 'Arrêt du service planificateur'); for (final id in _active.keys.toList()) {
_stopActiveRecording(id, reason: 'Arrêt du service planificateur');
}
print('[RecordingScheduler] Arrêté'); print('[RecordingScheduler] Arrêté');
} }
@@ -58,17 +74,15 @@ class RecordingScheduler {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
final recordings = _db.getAllRecordings(); final recordings = _db.getAllRecordings();
print(
'[RecordingScheduler] VÉRIFICATION: now=$now recordings=${recordings.length}',
);
// Si un enregistrement est en cours, vérifier s'il doit s'arrêter // Arrêter les enregistrements actifs dont l'heure de fin est passée
if (_currentRecording != null) { for (final id in _active.keys.toList()) {
if (now.isAfter(_currentRecording!.endTime.toUtc())) { final active = _active[id]!;
if (now.isAfter(active.recording.endTime.toUtc())) {
print( print(
'[RecordingScheduler] Fin de l\'enregistrement : ${_currentRecording!.title}', '[RecordingScheduler] Fin de l\'enregistrement : ${active.recording.title}',
); );
await _stopCurrentRecording(); _stopActiveRecording(id);
} }
} }
@@ -76,7 +90,7 @@ class RecordingScheduler {
for (final recording in recordings) { for (final recording in recordings) {
// Nettoyer les enregistrements bloqués "recording" suite à un crash serveur // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur
if (recording.status == 'recording' && if (recording.status == 'recording' &&
_currentRecording?.id != recording.id) { !_active.containsKey(recording.id)) {
print( print(
'[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}', '[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}',
); );
@@ -89,33 +103,28 @@ class RecordingScheduler {
} }
if (recording.status == 'scheduled') { if (recording.status == 'scheduled') {
final startUtc = recording.startTime.toUtc(); final action = decideRecordingAction(
final endUtc = recording.endTime.toUtc(); now: now,
print( startTime: recording.startTime,
'[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}', endTime: recording.endTime,
activeCount: _active.length,
maxConcurrent: maxConcurrent,
); );
// Si l'enregistrement est planifié, qu'il est temps de démarrer et qu'il n'est pas déjà fini switch (action) {
if (now.isAfter(startUtc) && now.isBefore(endUtc)) { case RecordingAction.start:
if (_currentRecording != null) {
print(
'[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.',
);
_db.updateRecordingStatus(
recording.id,
'failed',
errorReason: 'Un autre enregistrement était déjà en cours.',
);
continue;
}
print( print(
'[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***', '[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***',
); );
await _startRecording(recording); await _startRecording(recording);
} case RecordingAction.wait:
// Capacité atteinte mais la fenêtre est encore ouverte :
// Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) // on garde le statut "scheduled" et on réessaiera au prochain tick.
if (now.isAfter(endUtc)) { print(
'[RecordingScheduler] Capacité max atteinte (${_active.length}/$maxConcurrent), '
'"${recording.title}" en attente.',
);
case RecordingAction.fail:
print( print(
'[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).', '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).',
); );
@@ -124,6 +133,8 @@ class RecordingScheduler {
'failed', 'failed',
errorReason: 'Heure de fin dépassée avant le démarrage', errorReason: 'Heure de fin dépassée avant le démarrage',
); );
case RecordingAction.none:
break;
} }
} }
} }
@@ -223,7 +234,6 @@ class RecordingScheduler {
} }
Future<void> _startRecording(Recording recording) async { Future<void> _startRecording(Recording recording) async {
_currentRecording = recording;
_db.updateRecordingStatus(recording.id, 'recording'); _db.updateRecordingStatus(recording.id, 'recording');
// Un seul bloc try/catch englobant TOUT pour éviter les Unhandled exceptions // Un seul bloc try/catch englobant TOUT pour éviter les Unhandled exceptions
@@ -269,11 +279,12 @@ class RecordingScheduler {
]; ];
// Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception) // Créer le fichier de log de façon sécurisée (openWrite peut lancer une exception)
// Le contenu du log est exposé via l'API : ne jamais y écrire de credentials.
IOSink? logSink; IOSink? logSink;
try { try {
logSink = File(logFilePath).openWrite(); logSink = File(logFilePath).openWrite();
logSink.writeln('[${DateTime.now()}] Démarrage: ${recording.title}'); logSink.writeln('[${DateTime.now()}] Démarrage: ${recording.title}');
logSink.writeln('URL: $streamUrl'); logSink.writeln('URL: ${LogRedactor.redactUrl(streamUrl)}');
logSink.writeln('Destination: $filePath'); logSink.writeln('Destination: $filePath');
} catch (logError) { } catch (logError) {
// Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire) // Si on ne peut pas créer le log, on continue quand même (l'enregistrement reste prioritaire)
@@ -294,21 +305,24 @@ class RecordingScheduler {
ffmpegPath = '/usr/local/bin/ffmpeg'; ffmpegPath = '/usr/local/bin/ffmpeg';
} }
logSink?.writeln('Commande: $ffmpegPath ${args.join(' ')}\n'); final redactedArgs =
print('[RecordingScheduler] Exécution: $ffmpegPath ${args.join(' ')}'); args.map(LogRedactor.redactUrl).join(' ');
logSink?.writeln('Commande: $ffmpegPath $redactedArgs\n');
print('[RecordingScheduler] Exécution: $ffmpegPath $redactedArgs');
_ffmpegProcess = await Process.start(ffmpegPath, args); final process = await Process.start(ffmpegPath, args);
_active[recording.id] = _ActiveRecording(recording, process);
// Rediriger stdout/stderr dans le log // Rediriger stdout/stderr dans le log
_ffmpegProcess!.stdout.listen((event) => logSink?.add(event)); process.stdout.listen((event) => logSink?.add(event));
_ffmpegProcess!.stderr.listen((event) => logSink?.add(event)); process.stderr.listen((event) => logSink?.add(event));
// Enregistrer le chemin du fichier dans la BDD // Enregistrer le chemin du fichier dans la BDD
_db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); _db.updateRecordingStatus(recording.id, 'recording', filePath: filePath);
// Écouter la fin du processus FFmpeg de manière asynchrone // Écouter la fin du processus FFmpeg de manière asynchrone
_ffmpegProcess!.exitCode.then((exitCode) async { process.exitCode.then((exitCode) async {
if (_currentRecording?.id == recording.id) { if (_active.containsKey(recording.id)) {
logSink?.writeln( logSink?.writeln(
'\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode', '\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode',
); );
@@ -329,8 +343,7 @@ class RecordingScheduler {
errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.', errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.',
); );
} }
_currentRecording = null; _active.remove(recording.id);
_ffmpegProcess = null;
} else { } else {
await logSink?.close(); await logSink?.close();
} }
@@ -343,34 +356,30 @@ class RecordingScheduler {
'failed', 'failed',
errorReason: 'Erreur au lancement: $e', errorReason: 'Erreur au lancement: $e',
); );
_currentRecording = null; _active.remove(recording.id);
_ffmpegProcess = null;
} }
} }
/// Arrêter un enregistrement en cours (appelé depuis l'API) /// Arrêter un enregistrement en cours (appelé depuis l'API)
Future<bool> stopRecording(String id) async { Future<bool> stopRecording(String id) async {
if (_ffmpegProcess != null && _currentRecording?.id == id) { if (_active.containsKey(id)) {
print( print(
'[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}', '[RecordingScheduler] Arrêt demandé pour: ${_active[id]!.recording.title}',
); );
_ffmpegProcess!.kill(ProcessSignal.sigterm); _stopActiveRecording(id);
_db.updateRecordingStatus(id, 'completed');
_ffmpegProcess = null;
_currentRecording = null;
return true; return true;
} }
return false; // Pas d'enregistrement actif avec cet ID return false; // Pas d'enregistrement actif avec cet ID
} }
Future<void> _stopCurrentRecording({String? reason}) async { void _stopActiveRecording(String id, {String? reason}) {
if (_ffmpegProcess != null && _currentRecording != null) { final active = _active.remove(id);
print('[RecordingScheduler] Arrêt auto: ${reason ?? "Fin programmée"}'); if (active == null) return;
_ffmpegProcess!.kill(ProcessSignal.sigterm); if (reason != null) {
_db.updateRecordingStatus(_currentRecording!.id, 'completed'); print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})');
_ffmpegProcess = null;
_currentRecording = null;
} }
active.process.kill(ProcessSignal.sigterm);
_db.updateRecordingStatus(id, 'completed');
} }
Future<void> _checkDiskSpaceAndRotate(Directory dir) async { Future<void> _checkDiskSpaceAndRotate(Directory dir) async {
+33
View File
@@ -0,0 +1,33 @@
import 'package:test/test.dart';
import '../utils/log_redactor.dart';
void main() {
group('LogRedactor.redactUrl', () {
test('masks credentials in query strings', () {
final out = LogRedactor.redactUrl(
'http://srv:8080/player_api.php?username=john&password=hunter2&action=get_live_streams',
);
expect(out, isNot(contains('john')));
expect(out, isNot(contains('hunter2')));
expect(out, contains('username=***'));
expect(out, contains('password=***'));
expect(out, contains('action=get_live_streams'));
});
test('masks credentials in live/movie/series path segments', () {
for (final kind in ['live', 'movie', 'series']) {
final out = LogRedactor.redactUrl(
'http://srv:8080/$kind/john/hunter2/12345.ts',
);
expect(out, isNot(contains('john')), reason: kind);
expect(out, isNot(contains('hunter2')), reason: kind);
expect(out, contains('/$kind/***/***/12345.ts'), reason: kind);
}
});
test('leaves URLs without credentials untouched', () {
const url = 'http://srv:8080/images/logo.png';
expect(LogRedactor.redactUrl(url), url);
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
import '../utils/password_hasher.dart';
void main() {
group('PasswordHasher', () {
test('bcrypt roundtrip verifies the original password', () {
final hash = PasswordHasher.hash('s3cret-Pass!');
expect(hash, startsWith(r'$2'));
expect(PasswordHasher.verify('s3cret-Pass!', hash), isTrue);
expect(PasswordHasher.verify('wrong', hash), isFalse);
});
test('legacy unsalted SHA-256 hashes still verify', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.verify('admin', legacy), isTrue);
expect(PasswordHasher.verify('not-admin', legacy), isFalse);
});
test('isLegacy detects SHA-256 hex digests only', () {
final legacy = sha256.convert(utf8.encode('admin')).toString();
expect(PasswordHasher.isLegacy(legacy), isTrue);
expect(PasswordHasher.isLegacy(PasswordHasher.hash('admin')), isFalse);
});
test('rehash produces a non-legacy hash', () {
final rehashed = PasswordHasher.hash('admin');
expect(PasswordHasher.isLegacy(rehashed), isFalse);
expect(PasswordHasher.verify('admin', rehashed), isTrue);
});
test('malformed hash never verifies', () {
expect(PasswordHasher.verify('x', 'garbage-hash'), isFalse);
});
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:test/test.dart';
import '../services/recording_decision.dart';
void main() {
final now = DateTime.utc(2026, 6, 10, 12, 0);
group('decideRecordingAction', () {
test('starts when due and capacity available', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
test('waits (keeps scheduled) when at capacity but window still open', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 2,
maxConcurrent: 2,
),
RecordingAction.wait,
);
});
test('fails when the end time has passed', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(hours: 2)),
endTime: now.subtract(const Duration(minutes: 5)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.fail,
);
});
test('does nothing before the start time', () {
expect(
decideRecordingAction(
now: now,
startTime: now.add(const Duration(minutes: 30)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 0,
maxConcurrent: 2,
),
RecordingAction.none,
);
});
test('second overlapping recording starts when maxConcurrent is 2', () {
expect(
decideRecordingAction(
now: now,
startTime: now.subtract(const Duration(minutes: 1)),
endTime: now.add(const Duration(hours: 1)),
activeCount: 1,
maxConcurrent: 2,
),
RecordingAction.start,
);
});
});
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:test/test.dart';
import '../utils/safe_path.dart';
void main() {
group('SafePath.resolveWithin', () {
const base = '/app/recordings';
test('accepts files inside the base directory', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/show.log'),
isNotNull,
);
expect(SafePath.resolveWithin(base, 'show.log'), isNotNull);
expect(
SafePath.resolveWithin(base, '/app/recordings/sub/dir/file.log'),
isNotNull,
);
});
test('rejects .. traversal', () {
expect(
SafePath.resolveWithin(base, '/app/recordings/../data/xtremflow.db'),
isNull,
);
expect(SafePath.resolveWithin(base, '../../etc/passwd'), isNull);
});
test('rejects absolute paths outside the base', () {
expect(SafePath.resolveWithin(base, '/etc/passwd'), isNull);
expect(SafePath.resolveWithin(base, '/app/data/xtremflow.db'), isNull);
});
test('rejects sibling directories with a shared prefix', () {
expect(
SafePath.resolveWithin(base, '/app/recordings-evil/file.log'),
isNull,
);
});
});
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:test/test.dart';
import '../api/proxy_handler.dart';
void main() {
group('isForbiddenProxyHost', () {
test('blocks loopback', () {
expect(isForbiddenProxyHost('127.0.0.1'), isTrue);
expect(isForbiddenProxyHost('localhost'), isTrue);
expect(isForbiddenProxyHost('::1'), isTrue);
});
test('blocks private LAN ranges', () {
expect(isForbiddenProxyHost('10.0.0.5'), isTrue);
expect(isForbiddenProxyHost('172.16.0.1'), isTrue);
expect(isForbiddenProxyHost('172.31.255.255'), isTrue);
expect(isForbiddenProxyHost('192.168.1.10'), isTrue);
});
test('blocks link-local / cloud metadata range', () {
expect(isForbiddenProxyHost('169.254.169.254'), isTrue);
});
test('allows public IPs and hostnames', () {
expect(isForbiddenProxyHost('8.8.8.8'), isFalse);
expect(isForbiddenProxyHost('172.32.0.1'), isFalse);
expect(isForbiddenProxyHost('cdn.example.com'), isFalse);
});
});
}
+29
View File
@@ -0,0 +1,29 @@
/// Redacts IPTV credentials from URLs before they reach logs.
///
/// Xtream Codes embeds credentials both in query strings
/// (`?username=u&password=p`) and in path segments
/// (`/live/<user>/<pass>/<id>.ts`, `/movie/...`, `/series/...`).
class LogRedactor {
static final RegExp _queryCreds = RegExp(
r'(username|password)=[^&\s]*',
caseSensitive: false,
);
static final RegExp _pathCreds = RegExp(
r'/(live|movie|series)/[^/\s]+/[^/\s]+/',
caseSensitive: false,
);
/// Returns [url] with credentials replaced by `***`.
static String redactUrl(String url) {
var redacted = url.replaceAllMapped(
_queryCreds,
(m) => '${m.group(1)}=***',
);
redacted = redacted.replaceAllMapped(
_pathCreds,
(m) => '/${m.group(1)}/***/***/',
);
return redacted;
}
}
+25 -7
View File
@@ -1,17 +1,35 @@
import 'dart:convert'; import 'dart:convert';
import 'package:bcrypt/bcrypt.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
class PasswordHasher { class PasswordHasher {
/// Hash a password using SHA256 (simple implementation) /// Cost factor 10: ~100-300ms per hash in pure Dart, acceptable for login frequency.
/// Note: In production, use a proper bcrypt implementation static const int _bcryptRounds = 10;
/// Legacy hashes are unsalted SHA-256 hex digests (64 hex chars).
static final RegExp _legacyPattern = RegExp(r'^[0-9a-f]{64}$');
/// Hash a password using bcrypt (salt embedded in the result).
static String hash(String password) { static String hash(String password) {
final bytes = utf8.encode(password); return BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptRounds));
final digest = sha256.convert(bytes);
return digest.toString();
} }
/// Verify a password against a hash /// Verify a password against a stored hash.
/// Supports legacy unsalted SHA-256 hashes for accounts created before
/// the bcrypt migration; those are rehashed lazily on successful login.
static bool verify(String password, String hash) { static bool verify(String password, String hash) {
return PasswordHasher.hash(password) == hash; if (isLegacy(hash)) {
final digest = sha256.convert(utf8.encode(password)).toString();
return digest == hash;
} }
try {
return BCrypt.checkpw(password, hash);
} catch (_) {
// Malformed hash (neither legacy SHA-256 nor valid bcrypt)
return false;
}
}
/// True if the stored hash uses the legacy unsalted SHA-256 scheme.
static bool isLegacy(String hash) => _legacyPattern.hasMatch(hash);
} }
+18
View File
@@ -0,0 +1,18 @@
import 'package:path/path.dart' as p;
/// Path validation helpers to prevent directory traversal.
class SafePath {
/// Resolves [candidate] and returns it only if it stays inside [baseDir].
/// Returns null when the path escapes the base directory (e.g. via `..`
/// segments or an absolute path pointing elsewhere).
static String? resolveWithin(String baseDir, String candidate) {
final base = p.normalize(p.absolute(baseDir));
final resolved = p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(base, candidate),
);
if (resolved == base || p.isWithin(base, resolved)) {
return resolved;
}
return null;
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+8
View File
@@ -14,6 +14,14 @@ services:
# Exemple Windows : - D:\MesVideos\TV:/app/recordings # Exemple Windows : - D:\MesVideos\TV:/app/recordings
# Exemple Linux : - /mnt/nas/videos:/app/recordings # Exemple Linux : - /mnt/nas/videos:/app/recordings
- ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings - ${RECORDINGS_PATH:-/mnt/user/Data/Sport}:/app/recordings
environment:
# Origine externe autorisée pour CORS (optionnel — l'app est servie
# same-origin, ne définir que si un autre domaine doit appeler l'API)
- ALLOWED_ORIGIN=${ALLOWED_ORIGIN:-}
# Nombre max d'enregistrements TV simultanés (FFmpeg -c copy, peu de CPU)
- MAX_CONCURRENT_RECORDINGS=${MAX_CONCURRENT_RECORDINGS:-2}
# Accélération GPU NVIDIA pour le transcodage (NVENC) : true/false
- NVIDIA_GPU=${NVIDIA_GPU:-false}
restart: unless-stopped restart: unless-stopped
volumes: volumes:
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
+7 -2
View File
@@ -11,8 +11,13 @@ chown -R xtremuser:xtremuser /app/data 2>/dev/null || true
# Create subdirectories if they don't exist # Create subdirectories if they don't exist
mkdir -p /app/data/logs /app/data/tmp /app/recordings mkdir -p /app/data/logs /app/data/tmp /app/recordings
chown -R xtremuser:xtremuser /app/data/logs /app/data/tmp /app/recordings 2>/dev/null || true chown -R xtremuser:xtremuser /app/data/logs /app/data/tmp /app/recordings 2>/dev/null || true
# Garantir les permissions en écriture même si chown échoue (volume monté depuis l'hôte) # Garantir les permissions en écriture (770 si chown a réussi, 775 en
chmod -R 777 /app/recordings 2>/dev/null || true # secours pour les volumes hôtes avec un UID différent — jamais 777)
if chown -R xtremuser:xtremuser /app/recordings 2>/dev/null; then
chmod -R 770 /app/recordings 2>/dev/null || true
else
chmod -R 775 /app/recordings 2>/dev/null || true
fi
# Drop privileges and run the server as xtremuser # Drop privileges and run the server as xtremuser
echo "Starting server as xtremuser..." echo "Starting server as xtremuser..."
BIN
View File
Binary file not shown.
+7 -3
View File
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'dart:html' as html; import 'dart:html' as html;
/// API Client for communicating with the backend /// API Client for communicating with the backend
@@ -17,11 +18,14 @@ class ApiClient {
receiveTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60),
),); ),);
// Add interceptor for logging // Debug-only logging; request bodies are never logged (login payloads
// contain passwords).
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor( _dio.interceptors.add(LogInterceptor(
requestBody: true, requestBody: false,
responseBody: true, responseBody: false,
),); ),);
}
// Restore token from localStorage on initialization // Restore token from localStorage on initialization
_restoreTokenFromStorage(); _restoreTokenFromStorage();
-118
View File
@@ -1,118 +0,0 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import '../models/app_user.dart';
import '../models/playlist_config.dart';
class HiveService {
static const String _usersBoxName = 'users';
static const String _playlistsBoxName = 'playlists';
static bool _initialized = false;
static List<int>? _encryptionKey;
/// Initialize Hive for Web with encryption
static Future<void> init() async {
if (_initialized) return;
// Initialize Hive for Web (uses IndexedDB)
await Hive.initFlutter();
// Register adapters
Hive.registerAdapter(AppUserAdapter());
Hive.registerAdapter(PlaylistConfigAdapter());
// Open boxes WITHOUT encryption on Web
// Note: On Web, IndexedDB is already isolated by origin,
// and session-based encryption keys cause decrypt errors on reload
await Hive.openBox<AppUser>(_usersBoxName);
await Hive.openBox<PlaylistConfig>(_playlistsBoxName);
// Seed default admin if no users exist
await _seedDefaultAdmin();
_initialized = true;
}
/// Get or create 256-bit encryption key (web-compatible)
static Future<List<int>> _getOrCreateEncryptionKey() async {
// For web: use session-based key (regenerated each session)
// Note: Data persists in IndexedDB but encryption key is not stored
if (_encryptionKey != null) {
return _encryptionKey!;
}
// Generate new 256-bit key
_encryptionKey = Hive.generateSecureKey();
return _encryptionKey!;
}
/// Seed default admin user (admin/admin)
static Future<void> _seedDefaultAdmin() async {
final usersBox = Hive.box<AppUser>(_usersBoxName);
if (usersBox.isEmpty) {
final adminId = const Uuid().v4();
final passwordHash = _hashPassword('admin');
final admin = AppUser(
id: adminId,
username: 'admin',
passwordHash: passwordHash,
isAdmin: true,
assignedPlaylistIds: const [],
createdAt: DateTime.now(),
);
await usersBox.put(adminId, admin);
}
}
/// Hash password using SHA-256 with salt
static String _hashPassword(String password) {
// Generate a random salt using the first 16 chars of a UUID
final salt = const Uuid().v4().substring(0, 16);
final bytes = utf8.encode(password + salt);
final digest = sha256.convert(bytes);
return '$salt:${digest.toString()}';
}
/// Verify password against stored hash
static bool verifyPassword(String password, String storedHash) {
try {
final parts = storedHash.split(':');
if (parts.length != 2) {
// Legacy hash without salt - fallback to direct comparison
final legacyHash = sha256.convert(utf8.encode(password)).toString();
return legacyHash == storedHash;
}
final salt = parts[0];
final expectedHash = parts[1];
final bytes = utf8.encode(password + salt);
final actualHash = sha256.convert(bytes).toString();
return actualHash == expectedHash;
} catch (e) {
return false;
}
}
/// Public method to hash passwords (used by auth)
static String hashPassword(String password) => _hashPassword(password);
/// Get users box
static Box<AppUser> get usersBox => Hive.box<AppUser>(_usersBoxName);
/// Get playlists box
static Box<PlaylistConfig> get playlistsBox =>
Hive.box<PlaylistConfig>(_playlistsBoxName);
/// Close all boxes (cleanup)
static Future<void> dispose() async {
await Hive.close();
_initialized = false;
}
}
@@ -1,346 +0,0 @@
/// Adaptive Bitrate (ABR) Streaming Handler for HLS
///
/// Supports multi-bitrate HLS playlists for adaptive streaming
/// with fallback options for poor network conditions
library;
/// Represents a video quality level for adaptive bitrate streaming
class QualityLevel {
final String resolution;
final int bitrateBps;
final int width;
final int height;
final String ffmpegPreset; // ultrafast, fast, medium, slow
final String label;
const QualityLevel({
required this.resolution,
required this.bitrateBps,
required this.width,
required this.height,
this.ffmpegPreset = 'medium',
required this.label,
});
/// Get FFmpeg video bitrate argument
String get videoBitrateArg => '${(bitrateBps * 0.75).toInt() ~/ 1000}k';
/// Get FFmpeg maxrate argument (150% of bitrate)
String get maxRateArg => '${((bitrateBps * 1.5).toInt() ~/ 1000)}k';
/// Get FFmpeg buffer size argument
String get bufferSizeArg => '${((bitrateBps * 2).toInt() ~/ 1000)}k';
@override
String toString() => '$label ($resolution - ${bitrateBps ~/ 1000000}Mbps)';
}
/// Standard quality profiles for adaptive streaming
class QualityProfiles {
// Mobile & Low Bandwidth
static const mobile240p = QualityLevel(
resolution: '426x240',
bitrateBps: 500000, // 0.5 Mbps
width: 426,
height: 240,
ffmpegPreset: 'ultrafast',
label: '240p (Low)',
);
static const mobile360p = QualityLevel(
resolution: '640x360',
bitrateBps: 1200000, // 1.2 Mbps
width: 640,
height: 360,
ffmpegPreset: 'ultrafast',
label: '360p (Mobile)',
);
// Standard Quality
static const hd480p = QualityLevel(
resolution: '854x480',
bitrateBps: 2500000, // 2.5 Mbps
width: 854,
height: 480,
ffmpegPreset: 'fast',
label: '480p (SD)',
);
static const hd720p = QualityLevel(
resolution: '1280x720',
bitrateBps: 5000000, // 5 Mbps
width: 1280,
height: 720,
ffmpegPreset: 'medium',
label: '720p (HD)',
);
// High Quality
static const fhd1080p = QualityLevel(
resolution: '1920x1080',
bitrateBps: 8000000, // 8 Mbps
width: 1920,
height: 1080,
ffmpegPreset: 'medium',
label: '1080p (Full HD)',
);
// Ultra HD
static const uhd2k = QualityLevel(
resolution: '2560x1440',
bitrateBps: 15000000, // 15 Mbps
width: 2560,
height: 1440,
ffmpegPreset: 'slow',
label: '2K (QHD)',
);
static const uhd4k = QualityLevel(
resolution: '3840x2160',
bitrateBps: 25000000, // 25 Mbps
width: 3840,
height: 2160,
ffmpegPreset: 'slow',
label: '4K',
);
/// Get default profiles for balanced streaming
static List<QualityLevel> getBalancedProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
];
/// Get all available profiles
static List<QualityLevel> getAllProfiles() => [
mobile240p,
mobile360p,
hd480p,
hd720p,
fhd1080p,
uhd2k,
uhd4k,
];
/// Get profiles suitable for streaming type
static List<QualityLevel> getProfiles({
required String type, // 'live', 'vod', 'low-bandwidth'
int? maxBitrateBps,
}) {
List<QualityLevel> profiles = getBalancedProfiles();
// Filter by streaming type
if (type == 'live') {
// Live TV prefers lower bitrates for stability
profiles = [mobile240p, mobile360p, hd480p, hd720p];
} else if (type == 'low-bandwidth') {
// Mobile/low bandwidth - only lower profiles
profiles = [mobile240p, mobile360p, hd480p];
}
// Filter by max bitrate if specified
if (maxBitrateBps != null) {
profiles = profiles.where((p) => p.bitrateBps <= maxBitrateBps).toList();
}
// Ensure at least one profile
if (profiles.isEmpty) {
profiles = [mobile240p];
}
return profiles;
}
}
/// Bandwidth detector for adaptive bitrate selection
class BandwidthDetector {
final _samples = <DateTime, int>{}; // timestamp -> bytes downloaded
final int _totalBytesLastPeriod = 0;
final Duration _sampleWindow = const Duration(seconds: 10);
/// Record bytes downloaded at current time
void recordBytes(int bytes) {
final now = DateTime.now();
_samples[now] = bytes;
// Clean up old samples
final cutoff = now.subtract(_sampleWindow);
_samples.removeWhere((time, _) => time.isBefore(cutoff));
}
/// Estimate current bandwidth in bps
int estimateBandwidthBps() {
if (_samples.isEmpty) return 0;
int totalBytes = 0;
for (var bytes in _samples.values) {
totalBytes += bytes;
}
final timeSpan = DateTime.now()
.difference(
_samples.keys.reduce((a, b) => a.isBefore(b) ? a : b),
)
.inMilliseconds;
if (timeSpan == 0) return 0;
// bytes per second * 8 = bits per second
return ((totalBytes / (timeSpan / 1000)) * 8).toInt();
}
/// Get estimated bandwidth in Mbps
double estimateBandwidthMbps() {
return estimateBandwidthBps() / 1000000;
}
/// Select best quality level based on bandwidth
static QualityLevel selectBestQuality(
int bandwidthBps,
List<QualityLevel> availableProfiles,
) {
// Use 75% of detected bandwidth for conservative selection
final targetBitrate = (bandwidthBps * 0.75).toInt();
// Find highest quality that fits our bandwidth
var best = availableProfiles[0];
for (final profile in availableProfiles) {
if (profile.bitrateBps <= targetBitrate) {
best = profile;
} else {
break;
}
}
return best;
}
}
/// Master playlist generator for HLS variant streams
class HlsMasterPlaylistGenerator {
/// Generate M3U8 master playlist with multiple quality variants
static String generateMasterPlaylist({
required List<QualityLevel> qualityLevels,
required String baseUrl,
bool includeSubtitles = false,
}) {
final buffer = StringBuffer();
// HLS version 3 for compatibility
buffer.writeln('#EXTM3U');
buffer.writeln('#EXT-X-VERSION:3');
buffer.writeln('#EXT-X-TARGET-DURATION:10');
buffer.writeln('#EXT-X-MEDIA-SEQUENCE:0');
// Variant streams (quality levels)
for (final quality in qualityLevels) {
buffer.writeln('#EXT-X-STREAM-INF:BANDWIDTH=${quality.bitrateBps},'
'RESOLUTION=${quality.width}x${quality.height}');
buffer
.writeln('$baseUrl/variant_${quality.width}x${quality.height}.m3u8');
}
return buffer.toString();
}
/// Generate variant playlist for specific quality level
static String generateVariantPlaylist({
required String segmentPrefix,
required int totalSegments,
required double targetDuration,
required bool isFinal,
}) {
final buffer = StringBuffer();
buffer.writeln('#EXTM3U');
buffer.writeln('#EXT-X-VERSION:3');
buffer.writeln('#EXT-X-TARGET-DURATION:${targetDuration.ceil()}');
buffer.writeln('#EXT-X-MEDIA-SEQUENCE:0');
// Add segment entries
for (int i = 0; i < totalSegments; i++) {
buffer.writeln('#EXTINF:$targetDuration,');
buffer.writeln('${segmentPrefix}_$i.ts');
}
if (isFinal) {
buffer.writeln('#EXT-X-ENDLIST');
}
return buffer.toString();
}
}
/// Quality selector based on network conditions
class QualitySelector {
final _detector = BandwidthDetector();
QualityLevel _currentQuality;
final _listeners = <VoidCallback>[];
QualitySelector({
required QualityLevel initialQuality,
}) : _currentQuality = initialQuality;
QualityLevel get currentQuality => _currentQuality;
/// Record bandwidth and auto-adjust quality
void recordBandwidth(int bytesDownloaded) {
_detector.recordBytes(bytesDownloaded);
// Check if we should switch quality
_maybeAdjustQuality();
}
/// Manually set quality
void setQuality(QualityLevel quality) {
if (_currentQuality != quality) {
_currentQuality = quality;
_notifyListeners();
}
}
/// Force rebuffer wait before switching down
void rebufferDetected() {
// Switch to lower quality if rebuffering occurs
final allProfiles = QualityProfiles.getAllProfiles();
final currentIndex = allProfiles.indexOf(_currentQuality);
if (currentIndex > 0) {
setQuality(allProfiles[currentIndex - 1]);
}
}
void _maybeAdjustQuality() {
final bandwidthBps = _detector.estimateBandwidthBps();
final availableProfiles = QualityProfiles.getBalancedProfiles();
// Only switch if significantly different from current
final overhead = _currentQuality.bitrateBps * 1.2;
if (bandwidthBps > overhead ||
bandwidthBps < _currentQuality.bitrateBps * 0.5) {
final bestQuality =
BandwidthDetector.selectBestQuality(bandwidthBps, availableProfiles);
setQuality(bestQuality);
}
}
void addListener(VoidCallback listener) {
_listeners.add(listener);
}
void removeListener(VoidCallback listener) {
_listeners.remove(listener);
}
void _notifyListeners() {
for (final listener in _listeners) {
listener();
}
}
double getEstimatedBandwidthMbps() => _detector.estimateBandwidthMbps();
}
// Type alias for void callback
typedef VoidCallback = void Function();
-267
View File
@@ -1,267 +0,0 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/adaptive_bitrate_service.dart';
/// Video streaming performance metrics
class StreamingMetrics {
final double averageNetworkBitrate;
final double currentNetworkBitrate;
final int bufferDurationMs;
final int segmentDownloadTimeMs;
final int rebufferCount;
final DateTime startTime;
final DateTime? endTime;
StreamingMetrics({
required this.averageNetworkBitrate,
required this.currentNetworkBitrate,
required this.bufferDurationMs,
required this.segmentDownloadTimeMs,
required this.rebufferCount,
required this.startTime,
this.endTime,
});
double get totalPlaybackSeconds =>
(endTime ?? DateTime.now()).difference(startTime).inSeconds.toDouble();
double get qualityScore {
// Score based on metrics
var score = 100.0;
// Penalize for rebuffering
score -= rebufferCount * 10;
// Penalize for low bitrate
if (currentNetworkBitrate < 1000000) score -= 20;
if (currentNetworkBitrate < 500000) score -= 30;
// Reward for smooth playback
if (rebufferCount == 0) score += 10;
return (score).clamp(0, 100);
}
@override
String toString() => '''
StreamingMetrics:
- Avg Bitrate: ${(averageNetworkBitrate / 1000000).toStringAsFixed(2)} Mbps
- Current Bitrate: ${(currentNetworkBitrate / 1000000).toStringAsFixed(2)} Mbps
- Buffer: ${bufferDurationMs}ms
- Segment DL Time: ${segmentDownloadTimeMs}ms
- Rebuffers: $rebufferCount
- Quality Score: ${qualityScore.toStringAsFixed(1)}
- Duration: $totalPlaybackSeconds seconds
''';
}
/// Advanced streaming optimizer
class StreamingOptimizer {
final QualitySelector qualitySelector;
final _metrics = <StreamingMetrics>[];
late StreamingMetrics _currentMetrics;
final StreamController<StreamingMetrics> _metricsController =
StreamController.broadcast();
late Timer _metricsTimer;
final int _segmentStartTime = 0;
int _totalBytesDownloaded = 0;
int _rebufferCount = 0;
int _lastBufferNotificationTime = 0;
StreamingOptimizer({required this.qualitySelector}) {
_initializeMetrics();
_startMetricsCollection();
}
void _initializeMetrics() {
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
);
}
void _startMetricsCollection() {
_metricsTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
_updateMetrics();
});
}
void _updateMetrics() {
final bandwidthBps = qualitySelector.getEstimatedBandwidthMbps() * 1000000;
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: _totalBytesDownloaded > 0
? (_totalBytesDownloaded * 8 / _currentMetrics.totalPlaybackSeconds)
: 0,
currentNetworkBitrate: bandwidthBps,
bufferDurationMs: _currentMetrics.bufferDurationMs,
segmentDownloadTimeMs: _currentMetrics.segmentDownloadTimeMs,
rebufferCount: _rebufferCount,
startTime: _currentMetrics.startTime,
);
_metrics.add(_currentMetrics);
_metricsController.add(_currentMetrics);
}
/// Record segment download
void recordSegmentDownload(int bytes, Duration downloadTime) {
_totalBytesDownloaded += bytes;
final downloadMs = downloadTime.inMilliseconds;
_currentMetrics = _currentMetrics;
qualitySelector.recordBandwidth(bytes);
}
/// Handle rebuffer event
void rebufferDetected(Duration duration) {
_rebufferCount++;
_lastBufferNotificationTime = DateTime.now().millisecondsSinceEpoch;
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: _currentMetrics.averageNetworkBitrate,
currentNetworkBitrate: _currentMetrics.currentNetworkBitrate,
bufferDurationMs: duration.inMilliseconds,
segmentDownloadTimeMs: _currentMetrics.segmentDownloadTimeMs,
rebufferCount: _rebufferCount,
startTime: _currentMetrics.startTime,
);
// Auto downgrade quality on rebuffer
qualitySelector.rebufferDetected();
}
/// Get current streaming metrics
StreamingMetrics getMetrics() => _currentMetrics;
/// Get metrics stream for real-time monitoring
Stream<StreamingMetrics> get metricsStream => _metricsController.stream;
/// Get metrics history
List<StreamingMetrics> getMetricsHistory() => List.from(_metrics);
/// Finalize streaming session
void finalize() {
_currentMetrics = StreamingMetrics(
averageNetworkBitrate: _currentMetrics.averageNetworkBitrate,
currentNetworkBitrate: _currentMetrics.currentNetworkBitrate,
bufferDurationMs: _currentMetrics.bufferDurationMs,
segmentDownloadTimeMs: _currentMetrics.segmentDownloadTimeMs,
rebufferCount: _rebufferCount,
startTime: _currentMetrics.startTime,
endTime: DateTime.now(),
);
}
/// Clean up resources
void dispose() {
_metricsTimer.cancel();
_metricsController.close();
}
}
/// Buffer size calculator for adaptive streaming
class BufferCalculator {
static const _minBufferMs = 1000; // 1 second
static const _targetBufferMs = 8000; // 8 seconds
static const _maxBufferMs = 30000; // 30 seconds
/// Calculate optimal buffer size based on bandwidth
static int calculateOptimalBuffer(
int estimatedBandwidthBps,
int bitrateOfCurrentQuality,
) {
// Rule: buffer should be able to hold 8 seconds worth of content
final neededBytes = (bitrateOfCurrentQuality * _targetBufferMs) ~/ 8000;
final availableBytes = (estimatedBandwidthBps * _targetBufferMs) ~/ 8000;
// If we can't sustain the bitrate + have buffer, reduce target
if (availableBytes < neededBytes) {
return _minBufferMs;
}
// Normal case
return _targetBufferMs;
}
/// Calculate recommended segment duration
static Duration calculateSegmentDuration(int estimatedBandwidthBps) {
if (estimatedBandwidthBps < 500000) {
// Very low bandwidth: shorter segments
return const Duration(seconds: 2);
} else if (estimatedBandwidthBps < 2000000) {
// Low bandwidth
return const Duration(seconds: 4);
} else {
// Good bandwidth
return const Duration(seconds: 6);
}
}
/// Calculate maximum playback quality
static QualityLevel selectQualityForBandwidth(
int estimatedBandwidthBps,
) {
final availableProfiles = QualityProfiles.getBalancedProfiles();
return BandwidthDetector.selectBestQuality(
estimatedBandwidthBps,
availableProfiles,
);
}
}
// Riverpod providers
final streamingOptimizerProvider =
StateNotifierProvider<StreamingOptimizerNotifier, StreamingMetrics>((ref) {
final qualitySelector = ref.watch(qualitySelectorProvider);
return StreamingOptimizerNotifier(qualitySelector);
});
final qualitySelectorProvider = Provider<QualitySelector>((ref) {
return QualitySelector(initialQuality: QualityProfiles.hd720p);
});
class StreamingOptimizerNotifier extends StateNotifier<StreamingMetrics> {
final QualitySelector _qualitySelector;
late StreamingOptimizer _optimizer;
StreamingOptimizerNotifier(this._qualitySelector)
: super(
StreamingMetrics(
averageNetworkBitrate: 0,
currentNetworkBitrate: 0,
bufferDurationMs: 0,
segmentDownloadTimeMs: 0,
rebufferCount: 0,
startTime: DateTime.now(),
),
) {
_optimizer = StreamingOptimizer(qualitySelector: _qualitySelector);
// Listen to metrics updates
_optimizer.metricsStream.listen((metrics) {
state = metrics;
});
}
void recordSegmentDownload(int bytes, Duration duration) {
_optimizer.recordSegmentDownload(bytes, duration);
}
void rebufferDetected(Duration duration) {
_optimizer.rebufferDetected(duration);
}
List<StreamingMetrics> getHistory() => _optimizer.getMetricsHistory();
@override
void dispose() {
_optimizer.dispose();
super.dispose();
}
}
+3 -3
View File
@@ -14,8 +14,8 @@ class ThemedLoading extends StatelessWidget {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Color(0xFF0F1014), // Deep Space Dark AppColors.baseLevel0,
Color(0xFF121317), // Stitch background AppColors.background,
], ],
), ),
), ),
@@ -24,7 +24,7 @@ class ThemedLoading extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator( const CircularProgressIndicator(
color: Color(0xFF4b8eff), // AppColors.primaryContainer color: AppColors.primaryContainer,
strokeWidth: 3, strokeWidth: 3,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
-144
View File
@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Apple TV Modern Channel Grid
///
/// Responsive grid layout for channels with:
/// - Flexible column count based on screen size
/// - Smooth animations
/// - Focus-aware spacing
class TvChannelGrid extends StatelessWidget {
final List<Widget> children;
final EdgeInsetsGeometry padding;
final double horizontalSpacing;
final double verticalSpacing;
final ScrollController? scrollController;
final ScrollPhysics physics;
const TvChannelGrid({
super.key,
required this.children,
this.padding = const EdgeInsets.fromLTRB(32, 24, 32, 32),
this.horizontalSpacing = 20,
this.verticalSpacing = 20,
this.scrollController,
this.physics = const AlwaysScrollableScrollPhysics(),
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
// Guard: during first Flutter Web layout pass, size may be 0 or NaN.
// LayoutBuilder will re-trigger build once the real size is known.
if (screenWidth <= 0 || screenWidth.isNaN || screenWidth.isInfinite) {
return const SizedBox.shrink();
}
// Responsive column count
final int columnCount;
if (screenWidth > 1920) {
columnCount = 6;
} else if (screenWidth > 1600) {
columnCount = 5;
} else if (screenWidth > 1280) {
columnCount = 4;
} else if (screenWidth > 960) {
columnCount = 3;
} else {
columnCount = 2;
}
// Calculate horizontal padding safely using resolved EdgeInsets.
final resolvedPadding = padding.resolve(TextDirection.ltr);
final horizontalPadding = resolvedPadding.left + resolvedPadding.right;
// Compute item width, clamped to avoid negative/NaN values.
final totalSpacing = horizontalSpacing * (columnCount - 1);
final availableWidth = screenWidth - horizontalPadding - totalSpacing;
final itemWidth =
(availableWidth / columnCount).clamp(1.0, double.infinity);
return SingleChildScrollView(
controller: scrollController,
physics: physics,
padding: padding,
child: Wrap(
spacing: horizontalSpacing,
runSpacing: verticalSpacing,
children: [
for (int i = 0; i < children.length; i++)
SizedBox(
width: itemWidth,
child: children[i],
),
],
),
);
}
}
/// Apple TV Modern Horizontal Scrollable List
class TvHorizontalList extends StatelessWidget {
final List<Widget> children;
final String? title;
final EdgeInsetsGeometry padding;
final double itemWidth;
final double spacing;
final ScrollController? scrollController;
const TvHorizontalList({
super.key,
required this.children,
this.title,
this.padding = const EdgeInsets.symmetric(vertical: 16),
this.itemWidth = 200,
this.spacing = 16,
this.scrollController,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Title
if (title != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacing32,
vertical: AppTheme.spacing16,
),
child: Text(
title!,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
// Horizontal list
SizedBox(
height: 280,
child: ListView.separated(
controller: scrollController,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(
horizontal: 32,
// Resolve EdgeInsetsGeometry to a concrete EdgeInsets before
// accessing vertical — casting EdgeInsetsGeometry to double throws.
vertical: padding.resolve(TextDirection.ltr).top,
),
itemCount: children.length,
separatorBuilder: (_, __) => SizedBox(width: spacing),
itemBuilder: (context, index) => SizedBox(
width: itemWidth,
child: children[index],
),
),
),
],
);
}
}
+9 -1
View File
@@ -14,6 +14,9 @@ class TvFocusableCard extends StatefulWidget {
final bool autofocus; final bool autofocus;
final double borderRadius; final double borderRadius;
/// Accessibility label announced by screen readers (e.g. channel title).
final String? semanticLabel;
const TvFocusableCard({ const TvFocusableCard({
super.key, super.key,
required this.child, required this.child,
@@ -24,6 +27,7 @@ class TvFocusableCard extends StatefulWidget {
this.focusColor, this.focusColor,
this.autofocus = false, this.autofocus = false,
this.borderRadius = 12.0, this.borderRadius = 12.0,
this.semanticLabel,
}); });
@override @override
@@ -84,7 +88,10 @@ class _TvFocusableCardState extends State<TvFocusableCard>
final isActive = _isFocused || _isHovered; final isActive = _isFocused || _isHovered;
final focusColor = widget.focusColor ?? AppColors.primaryContainer; final focusColor = widget.focusColor ?? AppColors.primaryContainer;
return MouseRegion( return Semantics(
button: widget.onTap != null,
label: widget.semanticLabel,
child: MouseRegion(
onEnter: (_) => _handleHover(true), onEnter: (_) => _handleHover(true),
onExit: (_) => _handleHover(false), onExit: (_) => _handleHover(false),
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -148,6 +155,7 @@ class _TvFocusableCardState extends State<TvFocusableCard>
), ),
), ),
), ),
),
); );
} }
} }
-340
View File
@@ -1,340 +0,0 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
import 'glass_container.dart';
/// Apple TV Modern Content Card
///
/// Premium card for displaying movies, shows, or other content with:
/// - Poster image with skeleton loading
/// - Title and metadata
/// - Rating/score display
/// - Interactive hover states
/// - Context menu support
class TvModernCard extends StatefulWidget {
final String id;
final String title;
final String? imageUrl;
final String? rating;
final String? year;
final String? badge;
final Color? badgeColor;
final VoidCallback? onTap;
final VoidCallback? onPlayTap;
final VoidCallback? onMoreTap;
final bool isWatching;
final double? progress;
const TvModernCard({
super.key,
required this.id,
required this.title,
this.imageUrl,
this.rating,
this.year,
this.badge,
this.badgeColor,
this.onTap,
this.onPlayTap,
this.onMoreTap,
this.isWatching = false,
this.progress,
});
@override
State<TvModernCard> createState() => _TvModernCardState();
}
class _TvModernCardState extends State<TvModernCard>
with SingleTickerProviderStateMixin {
bool _isHovered = false;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: AppTheme.durationMd,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) {
setState(() => _isHovered = true);
_controller.forward();
},
onExit: (_) {
setState(() => _isHovered = false);
_controller.reverse();
},
child: GestureDetector(
onTap: widget.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// ============ POSTER IMAGE ============
AnimatedScale(
scale: _isHovered ? 1.06 : 1.0,
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
child: GlassCard(
borderRadius: AppTheme.radiusLg,
padding: EdgeInsets.zero,
interactive: true,
onTap: widget.onTap,
child: Stack(
fit: StackFit.expand,
children: [
// Poster image
_buildPosterImage(),
// Overlay
if (_isHovered)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
),
),
// Badge
if (widget.badge != null)
Positioned(
top: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: widget.badgeColor ?? AppColors.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
widget.badge!,
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.black,
letterSpacing: 0.5,
),
),
),
),
// Progress indicator (if watching)
if (widget.isWatching && widget.progress != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: 3,
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg),
),
),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(AppTheme.radiusLg),
),
child: LinearProgressIndicator(
value: widget.progress,
backgroundColor: AppColors.surfaceVariant,
valueColor: const AlwaysStoppedAnimation(
AppColors.primary,
),
minHeight: 3,
),
),
),
),
// Action buttons (on hover)
if (_isHovered &&
(widget.onPlayTap != null || widget.onMoreTap != null))
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.onPlayTap != null)
_buildActionButton(
icon: Icons.play_arrow,
label: 'Play',
onTap: widget.onPlayTap!,
),
if (widget.onPlayTap != null &&
widget.onMoreTap != null)
const SizedBox(width: 16),
if (widget.onMoreTap != null)
_buildActionButton(
icon: Icons.info_outline,
label: 'Info',
onTap: widget.onMoreTap!,
),
],
),
),
],
),
),
),
const SizedBox(height: 12),
// ============ METADATA ============
// Title
Text(
widget.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// Rating & Year
if (widget.rating != null || widget.year != null) ...[
const SizedBox(height: 6),
Row(
children: [
if (widget.rating != null) ...[
const Icon(
Icons.star,
size: 14,
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
widget.rating!,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
if (widget.rating != null && widget.year != null)
const SizedBox(width: 12),
if (widget.year != null)
Text(
widget.year!,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
),
],
),
],
],
),
),
);
}
Widget _buildPosterImage() {
if (widget.imageUrl == null || widget.imageUrl!.isEmpty) {
return Container(
color: AppColors.surface,
child: const Center(
child: Icon(
Icons.image_outlined,
color: AppColors.textSecondary,
size: 48,
),
),
);
}
return CachedNetworkImage(
imageUrl: widget.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: AppColors.shimmer,
child: const Center(
child: SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
),
),
errorWidget: (context, url, error) => Container(
color: AppColors.surface,
child: const Center(
child: Icon(
Icons.broken_image_outlined,
color: AppColors.textSecondary,
size: 48,
),
),
),
);
}
Widget _buildActionButton({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.4),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.black, size: 18),
const SizedBox(width: 6),
Text(
label,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
],
),
),
);
}
}
-397
View File
@@ -1,397 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
import 'glass_container.dart';
/// Apple TV Modern Top Navigation Bar
///
/// Premium navigation header with:
/// - Logo/branding
/// - Search integration
/// - User menu
/// - Glassmorphism effect
class TvTopNavBar extends StatefulWidget {
final String title;
final VoidCallback? onSearch;
final VoidCallback? onProfile;
final VoidCallback? onNotifications;
final int notificationCount;
final Widget? leading;
final List<Widget>? actions;
const TvTopNavBar({
super.key,
required this.title,
this.onSearch,
this.onProfile,
this.onNotifications,
this.notificationCount = 0,
this.leading,
this.actions,
});
@override
State<TvTopNavBar> createState() => _TvTopNavBarState();
}
class _TvTopNavBarState extends State<TvTopNavBar> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GlassContainer(
borderRadius: 0,
hasBorder: false,
showShadow: false,
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacing32,
vertical: AppTheme.spacing16,
),
margin: EdgeInsets.zero,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Logo + Title
Row(
children: [
if (widget.leading != null) widget.leading!,
const SizedBox(width: AppTheme.spacing16),
Text(
widget.title,
style: GoogleFonts.outfit(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
letterSpacing: -0.3,
),
),
],
),
// Right: Actions
Row(
children: [
if (widget.onSearch != null) ...[
_buildIconButton(
icon: Icons.search,
onTap: widget.onSearch!,
tooltip: 'Search',
),
const SizedBox(width: AppTheme.spacing16),
],
if (widget.onNotifications != null) ...[
_buildIconButton(
icon: Icons.notifications_none,
onTap: widget.onNotifications!,
tooltip: 'Notifications',
badge: widget.notificationCount > 0
? widget.notificationCount.toString()
: null,
),
const SizedBox(width: AppTheme.spacing16),
],
if (widget.onProfile != null)
_buildIconButton(
icon: Icons.account_circle,
onTap: widget.onProfile!,
tooltip: 'Profile',
),
if (widget.actions != null) ...[
const SizedBox(width: AppTheme.spacing16),
...widget.actions!,
],
],
),
],
),
),
);
}
Widget _buildIconButton({
required IconData icon,
required VoidCallback onTap,
required String tooltip,
String? badge,
}) {
return Tooltip(
message: tooltip,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isHovered ? AppColors.surface : Colors.transparent,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
),
child: Stack(
children: [
Icon(
icon,
color: AppColors.textPrimary,
size: 24,
),
if (badge != null)
Positioned(
top: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: AppColors.error,
shape: BoxShape.circle,
),
child: Text(
badge,
style: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
],
),
),
),
),
);
}
}
/// Apple TV Modern Side Navigation
///
/// Vertical navigation menu with:
/// - Window navigation items
/// - Collapsible menu
/// - Smooth animations
/// - Category grouping
class TvSideNav extends StatefulWidget {
final List<TvNavItem> items;
final int selectedIndex;
final ValueChanged<int> onItemSelected;
final bool expanded;
final VoidCallback? onToggleExpanded;
const TvSideNav({
super.key,
required this.items,
this.selectedIndex = 0,
required this.onItemSelected,
this.expanded = true,
this.onToggleExpanded,
});
@override
State<TvSideNav> createState() => _TvSideNavState();
}
class _TvSideNavState extends State<TvSideNav> {
@override
Widget build(BuildContext context) {
return GlassContainer(
borderRadius: 0,
hasBorder: false,
showShadow: false,
padding: const EdgeInsets.symmetric(vertical: AppTheme.spacing16),
margin: EdgeInsets.zero,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(
widget.items.length,
(index) => _buildNavItem(
item: widget.items[index],
isSelected: index == widget.selectedIndex,
onTap: () => widget.onItemSelected(index),
),
),
),
),
);
}
Widget _buildNavItem({
required TvNavItem item,
required bool isSelected,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacing16,
vertical: AppTheme.spacing8,
),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacing16,
vertical: AppTheme.spacing12,
),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: !isSelected
? Border.all(color: AppColors.border)
: null,
),
child: Row(
children: [
Icon(
item.icon,
color: isSelected ? Colors.black : AppColors.textSecondary,
size: 20,
),
const SizedBox(width: AppTheme.spacing12),
Expanded(
child: Text(
item.label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.black : AppColors.textPrimary,
),
),
),
],
),
),
),
);
}
}
/// Navigation item model
class TvNavItem {
final String label;
final IconData icon;
final String? badge;
TvNavItem({
required this.label,
required this.icon,
this.badge,
});
}
/// Apple TV Modern Floating Action Menu
///
/// Context-aware floating menu with:
/// - Multiple action buttons
/// - Smooth reveal animation
/// - Accessible positioning
class TvFloatingMenu extends StatefulWidget {
final List<TvMenuAction> actions;
final VoidCallback? onClose;
const TvFloatingMenu({
super.key,
required this.actions,
this.onClose,
});
@override
State<TvFloatingMenu> createState() => _TvFloatingMenuState();
}
class _TvFloatingMenuState extends State<TvFloatingMenu>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: AppTheme.durationMd,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _controller,
child: Container(
padding: const EdgeInsets.all(AppTheme.spacing12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusLg),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(
widget.actions.length,
(index) => GestureDetector(
onTap: () {
widget.actions[index].onTap?.call();
widget.onClose?.call();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacing16,
vertical: AppTheme.spacing12,
),
child: Row(
children: [
Icon(
widget.actions[index].icon,
color: widget.actions[index].color ?? AppColors.textPrimary,
),
const SizedBox(width: AppTheme.spacing12),
Text(
widget.actions[index].label,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
],
),
),
),
),
),
),
);
}
}
/// Menu action model
class TvMenuAction {
final String label;
final IconData icon;
final Color? color;
final VoidCallback? onTap;
TvMenuAction({
required this.label,
required this.icon,
this.color,
this.onTap,
});
}
+2 -2
View File
@@ -58,9 +58,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0xFF050505), AppColors.surfaceContainerLowest,
AppColors.background, AppColors.background,
Color(0xFF0A0A0A), AppColors.baseLevel0,
], ],
), ),
), ),
@@ -30,9 +30,6 @@ class LiveChannel {
); );
} }
String getStreamUrl(String dns, String username, String password) {
return '$dns/live/$username/$password/$streamId.ts';
}
} }
class Movie { class Movie {
@@ -75,9 +72,6 @@ class Movie {
); );
} }
String getStreamUrl(String dns, String username, String password) {
return '$dns/movie/$username/$password/$streamId.$containerExtension';
}
} }
class Series { class Series {
@@ -165,9 +159,6 @@ class Episode {
); );
} }
String getStreamUrl(String dns, String username, String password) {
return '$dns/series/$username/$password/$id.${containerExtension ?? 'mkv'}';
}
} }
/// Series info with seasons and episodes /// Series info with seasons and episodes
@@ -1,253 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/models/playlist_config.dart';
import '../models/playlist.dart';
/// Represents a content recommendation
class Recommendation {
final Playlist item;
final RecommendationType type;
final double score;
final String reason;
Recommendation({
required this.item,
required this.type,
this.score = 0.0,
required this.reason,
});
}
enum RecommendationType {
continueWatching,
trending,
recentlyAdded,
similarContent,
topRated,
forYou,
}
class RecommendationService {
/// Get continue watching items based on watch history
static List<Recommendation> getContinueWatching(
Map<String, dynamic> watchHistory,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final historyKey = item.streamId.toString();
if (watchHistory.containsKey(historyKey)) {
final lastPosition = watchHistory[historyKey] as double? ?? 0.0;
// Only show if watched more than 5 seconds ago and less than 95% complete
if (lastPosition > 5 && lastPosition < 95) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.continueWatching,
score: 100.0 - lastPosition, // Score higher if recently started
reason:
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
),
);
}
}
}
// Sort by last watched (highest incomplete progress first)
recommendations.sort((a, b) => b.score.compareTo(a.score));
return recommendations.take(10).toList();
}
/// Get trending content based on view counts
static List<Recommendation> getTrending(
Map<String, int> viewCounts,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
for (final item in allContent) {
final views = viewCounts[item.streamId.toString()] ?? 0;
if (views > 0) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.trending,
score: views.toDouble(),
reason: '$views views - Trending now',
),
);
}
}
recommendations.sort((a, b) => b.score.compareTo(a.score));
return recommendations.take(10).toList();
}
/// Get recently added content
static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) {
final recommendations = <Recommendation>[];
// Sort by added date (most recent first)
final sorted = [...allContent];
sorted.sort((a, b) {
final aDate = DateTime.tryParse(a.added ?? '') ?? DateTime(2000);
final bDate = DateTime.tryParse(b.added ?? '') ?? DateTime(2000);
return bDate.compareTo(aDate);
});
for (final item in sorted.take(20)) {
final daysAgo = DateTime.now()
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
.inDays;
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.recentlyAdded,
score: (20 - daysAgo).toDouble().clamp(0, 100),
reason: 'Added $daysAgo days ago',
),
);
}
return recommendations.take(10).toList();
}
/// Get personalized recommendations based on watch history
static List<Recommendation> getForYou(
Map<String, dynamic> watchHistory,
List<Playlist> allContent,
) {
final recommendations = <Recommendation>[];
// Collect watched categories
final watchedCategories = <String>{};
watchHistory.forEach((key, value) {
if (value is Map && value.containsKey('category')) {
watchedCategories.add(value['category'] as String);
}
});
// Recommend similar content
for (final item in allContent) {
if (watchedCategories.contains(item.categoryId)) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.forYou,
score: 75.0,
reason: 'Based on your interests',
),
);
}
}
// Add highly rated content
var ratedContent = [...allContent];
ratedContent.sort((a, b) {
final aRating = double.tryParse(a.rating ?? '0') ?? 0.0;
final bRating = double.tryParse(b.rating ?? '0') ?? 0.0;
return bRating.compareTo(aRating);
});
for (final item in ratedContent.where((i) {
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
return rating >= 7.5;
}).take(5)) {
recommendations.add(
Recommendation(
item: item,
type: RecommendationType.topRated,
score: double.tryParse(item.rating ?? '0') ?? 0.0,
reason: 'Highly rated - ${item.rating}/10',
),
);
}
// Remove duplicates and limit
final seen = <int>{};
final unique = <Recommendation>[];
for (final rec in recommendations) {
if (!seen.contains(rec.item.streamId)) {
unique.add(rec);
seen.add(rec.item.streamId);
}
}
return unique.take(15).toList();
}
}
/// Provider for watch history
final watchHistoryProvider =
StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
return WatchHistoryNotifier();
});
class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
WatchHistoryNotifier() : super({});
void updateWatchTime(int streamId, double percentage) {
state = {
...state,
streamId.toString(): {
'percentage': percentage,
'timestamp': DateTime.now().toIso8601String(),
},
};
}
void clearHistory() {
state = {};
}
}
/// Provider for view counts/trending
final trendingProvider =
StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
return TrendingNotifier();
});
class TrendingNotifier extends StateNotifier<Map<String, int>> {
TrendingNotifier() : super({});
void incrementViewCount(int streamId) {
final key = streamId.toString();
state = {
...state,
key: (state[key] ?? 0) + 1,
};
}
}
/// Provider for recommendations
final recommendationsProvider = FutureProvider.family<List<Recommendation>,
(Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
(ref, params) async {
final (playlist, watchHistory, trending, allContent) = params;
// Combine all recommendation types
final allRecommendations = <Recommendation>[
...RecommendationService.getContinueWatching(watchHistory, allContent),
...RecommendationService.getTrending(trending, allContent),
...RecommendationService.getRecentlyAdded(allContent),
...RecommendationService.getForYou(watchHistory, allContent),
];
// Deduplicate and score
final seen = <int>{};
final scored = <Recommendation>[];
for (final rec in allRecommendations) {
if (!seen.contains(rec.item.streamId)) {
scored.add(rec);
seen.add(rec.item.streamId);
}
}
// Sort by score
scored.sort((a, b) => b.score.compareTo(a.score));
return scored;
},
);
@@ -54,9 +54,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0xFF050505), // Very dark top AppColors.surfaceContainerLowest, // Very dark top
AppColors.background, // Black bottom AppColors.background, // Black bottom
Color(0xFF0A0A0A), // Slightly darker for depth AppColors.baseLevel0, // Slightly darker for depth
], ],
), ),
), ),
+146 -21
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:html' as html; import 'dart:html' as html;
import 'dart:ui_web' as ui_web; import 'dart:ui_web' as ui_web;
@@ -7,6 +8,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/playback_positions_provider.dart'; import '../providers/playback_positions_provider.dart';
import '../widgets/quality_selector_widget.dart';
// ... (existing imports) // ... (existing imports)
// ... (existing imports) // ... (existing imports)
@@ -63,6 +65,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool _isMuted = false; bool _isMuted = false;
bool _ignoreStatusUpdates = false; bool _ignoreStatusUpdates = false;
/// Server-side transcoding quality. Live TV defaults to `source`
/// (direct MPEG-TS proxy, zero transcoding); VOD defaults to `high`.
late StreamQuality _quality = widget.streamType == StreamType.live
? StreamQuality.source
: StreamQuality.high;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -109,8 +117,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
} catch (_) {} } catch (_) {}
} }
// Stable view ID for the instance to prevent iframe reload on rebuild (orientation change) // Stable view ID for the instance to prevent iframe reload on rebuild
_viewId = 'iptv-player-$currentStreamId-$_viewIdPrefix'; // (orientation change). Includes quality so a quality switch forces a
// fresh iframe.
_viewId = 'iptv-player-$currentStreamId-${_quality.value}-$_viewIdPrefix';
final service = ref.read(xtreamServiceProvider(widget.playlist)); final service = ref.read(xtreamServiceProvider(widget.playlist));
String streamUrl = ''; String streamUrl = '';
@@ -118,14 +128,21 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// Generate Stream URL based on type // Generate Stream URL based on type
// NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/ // NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/
if (widget.streamType == StreamType.live) { if (widget.streamType == StreamType.live) {
streamUrl = service.getLiveStreamUrl(currentStreamId); streamUrl = service.getLiveStreamUrl(
currentStreamId,
quality: _quality.value,
);
} else if (widget.streamType == StreamType.vod) { } else if (widget.streamType == StreamType.vod) {
streamUrl = streamUrl = service.getVodStreamUrl(
service.getVodStreamUrl(currentStreamId, widget.containerExtension); currentStreamId,
widget.containerExtension,
quality: _quality.value,
);
} else if (widget.streamType == StreamType.series) { } else if (widget.streamType == StreamType.series) {
streamUrl = service.getSeriesStreamUrl( streamUrl = service.getSeriesStreamUrl(
currentStreamId, currentStreamId,
widget.containerExtension, widget.containerExtension,
quality: _quality.value,
); );
} else if (widget.streamType == StreamType.recording) { } else if (widget.streamType == StreamType.recording) {
streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8';
@@ -135,8 +152,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
final isMobile = MediaQuery.of(context).size.width < 600; final isMobile = MediaQuery.of(context).size.width < 600;
final isLiveTV = widget.streamType == StreamType.live; final isLiveTV = widget.streamType == StreamType.live;
// TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping // TURBO-START: in `source` quality, Live TV on desktop uses the direct
if (isLiveTV && !isMobile) { // MPEG-TS proxy (mpegts.js) for instant zapping with zero transcoding.
if (isLiveTV && !isMobile && _quality == StreamQuality.source) {
streamUrl = service.getLiveStreamUrlTs(currentStreamId); streamUrl = service.getLiveStreamUrlTs(currentStreamId);
} }
@@ -215,6 +233,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
void _setupMessageListener() { void _setupMessageListener() {
_messageSubscription = html.window.onMessage.listen((event) { _messageSubscription = html.window.onMessage.listen((event) {
// The player iframes are same-origin; drop messages from anywhere else.
if (event.origin != html.window.location.origin) return;
final data = event.data; final data = event.data;
if (data == null) return; if (data == null) return;
@@ -271,8 +291,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
} }
if (iframe != null) { if (iframe != null) {
print('[PlayerScreen] Sending message to iframe: $message'); iframe.contentWindow
iframe.contentWindow?.postMessage(message, '*'); ?.postMessage(message, html.window.location.origin);
} else { } else {
print( print(
'[PlayerScreen] Error: Could not find iframe to send message: $message', '[PlayerScreen] Error: Could not find iframe to send message: $message',
@@ -331,6 +351,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0}); _sendMessage({'type': 'set_volume', 'value': _isMuted ? 0.0 : 1.0});
} }
void _changeQuality(StreamQuality quality) {
if (quality == _quality) return;
setState(() => _quality = quality);
// Rebuild the iframe with the new quality; VOD resumes at the
// current position, live restarts at the live edge.
final resumeAt =
widget.streamType == StreamType.live ? null : _currentPosition;
_initializePlayer(startTimeOverride: resumeAt, isChannelSwitch: true);
}
void _previousChannel() { void _previousChannel() {
if (widget.channels != null && widget.channels!.isNotEmpty) { if (widget.channels != null && widget.channels!.isNotEmpty) {
setState(() { setState(() {
@@ -357,6 +387,68 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
} }
} }
/// Keyboard / TV remote controls:
/// space-enter = play/pause, ←/→ = seek ±10s (VOD) or zap (live),
/// ↑/↓ = zap (live), M = mute, Esc = exit player.
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final isLive = widget.streamType == StreamType.live;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.mediaPlayPause) {
_togglePlayPause();
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape) {
Navigator.pop(context);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.keyM) {
_toggleMute();
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft) {
if (isLive) {
_previousChannel();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition - 10).clamp(0, _totalDuration),
});
}
_onHover();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowRight) {
if (isLive) {
_nextChannel();
} else {
_sendMessage({
'type': 'seek',
'value': (_currentPosition + 10).clamp(0, _totalDuration),
});
}
_onHover();
return KeyEventResult.handled;
}
if (isLive && key == LogicalKeyboardKey.arrowUp) {
_nextChannel();
_onHover();
return KeyEventResult.handled;
}
if (isLive && key == LogicalKeyboardKey.arrowDown) {
_previousChannel();
_onHover();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
String _formatDuration(Duration d) { String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0'); String twoDigits(int n) => n.toString().padLeft(2, '0');
final hours = twoDigits(d.inHours); final hours = twoDigits(d.inHours);
@@ -371,7 +463,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
// LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter) // LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter)
if (isLiveTV) { if (isLiveTV) {
return Scaffold( return Focus(
autofocus: true,
onKeyEvent: _onKeyEvent,
child: Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -425,6 +520,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_buildSimpleIconButton( _buildSimpleIconButton(
icon: Icons.arrow_back_rounded, icon: Icons.arrow_back_rounded,
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
tooltip: 'Retour',
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -554,11 +650,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
], ],
), ),
),
); );
} }
// STANDARD PLAYER MODE (VOD/Series) // STANDARD PLAYER MODE (VOD/Series)
return Scaffold( return Focus(
autofocus: true,
onKeyEvent: _onKeyEvent,
child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -607,6 +707,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_buildGlassIconButton( _buildGlassIconButton(
icon: Icons.arrow_back_rounded, icon: Icons.arrow_back_rounded,
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
tooltip: 'Retour',
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -725,6 +826,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
.clamp(0, _totalDuration), .clamp(0, _totalDuration),
}), }),
transparent: true, transparent: true,
tooltip: 'Reculer de 10 s',
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
_buildGlassIconButton( _buildGlassIconButton(
@@ -734,6 +836,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
onTap: _togglePlayPause, onTap: _togglePlayPause,
size: 56, size: 56,
iconSize: 32, iconSize: 32,
tooltip: _isPlaying ? 'Pause' : 'Lecture',
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
_buildGlassIconButton( _buildGlassIconButton(
@@ -744,20 +847,30 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
.clamp(0, _totalDuration), .clamp(0, _totalDuration),
}), }),
transparent: true, transparent: true,
tooltip: 'Avancer de 10 s',
), ),
const Spacer(), const Spacer(),
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
),
const SizedBox(width: 16),
_buildGlassIconButton( _buildGlassIconButton(
icon: _isMuted icon: _isMuted
? Icons.volume_off_rounded ? Icons.volume_off_rounded
: Icons.volume_up_rounded, : Icons.volume_up_rounded,
onTap: _toggleMute, onTap: _toggleMute,
transparent: true, transparent: true,
tooltip: _isMuted
? 'Activer le son'
: 'Couper le son',
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
_buildGlassIconButton( _buildGlassIconButton(
icon: Icons.aspect_ratio_rounded, icon: Icons.aspect_ratio_rounded,
onTap: _toggleFullscreen, onTap: _toggleFullscreen,
transparent: true, transparent: true,
tooltip: 'Plein écran',
), ),
], ],
), ),
@@ -772,6 +885,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
], ],
), ),
),
); );
} }
@@ -781,16 +895,14 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
bool transparent = false, bool transparent = false,
double size = 48, double size = 48,
double iconSize = 24, double iconSize = 24,
String? tooltip,
}) { }) {
// Button with direct Material/InkWell - PointerInterceptor is on outer container // Button with direct Material/InkWell - PointerInterceptor is on outer container
return Material( final button = Material(
color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1), color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1),
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
onTap: () { onTap: onTap,
print('[PlayerScreen] Button tapped: $icon');
onTap();
},
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Container( child: Container(
width: size, width: size,
@@ -806,6 +918,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
); );
return tooltip != null ? Tooltip(message: tooltip, child: button) : button;
} }
List<Widget> _buildControlButtons(bool isSmallScreen) { List<Widget> _buildControlButtons(bool isSmallScreen) {
@@ -820,6 +933,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
icon: Icons.skip_previous_rounded, icon: Icons.skip_previous_rounded,
onTap: _previousChannel, onTap: _previousChannel,
size: buttonSize, size: buttonSize,
tooltip: 'Chaîne précédente',
), ),
if (widget.channels != null && widget.channels!.length > 1) if (widget.channels != null && widget.channels!.length > 1)
@@ -832,6 +946,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
size: playButtonSize, size: playButtonSize,
iconSize: isSmallScreen ? 24 : 28, iconSize: isSmallScreen ? 24 : 28,
highlighted: true, highlighted: true,
tooltip: _isPlaying ? 'Pause' : 'Lecture',
), ),
if (widget.channels != null && widget.channels!.length > 1) if (widget.channels != null && widget.channels!.length > 1)
@@ -843,6 +958,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
icon: Icons.skip_next_rounded, icon: Icons.skip_next_rounded,
onTap: _nextChannel, onTap: _nextChannel,
size: buttonSize, size: buttonSize,
tooltip: 'Chaîne suivante',
), ),
SizedBox(width: spacing), SizedBox(width: spacing),
@@ -852,6 +968,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
onTap: _toggleMute, onTap: _toggleMute,
size: buttonSize, size: buttonSize,
tooltip: _isMuted ? 'Activer le son' : 'Couper le son',
),
SizedBox(width: spacing),
// Quality selector
QualitySelectorButton(
current: _quality,
onSelected: _changeQuality,
size: buttonSize,
), ),
]; ];
} }
@@ -863,17 +989,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
double size = 48, double size = 48,
double iconSize = 24, double iconSize = 24,
bool highlighted = false, bool highlighted = false,
String? tooltip,
}) { }) {
return Material( final button = Material(
color: highlighted color: highlighted
? AppColors.primary.withOpacity(0.2) ? AppColors.primary.withOpacity(0.2)
: Colors.white.withOpacity(0.1), : Colors.white.withOpacity(0.1),
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
onTap: () { onTap: onTap,
print('[PlayerScreen] Simple button tapped: $icon');
onTap();
},
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
splashColor: AppColors.primary.withOpacity(0.3), splashColor: AppColors.primary.withOpacity(0.3),
highlightColor: AppColors.primary.withOpacity(0.1), highlightColor: AppColors.primary.withOpacity(0.1),
@@ -898,6 +1022,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
), ),
); );
return tooltip != null ? Tooltip(message: tooltip, child: button) : button;
} }
/// Build inline EPG info for the unified control bar /// Build inline EPG info for the unified control bar
@@ -62,9 +62,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0xFF050505), AppColors.surfaceContainerLowest,
AppColors.background, AppColors.background,
Color(0xFF0A0A0A), AppColors.baseLevel0,
], ],
), ),
), ),
+70 -138
View File
@@ -42,6 +42,36 @@ class XtreamService {
); );
_dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions));
// Attach the backend session token: the Xtream gateway
// (/api/xtream-api) requires authentication and injects the IPTV
// credentials server-side.
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
if (kIsWeb) {
final token = html.window.localStorage['auth_token'];
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
},
),
);
}
/// GET on the authenticated Xtream gateway. Credentials are injected by
/// the backend; the client only passes the action and its parameters.
Future<Response<dynamic>> _apiGet(
Map<String, String> params, {
Options? options,
}) {
return _dio.get(
'$_backendBaseUrl/api/xtream-api',
queryParameters: params,
options: options ?? Options(extra: _cacheOptions.toExtra()),
);
} }
String? _manualBackendUrl; String? _manualBackendUrl;
@@ -84,47 +114,45 @@ class XtreamService {
_dio.options.sendTimeout = Duration(seconds: seconds); _dio.options.sendTimeout = Duration(seconds: seconds);
} }
/// Wrap URL with proxy for all external IPTV URLs
String _wrapWithProxy(String url) {
if (url.startsWith('http')) {
return '$_backendBaseUrl/api/xtream/$url';
}
return url;
}
/// Initialize connection with a playlist /// Initialize connection with a playlist
void setPlaylist(PlaylistConfig playlist) { void setPlaylist(PlaylistConfig playlist) {
_currentPlaylist = playlist; _currentPlaylist = playlist;
} }
/// Generate stream URL for live TV (HLS) /// Generate stream URL for live TV (HLS)
String getLiveStreamUrl(String streamId) { /// [quality]: source | high | medium | low (server-side FFmpeg preset)
String getLiveStreamUrl(String streamId, {String quality = 'high'}) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/live/$streamId/playlist.m3u8'; return '$_backendBaseUrl/api/live/$streamId/$quality/playlist.m3u8';
} }
/// Generate stream URL for live TV (Direct MPEG-TS) /// Generate stream URL for live TV (Direct MPEG-TS)
/// Faster zapping on compatible players (mpegts.js) /// Faster zapping on compatible players (mpegts.js)
String getLiveStreamUrlTs(String streamId) { String getLiveStreamUrlTs(String streamId) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
// Direct link to the .ts stream through our proxy // Backend route injects the Xtream credentials server-side
final dns = _currentPlaylist!.dns; return '$_backendBaseUrl/api/live/$streamId.ts';
final user = _currentPlaylist!.username;
final pass = _currentPlaylist!.password;
final url = '$dns/live/$user/$pass/$streamId.ts';
return '$_backendBaseUrl/api/xtream/${Uri.encodeComponent(url)}';
} }
/// Generate stream URL for VOD (movies) /// Generate stream URL for VOD (movies)
String getVodStreamUrl(String streamId, String containerExtension) { /// [quality]: source | high | medium | low (server-side FFmpeg preset)
String getVodStreamUrl(
String streamId,
String containerExtension, {
String quality = 'high',
}) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8'; return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8';
} }
/// Generate stream URL for series episodes /// Generate stream URL for series episodes
String getSeriesStreamUrl(String streamId, String containerExtension) { String getSeriesStreamUrl(
String streamId,
String containerExtension, {
String quality = 'high',
}) {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8?type=series'; return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series';
} }
/// Authenticate and get server info /// Authenticate and get server info
@@ -132,14 +160,7 @@ class XtreamService {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
final response = await _dio.get( final response = await _apiGet({});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
},
options: Options(extra: _cacheOptions.toExtra()),
);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
} catch (e) { } catch (e) {
@@ -152,15 +173,7 @@ class XtreamService {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
final response = await _dio.get( final response = await _apiGet({'action': 'get_live_categories'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -198,13 +211,8 @@ class XtreamService {
// Parallelize categories and streams fetching // Parallelize categories and streams fetching
final results = await Future.wait([ final results = await Future.wait([
_getLiveCategories(), _getLiveCategories(),
_dio.get( _apiGet(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl), {'action': 'get_live_streams'},
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_live_streams',
},
options: Options(extra: options), options: Options(extra: options),
), ),
]); ]);
@@ -279,15 +287,7 @@ class XtreamService {
if (_currentPlaylist == null) return {}; if (_currentPlaylist == null) return {};
try { try {
final response = await _dio.get( final response = await _apiGet({'action': 'get_vod_categories'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -319,13 +319,8 @@ class XtreamService {
// Parallelize VOD categories and streams fetching // Parallelize VOD categories and streams fetching
final results = await Future.wait([ final results = await Future.wait([
_getVodCategories(), _getVodCategories(),
_dio.get( _apiGet(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl), {'action': 'get_vod_streams'},
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: options), options: Options(extra: options),
), ),
]); ]);
@@ -364,15 +359,7 @@ class XtreamService {
if (_currentPlaylist == null) return {}; if (_currentPlaylist == null) return {};
try { try {
final response = await _dio.get( final response = await _apiGet({'action': 'get_series_categories'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_categories',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> categories = response.data as List<dynamic>; final List<dynamic> categories = response.data as List<dynamic>;
final Map<String, String> categoryMap = {}; final Map<String, String> categoryMap = {};
@@ -404,13 +391,8 @@ class XtreamService {
// Parallelize Series categories and streams fetching // Parallelize Series categories and streams fetching
final results = await Future.wait([ final results = await Future.wait([
_getSeriesCategories(), _getSeriesCategories(),
_dio.get( _apiGet(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl), {'action': 'get_series'},
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: options), options: Options(extra: options),
), ),
]); ]);
@@ -455,15 +437,7 @@ class XtreamService {
// Load categories for mapping // Load categories for mapping
final categoryMap = await _getVodCategories(); final categoryMap = await _getVodCategories();
final response = await _dio.get( final response = await _apiGet({'action': 'get_vod_streams'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>; final List<dynamic> allMovies = response.data as List<dynamic>;
@@ -494,15 +468,7 @@ class XtreamService {
try { try {
final categoryMap = await _getVodCategories(); final categoryMap = await _getVodCategories();
final response = await _dio.get( final response = await _apiGet({'action': 'get_vod_streams'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_streams',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allMovies = response.data as List<dynamic>; final List<dynamic> allMovies = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
@@ -537,15 +503,7 @@ class XtreamService {
// Load categories for mapping // Load categories for mapping
final categoryMap = await _getSeriesCategories(); final categoryMap = await _getSeriesCategories();
final response = await _dio.get( final response = await _apiGet({'action': 'get_series'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>; final List<dynamic> allSeries = response.data as List<dynamic>;
@@ -576,15 +534,7 @@ class XtreamService {
try { try {
final categoryMap = await _getSeriesCategories(); final categoryMap = await _getSeriesCategories();
final response = await _dio.get( final response = await _apiGet({'action': 'get_series'});
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series',
},
options: Options(extra: _cacheOptions.toExtra()),
);
final List<dynamic> allSeries = response.data as List<dynamic>; final List<dynamic> allSeries = response.data as List<dynamic>;
final queryLower = query.toLowerCase(); final queryLower = query.toLowerCase();
@@ -613,16 +563,10 @@ class XtreamService {
if (_currentPlaylist == null) throw Exception('No playlist configured'); if (_currentPlaylist == null) throw Exception('No playlist configured');
try { try {
final response = await _dio.get( final response = await _apiGet({
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_series_info', 'action': 'get_series_info',
'series_id': seriesId, 'series_id': seriesId,
}, });
options: Options(extra: _cacheOptions.toExtra()),
);
return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>); return xm.SeriesInfo.fromJson(response.data as Map<String, dynamic>);
} catch (e) { } catch (e) {
@@ -637,16 +581,10 @@ class XtreamService {
if (_currentPlaylist == null) return null; if (_currentPlaylist == null) return null;
try { try {
final response = await _dio.get( final response = await _apiGet({
_wrapWithProxy(_currentPlaylist!.apiBaseUrl),
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_vod_info', 'action': 'get_vod_info',
'vod_id': vodId, 'vod_id': vodId,
}, });
options: Options(extra: _cacheOptions.toExtra()),
);
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
@@ -698,11 +636,8 @@ class XtreamService {
} }
try { try {
final response = await _dio.get( final response = await _apiGet(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl), {
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg', 'action': 'get_short_epg',
'stream_id': streamId, 'stream_id': streamId,
}, },
@@ -740,11 +675,8 @@ class XtreamService {
} }
try { try {
final response = await _dio.get( final response = await _apiGet(
_wrapWithProxy(_currentPlaylist!.apiBaseUrl), {
queryParameters: {
'username': _currentPlaylist!.username,
'password': _currentPlaylist!.password,
'action': 'get_short_epg', 'action': 'get_short_epg',
'stream_id': streamId, 'stream_id': streamId,
}, },
-227
View File
@@ -1,227 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
class ChannelCard extends StatefulWidget {
final String channelNumber;
final String channelName;
final String? channelLogo;
final String? currentProgram;
final String? programTime;
final bool isFavorite;
final VoidCallback? onTap;
final VoidCallback? onFavoriteTap;
final bool isActive;
const ChannelCard({
super.key,
required this.channelNumber,
required this.channelName,
this.channelLogo,
this.currentProgram,
this.programTime,
this.isFavorite = false,
this.onTap,
this.onFavoriteTap,
this.isActive = false,
});
@override
State<ChannelCard> createState() => _ChannelCardState();
}
class _ChannelCardState extends State<ChannelCard>
with SingleTickerProviderStateMixin {
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: AppTheme.durationMd,
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(parent: _pulseController, curve: AppTheme.curveSmooth),
);
if (widget.isActive) {
_pulseController.forward();
}
}
@override
void didUpdateWidget(ChannelCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
_pulseController.forward();
} else if (!widget.isActive && oldWidget.isActive) {
_pulseController.reverse();
}
}
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: widget.isActive
? _pulseAnimation
: const AlwaysStoppedAnimation(1.0),
child: AnimatedContainer(
duration: AppTheme.durationMd,
curve: AppTheme.curveDefault,
decoration: BoxDecoration(
color: widget.isActive
? AppColors.primary.withOpacity(0.15)
: AppColors.surface,
border: Border.all(
color: widget.isActive
? AppColors.primary
: (_isHovered
? AppColors.border
: AppColors.border.withOpacity(0.5)),
width: widget.isActive ? 2.5 : 1.5,
),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
boxShadow: [
if (_isHovered || widget.isActive)
BoxShadow(
color: widget.isActive
? AppColors.primary.withOpacity(0.25)
: AppColors.primary.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
spreadRadius: 1,
),
],
),
child: Stack(
fit: StackFit.expand,
children: [
Padding(
padding: EdgeInsets.all(AppTheme.spacingMd),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channelNumber,
style: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
letterSpacing: 0.5,
),
),
SizedBox(height: AppTheme.spacingXs),
Text(
widget.channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
GestureDetector(
onTap: widget.onFavoriteTap,
child: AnimatedScale(
scale: widget.isFavorite ? 1.1 : 1.0,
duration: AppTheme.durationXs,
child: Icon(
widget.isFavorite
? Icons.favorite
: Icons.favorite_border,
color: widget.isFavorite
? AppColors.secondary
: AppColors.textSecondary,
size: 20,
),
),
),
],
),
SizedBox(height: AppTheme.spacingSm),
if (widget.currentProgram != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.currentProgram!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
if (widget.programTime != null) ...[
SizedBox(height: AppTheme.spacingXs),
Text(
widget.programTime!,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
),
],
],
),
],
),
),
if (widget.isActive)
Positioned(
top: AppTheme.spacingSm,
right: AppTheme.spacingSm,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.6),
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
),
],
),
),
),
),
);
}
}
@@ -1,543 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/recommendations_provider.dart';
import '../../../core/models/playlist_config.dart';
import '../models/playlist.dart';
/// Widget showing "Continue Watching" section
class ContinueWatchingWidget extends ConsumerStatefulWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const ContinueWatchingWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
ConsumerState<ContinueWatchingWidget> createState() =>
_ContinueWatchingWidgetState();
}
class _ContinueWatchingWidgetState
extends ConsumerState<ContinueWatchingWidget> {
@override
Widget build(BuildContext context) {
final watchHistory = ref.watch(watchHistoryProvider);
final continueWatching =
RecommendationService.getContinueWatching(watchHistory, widget.content);
if (continueWatching.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Continue Watching',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// Navigate to full continue watching list
},
child: const Text('View All'),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...continueWatching.take(5).map((rec) {
final item = rec.item;
final percentage =
watchHistory[item.streamId.toString()] is Map
? (watchHistory[item.streamId.toString()]
as Map)['percentage'] as double? ??
0.0
: 0.0;
return Container(
margin: const EdgeInsets.only(right: 12),
child: _ContinueWatchingCard(
item: item,
progress: percentage,
onTap: widget.onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _ContinueWatchingCard extends StatelessWidget {
final Playlist item;
final double progress;
final VoidCallback? onTap;
const _ContinueWatchingCard({
required this.item,
required this.progress,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster with progress bar
Container(
width: 160,
height: 240,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[800],
),
child: Stack(
fit: StackFit.expand,
children: [
// Poster image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
// Dark overlay at bottom
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.8),
],
),
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress / 100.0,
minHeight: 3,
backgroundColor: Colors.white30,
valueColor: AlwaysStoppedAnimation<Color>(
_getProgressColor(progress),
),
),
),
const SizedBox(height: 4),
Text(
'${progress.toStringAsFixed(0)}% watched',
style: const TextStyle(
color: Colors.white,
fontSize: 11,
),
),
],
),
),
),
// Play button overlay on hover
Positioned(
top: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.7),
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.all(8),
child: const Icon(
Icons.play_arrow,
color: Colors.white,
size: 24,
),
),
),
],
),
),
const SizedBox(height: 8),
// Title
SizedBox(
width: 160,
child: Text(
item.name ?? 'Unknown',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Color _getProgressColor(double progress) {
if (progress < 30) return Colors.red;
if (progress < 70) return Colors.orange;
return Colors.green;
}
}
/// Widget showing trending/popular content
class TrendingWidget extends ConsumerWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const TrendingWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trending = ref.watch(trendingProvider);
final trendingContent =
RecommendationService.getTrending(trending, content);
if (trendingContent.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(
Icons.local_fire_department,
color: Colors.red,
size: 24,
),
SizedBox(width: 8),
Text(
'Trending Now',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
TextButton(
onPressed: () {},
child: const Text('View All'),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...trendingContent.take(5).map((rec) {
final item = rec.item;
final rank = trendingContent.indexOf(rec) + 1;
return Container(
margin: const EdgeInsets.only(right: 12),
child: _TrendingCard(
item: item,
rank: rank,
onTap: onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _TrendingCard extends StatelessWidget {
final Playlist item;
final int rank;
final VoidCallback? onTap;
const _TrendingCard({
required this.item,
required this.rank,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
children: [
// Card
Container(
width: 180,
height: 260,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey[800],
),
child: Column(
children: [
// Image
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) => Icon(
Icons.image_not_supported,
color: Colors.grey[600],
),
),
),
),
// Info
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? 'Unknown',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
if (item.rating != null)
Row(
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 12,
),
const SizedBox(width: 4),
Text(
item.rating ?? '0',
style: const TextStyle(fontSize: 11),
),
],
),
],
),
),
],
),
),
// Rank badge
Positioned(
top: 8,
left: 8,
child: Container(
decoration: BoxDecoration(
color: _getRankColor(rank),
shape: BoxShape.circle,
),
width: 40,
height: 40,
child: Center(
child: Text(
'#$rank',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
),
],
),
);
}
Color _getRankColor(int rank) {
switch (rank) {
case 1:
return Colors.amber.shade700;
case 2:
return Colors.grey.shade600;
case 3:
return Colors.orange.shade700;
default:
return Colors.blue.shade700;
}
}
}
/// Widget showing recently added content
class RecentlyAddedWidget extends ConsumerWidget {
final Playlist playlist;
final List<Playlist> content;
final VoidCallback? onItemTap;
const RecentlyAddedWidget({
super.key,
required this.playlist,
required this.content,
this.onItemTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recent = RecommendationService.getRecentlyAdded(content);
if (recent.isEmpty) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Recently Added',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
const SizedBox(width: 16),
...recent.take(5).map((rec) {
return Container(
margin: const EdgeInsets.only(right: 12),
child: _RecentCard(
item: rec.item,
onTap: onItemTap,
),
);
}),
const SizedBox(width: 8),
],
),
),
],
),
);
}
}
class _RecentCard extends StatelessWidget {
final Playlist item;
final VoidCallback? onTap;
const _RecentCard({
required this.item,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.grey[800],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.cover ?? '',
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[800],
),
errorWidget: (context, url, error) =>
Icon(Icons.image_not_supported, color: Colors.grey[600]),
),
),
),
);
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ class EpgOverlay extends ConsumerWidget {
margin: const EdgeInsets.fromLTRB(0, 0, 24, 0), margin: const EdgeInsets.fromLTRB(0, 0, 24, 0),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A2E) color: AppColors.surfaceContainerLow
.withOpacity(0.85), // Match Native player controls .withOpacity(0.85), // Match Native player controls
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withOpacity(0.1)), border: Border.all(color: Colors.white.withOpacity(0.1)),
+2 -1
View File
@@ -371,6 +371,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return TvFocusableCard( return TvFocusableCard(
onTap: () => _playChannel(channel, channels), onTap: () => _playChannel(channel, channels),
borderRadius: 12, borderRadius: 12,
semanticLabel: 'Chaîne ${channel.name}',
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.surfaceVariant, color: AppColors.surfaceVariant,
@@ -481,7 +482,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return Text( return Text(
currentProgram.title, currentProgram.title,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: const Color(0xFFFFD700), color: AppColors.ratingGold,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
+2 -1
View File
@@ -341,6 +341,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
return TvFocusableCard( return TvFocusableCard(
onTap: () => _playMovie(movie), onTap: () => _playMovie(movie),
borderRadius: 12, borderRadius: 12,
semanticLabel: 'Film ${movie.name}',
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -420,7 +421,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
const Icon( const Icon(
Icons.star, Icons.star,
size: 10, size: 10,
color: Color(0xFFFFD700), color: AppColors.ratingGold,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -1,253 +1,107 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../core/services/adaptive_bitrate_service.dart'; import '../../../core/theme/app_colors.dart';
/// Widget for displaying and selecting video quality during playback /// Stream quality presets handled server-side by FFmpeg
class QualitySelectorWidget extends ConsumerStatefulWidget { /// (see bin/api/streaming_handler.dart).
final QualitySelector qualitySelector; enum StreamQuality {
final VoidCallback onClose; source('source', 'Source', 'Flux direct, zéro transcodage'),
high('high', 'Haute', '1080p · 6-8 Mbps'),
medium('medium', 'Moyenne', '3 Mbps'),
low('low', 'Basse', '720p · 1.5 Mbps');
const QualitySelectorWidget({ const StreamQuality(this.value, this.label, this.description);
super.key,
required this.qualitySelector,
required this.onClose,
});
@override final String value;
ConsumerState<QualitySelectorWidget> createState() => final String label;
_QualitySelectorWidgetState(); final String description;
} }
class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> { /// Popup button to switch the transcoding quality of the current stream.
late List<QualityLevel> _availableQualities; class QualitySelectorButton extends StatelessWidget {
late QualityLevel _selectedQuality; final StreamQuality current;
final ValueChanged<StreamQuality> onSelected;
final double size;
@override const QualitySelectorButton({
void initState() { super.key,
super.initState(); required this.current,
_availableQualities = QualityProfiles.getBalancedProfiles(); required this.onSelected,
_selectedQuality = widget.qualitySelector.currentQuality; this.size = 44,
} });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final currentQuality = widget.qualitySelector.currentQuality; return Tooltip(
final bandwidthMbps = widget.qualitySelector.getEstimatedBandwidthMbps(); message: 'Qualité : ${current.label}',
child: PopupMenuButton<StreamQuality>(
return Dialog( initialValue: current,
backgroundColor: Colors.black87, color: AppColors.surfaceContainerHigh,
insetPadding: const EdgeInsets.all(16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
onSelected: onSelected,
itemBuilder: (context) => StreamQuality.values
.map(
(q) => PopupMenuItem<StreamQuality>(
value: q,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
q == current
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 18,
color: q == current
? AppColors.primary
: AppColors.textTertiary,
),
const SizedBox(width: 12),
Flexible(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white24),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Video Quality',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: widget.onClose,
),
],
),
),
// Network Info
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white10,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Current Bandwidth:',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
Text( Text(
'${bandwidthMbps.toStringAsFixed(1)} Mbps', q.label,
style: const TextStyle( overflow: TextOverflow.ellipsis,
color: Colors.white, style: GoogleFonts.inter(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold,
), ),
), ),
],
),
),
),
// Quality Options
ListView.builder(
shrinkWrap: true,
itemCount: _availableQualities.length,
itemBuilder: (context, index) {
final quality = _availableQualities[index];
final isSelected = quality == currentQuality;
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue)
: const Icon(
Icons.radio_button_unchecked,
color: Colors.white30,
),
title: Text(
quality.label,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
'${quality.bitrateBps ~/ 1000000} Mbps',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
onTap: () {
widget.qualitySelector.setQuality(quality);
setState(() {
_selectedQuality = quality;
});
},
);
},
),
// Auto Quality Option
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Colors.white24),
),
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
leading: const Icon(Icons.auto_awesome, color: Colors.amber),
title: const Text(
'Auto Quality',
style: TextStyle(
color: Colors.amber,
fontWeight: FontWeight.bold,
),
),
subtitle: const Text(
'Adjusts based on network speed',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
onTap: () {
// Auto mode would be handled by the player
widget.onClose();
},
),
),
),
const SizedBox(height: 8),
],
),
);
}
}
/// Floating quality indicator (minimal UI during playback)
class QualityIndicator extends ConsumerWidget {
final QualitySelector qualitySelector;
final VoidCallback onTap;
const QualityIndicator({
super.key,
required this.qualitySelector,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final quality = qualitySelector.currentQuality;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.high_quality, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text( Text(
quality.resolution.split('x')[1], // Show just the height q.description,
style: const TextStyle( overflow: TextOverflow.ellipsis,
color: Colors.white, style: GoogleFonts.inter(
fontSize: 12, color: AppColors.textTertiary,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
/// Bandwidth monitor widget (for debugging/info)
class BandwidthMonitor extends ConsumerWidget {
final QualitySelector qualitySelector;
const BandwidthMonitor({
super.key,
required this.qualitySelector,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.speed, color: Colors.green, size: 14),
const SizedBox(width: 4),
Text(
'${qualitySelector.getEstimatedBandwidthMbps().toStringAsFixed(1)} Mbps',
style: const TextStyle(
color: Colors.white,
fontSize: 11, fontSize: 11,
), ),
), ),
], ],
), ),
),
],
),
),
)
.toList(),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.2)),
),
child: const Icon(
Icons.high_quality_rounded,
color: Colors.white,
size: 22,
),
),
),
); );
} }
} }
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../screens/player_screen.dart'; import '../screens/player_screen.dart';
@@ -363,7 +364,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
const Icon( const Icon(
Icons.tv_off, Icons.tv_off,
size: 64, size: 64,
color: Color(0x1FFFFFFF), color: AppColors.glassLevel1Border,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -512,7 +513,7 @@ class _ProgrammeCard extends StatelessWidget {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: AppColors.surfaceContainer,
title: Row( title: Row(
children: [ children: [
const Icon( const Icon(
@@ -823,7 +824,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: AppColors.surfaceContainer,
title: Text( title: Text(
title, title,
style: GoogleFonts.outfit(color: Colors.white, fontSize: 14), style: GoogleFonts.outfit(color: Colors.white, fontSize: 14),
@@ -918,7 +919,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
const Icon( const Icon(
Icons.videocam_off, Icons.videocam_off,
size: 64, size: 64,
color: Color(0x1FFFFFFF), color: AppColors.glassLevel1Border,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -1124,7 +1125,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: AppColors.surfaceContainer,
title: Row( title: Row(
children: [ children: [
const Icon(Icons.repeat, color: Colors.purpleAccent), const Icon(Icons.repeat, color: Colors.purpleAccent),
@@ -1282,7 +1283,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
const Icon( const Icon(
Icons.repeat, Icons.repeat,
size: 64, size: 64,
color: Color(0x1FFFFFFF), color: AppColors.glassLevel1Border,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
+2 -1
View File
@@ -333,6 +333,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
return TvFocusableCard( return TvFocusableCard(
onTap: () => _openSeries(serie), onTap: () => _openSeries(serie),
borderRadius: 12, borderRadius: 12,
semanticLabel: 'Série ${serie.name}',
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -434,7 +435,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
const Icon( const Icon(
Icons.star, Icons.star,
size: 10, size: 10,
color: Color(0xFFFFD700), color: AppColors.ratingGold,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
+1 -1
View File
@@ -482,7 +482,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: AppColors.surfaceContainer,
title: const Text( title: const Text(
'Vider le cache ?', 'Vider le cache ?',
style: TextStyle(color: AppColors.onSurface), style: TextStyle(color: AppColors.onSurface),
@@ -287,7 +287,7 @@ class StreamingSettingsTab extends ConsumerWidget {
trailing: DropdownButton<T>( trailing: DropdownButton<T>(
value: value, value: value,
underline: const SizedBox(), underline: const SizedBox(),
dropdownColor: const Color(0xFF1C1C1E), // Dark background for dropdown dropdownColor: AppColors.surfaceContainer, // Dark background for dropdown
items: items.map((item) { items: items.map((item) {
return DropdownMenuItem<T>( return DropdownMenuItem<T>(
value: item, value: item,
+16 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart'; import 'core/theme/app_theme.dart';
@@ -24,7 +25,21 @@ class MyApp extends ConsumerWidget {
darkTheme: AppTheme.darkTheme, darkTheme: AppTheme.darkTheme,
themeMode: themeState.themeMode, themeMode: themeState.themeMode,
routerConfig: router, routerConfig: router,
// DPAD / keyboard navigation (TV remotes, keyboards): arrow keys move
// focus between focusable widgets, Enter/Select activates.
shortcuts: {
...WidgetsApp.defaultShortcuts,
const SingleActivator(LogicalKeyboardKey.arrowUp):
const DirectionalFocusIntent(TraversalDirection.up),
const SingleActivator(LogicalKeyboardKey.arrowDown):
const DirectionalFocusIntent(TraversalDirection.down),
const SingleActivator(LogicalKeyboardKey.arrowLeft):
const DirectionalFocusIntent(TraversalDirection.left),
const SingleActivator(LogicalKeyboardKey.arrowRight):
const DirectionalFocusIntent(TraversalDirection.right),
const SingleActivator(LogicalKeyboardKey.select):
const ActivateIntent(),
},
); );
} }
} }
@@ -68,9 +68,9 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Color(0xFF000000), // Deep Black AppColors.baseLevel0,
Color(0xFF0F0F12), // Subtle Gray AppColors.surfaceContainerLowest,
Color(0xFF000000), // Back to Black AppColors.baseLevel0,
], ],
), ),
), ),
+24 -21
View File
@@ -1,30 +1,33 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:xtremflow/main.dart'; import 'package:xtremflow/features/iptv/widgets/quality_selector_widget.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('QualitySelectorButton shows all presets when opened',
// Build our app and trigger a frame. (tester) async {
await tester.pumpWidget(const MyApp()); StreamQuality? selected;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: QualitySelectorButton(
current: StreamQuality.high,
onSelected: (q) => selected = q,
),
),
),
);
// Verify that our counter starts at 0. await tester.tap(find.byType(QualitySelectorButton));
expect(find.text('0'), findsOneWidget); await tester.pumpAndSettle();
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame. expect(find.text('Source'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add)); expect(find.text('Haute'), findsOneWidget);
await tester.pump(); expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Basse'), findsOneWidget);
// Verify that our counter has incremented. await tester.tap(find.text('Basse'));
expect(find.text('0'), findsNothing); await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget); expect(selected, StreamQuality.low);
}); });
} }
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more