diff --git a/.agents/skills/source-command-bmad-core-tasks-index-docs/SKILL.md b/.agents/skills/source-command-bmad-core-tasks-index-docs/SKILL.md new file mode 100644 index 0000000..0515a7f --- /dev/null +++ b/.agents/skills/source-command-bmad-core-tasks-index-docs/SKILL.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b2bcc8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b7cb634 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ + +# 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 # 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 # 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 + + + + + + + + + +
+ + + +
+ +
+
+
+search + +
+
+
+ + +
+ +
+
+ +
+ +
+
+

System Administration

+

Monitor server performance, manage user access levels, and configure global environment variables for the XtremFlow network.

+
+
+ + +
+
+ +
+ +
+
+
+
+

Core Processing

+
78%
+
+
+memory +
+
+
+
+Load Average: 2.14, 2.05, 1.98 +Stable +
+
+
+
+
+
+
+
+ +
+
+
+
+

Active Memory

+
42GB
+
+
+dns +
+
+
+
+64 GB Total Capacity +65% Utilized +
+
+
+
+
+
+ +
+
+swap_vert +

Network I/O

+
+
+
+
+south_west +Inbound Traffic +
+2.4 GB/s +
+
+
+north_east +Outbound Traffic +
+8.1 GB/s +
+
+
+
+ +
+ +
+
+
+

Active Personnel

+

Manage admin and moderator access levels.

+
+
+ +12 Online +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
User ProfileRole LevelLast AccessActions
+
+Sarah Connor +
+
Sarah Connor
+
s.connor@xtremflow.io
+
+
+
+Super Admin + + 2 mins ago
192.168.1.104 +
+
+ + +
+
+
+
JD
+
+
John Doe
+
j.doe@xtremflow.io
+
+
+
+Moderator + + 1 hour ago
10.0.0.42 +
+
+ + +
+
+
+Michael Chen +
+
Michael Chen
+
m.chen@xtremflow.io
+
+
+
+Viewer + + Yesterday
172.16.0.5 +
+
+ + +
+
+
+
+ +
+
+ +
+
+tune +

Environment Config

+
+
+
+ + +
+
+ +
+ +connections +
+
+
+
+
Auto-Scaling
+
Deploy additional nodes on high load
+
+ +
+
+ + +
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/Theme/admin_panel_xtremflow_redesign/screen.png b/Theme/admin_panel_xtremflow_redesign/screen.png new file mode 100644 index 0000000..900ea69 Binary files /dev/null and b/Theme/admin_panel_xtremflow_redesign/screen.png differ diff --git a/Theme/cyber_cinematic_glass/DESIGN.md b/Theme/cyber_cinematic_glass/DESIGN.md new file mode 100644 index 0000000..f43b7b6 --- /dev/null +++ b/Theme/cyber_cinematic_glass/DESIGN.md @@ -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. \ No newline at end of file diff --git a/Theme/live_tv_xtremflow_redesign/code.html b/Theme/live_tv_xtremflow_redesign/code.html new file mode 100644 index 0000000..d0c5629 --- /dev/null +++ b/Theme/live_tv_xtremflow_redesign/code.html @@ -0,0 +1,258 @@ + + + + + +Live TV Categories - AuraStream + + + + + + + + + + + +
+ +
+ +
+
+

Live TV Categories

+

Select a category to browse channels

+
+
+
+search + +
+
+
+ + +
+ \ No newline at end of file diff --git a/Theme/live_tv_xtremflow_redesign/screen.png b/Theme/live_tv_xtremflow_redesign/screen.png new file mode 100644 index 0000000..9be760d Binary files /dev/null and b/Theme/live_tv_xtremflow_redesign/screen.png differ diff --git a/Theme/login_xtremflow_redesign/code.html b/Theme/login_xtremflow_redesign/code.html new file mode 100644 index 0000000..c15ad69 --- /dev/null +++ b/Theme/login_xtremflow_redesign/code.html @@ -0,0 +1,177 @@ + + + + + +XtremFlow - Login + + + + + + + + + + + +
+
+
+ +
+
+ +
+ +
+ +
+
+ +
+
+hub +
+

XtremFlow

+

System Access

+
+ +
+ +
+ +
+person + +
+
+ +
+
+ +Forgot? +
+
+lock + + +
+
+ +
+ +
+
+ +
+

+ Require access? Contact Administrator +

+
+
+
+ \ No newline at end of file diff --git a/Theme/login_xtremflow_redesign/screen.png b/Theme/login_xtremflow_redesign/screen.png new file mode 100644 index 0000000..9695127 Binary files /dev/null and b/Theme/login_xtremflow_redesign/screen.png differ diff --git a/Theme/movies_xtremflow_redesign/code.html b/Theme/movies_xtremflow_redesign/code.html new file mode 100644 index 0000000..00d35dc --- /dev/null +++ b/Theme/movies_xtremflow_redesign/code.html @@ -0,0 +1,309 @@ + + + + + +AuraStream - Movies + + + + + + + + + + + + + +
+ +
+
+
+Sci-Fi Thriller +star 4.9 +PG-13 +2h 15m +
+

The Mother and the Bear (2026)

+

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.

+
+ + +
+
+
+ +
+
+

Trending Now

+
+ + +
+
+
+ +
+Movie Poster +
+
+

Quantum Paradox

+
+star 4.8 +Sci-Fi +2025 +
+
+
+ +
+Movie Poster +
+

Neon Rain

+
Action • 4.5
+
+
+ +
+Movie Poster +
+

Beyond Horizon

+
Adventure • 4.7
+
+
+ +
+Movie Poster +
+

The Artifact

+
Mystery • 4.3
+
+
+ +
+Movie Poster +
+

Echoes of Void

+
Thriller • 4.6
+
+
+
+
+ +
+

Continue Watching

+
+ +
+
+Scene +
+play_circle +
+ +
+
+
+
+
+

Director's Cut

+
1h 12m remaining
+
+
+ +
+
+Scene +
+play_circle +
+ +
+
+
+
+
+

The Final Act

+
2h 05m remaining
+
+
+ +
+
+Scene +
+play_circle +
+ +
+
+
+
+
+

Silent Shadows

+
15m remaining
+
+
+
+
+
+ \ No newline at end of file diff --git a/Theme/movies_xtremflow_redesign/screen.png b/Theme/movies_xtremflow_redesign/screen.png new file mode 100644 index 0000000..284942d Binary files /dev/null and b/Theme/movies_xtremflow_redesign/screen.png differ diff --git a/Theme/recordings_xtremflow_redesign/code.html b/Theme/recordings_xtremflow_redesign/code.html new file mode 100644 index 0000000..fd8b22b --- /dev/null +++ b/Theme/recordings_xtremflow_redesign/code.html @@ -0,0 +1,292 @@ + + + + + +Enregistrements - XtremFlow + + + + + + + + + + + +
+
+
+
+

Enregistrements

+

Gérez vos programmes sportifs enregistrés.

+
+
+ + +
+
+
+
+
+ +
+
+
+Football Pitch +sports_soccer +
+
+

Ligue 1: PSG vs OM

+
+calendar_today 12 Oct 2023 +• +schedule 21:00 (120 min) +• +BEIN SPORTS 1 +
+
+
+
+
+
+ Terminé +
+
+ + + +
+
+
+ +
+
+
+Basketball Court +sports_basketball +
+
+

NBA Finals: Game 6

+
+calendar_today 10 Oct 2023 +• +schedule 03:00 (180 min) +• +BEIN SPORTS 2 +
+
+
+
+
+
+ Échoué +
+
+ + + +
+
+
+ +
+
+
+Tennis Ball +sports_tennis +
+
+

Roland Garros: Finale H

+
+calendar_today 05 Oct 2023 +• +schedule 15:00 (240 min) +• +BEIN SPORTS 1 +
+
+
+
+
+
+ Terminé +
+
+ + + +
+
+
+
+
+
+ \ No newline at end of file diff --git a/Theme/recordings_xtremflow_redesign/screen.png b/Theme/recordings_xtremflow_redesign/screen.png new file mode 100644 index 0000000..0e8383a Binary files /dev/null and b/Theme/recordings_xtremflow_redesign/screen.png differ diff --git a/Theme/series_xtremflow_redesign/code.html b/Theme/series_xtremflow_redesign/code.html new file mode 100644 index 0000000..a4e996f --- /dev/null +++ b/Theme/series_xtremflow_redesign/code.html @@ -0,0 +1,394 @@ + + + + + +XtremFlow - TV Series + + + + + + + + + + +
+ +
+ +
+L'affaire Moloch Background + +
+
+
+ +
+ +
+ +
+ +
+NEW EPISODE +THRILLER +4K HDR +
+

L'affaire Moloch

+

+ 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. +

+
+ + + + +
+
+
+ +
+

+history + Continue Watching +

+
+ +
+
+Stranger Things +
+ +
+
+play_arrow +
+
+
+
+

Stranger Things

+

S4:E8 • "Papa"

+ +
+
+
+
+
+ +
+
+The Crown +
+
+
+play_arrow +
+
+
+
+

The Crown

+

S5:E2 • "The System"

+
+
+
+
+
+
+
+ +
+
+

+local_fire_department + Trending Now +

+ +
+
+ +
+Reykjavik Fusion + +
+star + 9.2 +
+ +
+

Reykjavik Fusion

+

Crime • 2 Seasons

+
+
+ +
+Neon Genesis +
+star + 8.8 +
+
+

Neon Genesis

+

Sci-Fi • 1 Season

+
+
+ +
+The Archive +
+star + 9.5 +
+
+

The Archive

+

Fantasy • 4 Seasons

+
+
+ +
+Apex Protocol +
+star + 7.9 +
+
+

Apex Protocol

+

Action • 1 Season

+
+
+ + + + +
+
+
+ \ No newline at end of file diff --git a/Theme/series_xtremflow_redesign/screen.png b/Theme/series_xtremflow_redesign/screen.png new file mode 100644 index 0000000..ff43f94 Binary files /dev/null and b/Theme/series_xtremflow_redesign/screen.png differ diff --git a/Theme/settings_xtremflow_redesign/code.html b/Theme/settings_xtremflow_redesign/code.html new file mode 100644 index 0000000..b4b0b7f --- /dev/null +++ b/Theme/settings_xtremflow_redesign/code.html @@ -0,0 +1,323 @@ + + + + + +Paramètres - XtremFlow + + + + + + + + + + + +
+ +
+
+

Paramètres

+

Configuration de votre expérience XtremFlow.

+
+
+ + + + +
+
+ +
+ +
+
+
+live_tv +
+

Filtre TV Live

+
+
+
+ +
+search + +
+
+
+ +
+
+FRANCE +close +
+
+SPORT +close +
+
+DOCUMENTAIRE +close +
+
+MUSIQUE +close +
+
+
+
+
+ +
+
+
+movie +
+

Filtre Films

+
+
+
+ +
+search + +
+
+
+ +
+
+VF +close +
+
+ACTION +close +
+
+SCI-FI +close +
+
+
+
+
+ +
+
+
+theaters +
+

Filtre Séries

+
+
+
+ +
+search + +
+
+
+ +
+
+NETFLIX +close +
+
+VOSTFR +close +
+
+DRAME +close +
+
+HBO +close +
+
+
+
+
+
+ +
+ + +
+
+ \ No newline at end of file diff --git a/Theme/settings_xtremflow_redesign/screen.png b/Theme/settings_xtremflow_redesign/screen.png new file mode 100644 index 0000000..599a011 Binary files /dev/null and b/Theme/settings_xtremflow_redesign/screen.png differ diff --git a/bin/api/auth_handler.dart b/bin/api/auth_handler.dart index f581784..38855b5 100644 --- a/bin/api/auth_handler.dart +++ b/bin/api/auth_handler.dart @@ -23,8 +23,7 @@ class AuthHandler { try { print('[Auth] Login attempt received'); final bodyStr = await request.readAsString(); - print('[Auth] Request body: $bodyStr'); - + final payload = jsonDecode(bodyStr) as Map; final username = payload['username'] as String?; final password = payload['password'] as String?; @@ -51,13 +50,19 @@ class AuthHandler { print('[Auth] User verified, creating session for userId: ${user.id}'); // Create session 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({ 'success': true, 'user': user.toJson(), '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) { print('[Auth] ERROR during login: $e'); print('[Auth] Stack trace: $stackTrace'); @@ -83,7 +88,10 @@ class AuthHandler { return Response.ok(jsonEncode({ 'success': true, - }), headers: {'Content-Type': 'application/json'},); + }), headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': 'session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0', + },); } catch (e) { return Response.internalServerError( body: jsonEncode({'success': false, 'error': e.toString()}), diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index 1013b6e..b7630b2 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -1,19 +1,17 @@ import 'dart:convert'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; -import '../database/database.dart'; import '../models/playlist_config.dart'; /// API EPG — proxy vers Xtream avec cache 30 minutes /// GET /api/epg/?days=1 class EpgApi { - final AppDatabase _db; final Future Function(Request) _getPlaylist; // Cache simple en mémoire : channelId → {data, expiresAt} final Map _cache = {}; - EpgApi(this._db, this._getPlaylist); + EpgApi(this._getPlaylist); Future handleGetEpg(Request request, String channelId) async { // Vérifier le cache diff --git a/bin/api/playlists_handler.dart b/bin/api/playlists_handler.dart index 5575b47..7ccd9cf 100644 --- a/bin/api/playlists_handler.dart +++ b/bin/api/playlists_handler.dart @@ -120,7 +120,12 @@ class PlaylistsHandler { final name = payload['name'] as String? ?? existing.name; final serverUrl = payload['serverUrl'] as String? ?? existing.serverUrl; 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 playlist = db.updatePlaylist( diff --git a/bin/api/proxy_handler.dart b/bin/api/proxy_handler.dart index 693fccb..b6f2c6a 100644 --- a/bin/api/proxy_handler.dart +++ b/bin/api/proxy_handler.dart @@ -1,14 +1,36 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:shelf/shelf.dart'; import 'package:http/http.dart' as http; 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 class ProxyHandler { final Future Function(Request) _getPlaylist; - final AppDatabase _db; final http.Client _client = http.Client(); final Map _playlistCache = {}; @@ -48,30 +70,7 @@ class ProxyHandler { return playlist; } - ProxyHandler(this._getPlaylist, this._db); - - /// 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; - } + ProxyHandler(this._getPlaylist); /// Create Xtream proxy handler with M3U8 URL rewriting support Handler get handler { @@ -118,6 +117,17 @@ class ProxyHandler { 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 // Xtream providers often use separate CDN servers for picons/images final isStaticAsset = targetUrl.path.endsWith('.png') || @@ -164,7 +174,7 @@ class ProxyHandler { } try { - print('[Proxy] Forwarding to: $targetUrl'); + print('[Proxy] Forwarding to: ${LogRedactor.redactUrl(targetUrl.toString())}'); final proxyRequest = http.Request(request.method, targetUrl); // Forward safe request headers diff --git a/bin/api/recordings_api.dart b/bin/api/recordings_api.dart index 900700f..f9ee9ba 100644 --- a/bin/api/recordings_api.dart +++ b/bin/api/recordings_api.dart @@ -1,9 +1,11 @@ import 'dart:io'; import 'dart:convert'; +import 'package:path/path.dart' as p; import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; import '../database/database.dart'; +import '../models/user.dart'; import '../services/recording_scheduler.dart'; +import '../utils/safe_path.dart'; class RecordingsApi { final AppDatabase _db; @@ -30,12 +32,24 @@ class RecordingsApi { ); } - final logFilePath = recording.filePath!.replaceAll('.mp4', '.log'); - final logFile = File(logFilePath); + // Les enregistrements sont écrits en .mkv avec un .log à côté + // (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()) { 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'}, ); } @@ -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) { - 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( json.encode(recordings.map((r) => r.toMap()).toList()), headers: {'Content-Type': 'application/json'}, @@ -62,8 +80,9 @@ class RecordingsApi { final payload = await request.readAsString(); final data = json.decode(payload); + final userId = request.context['userId'] as String? ?? 'dev_user_id'; final recording = _db.createRecording( - userId: 'dev_user_id', + userId: userId, channelId: data['channel_id'], streamUrl: data['stream_url'], title: data['title'] ?? 'Sans Titre', diff --git a/bin/api/streaming_handler.dart b/bin/api/streaming_handler.dart index 67a24a8..055e2d9 100644 --- a/bin/api/streaming_handler.dart +++ b/bin/api/streaming_handler.dart @@ -1,20 +1,19 @@ -import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:http/http.dart' as http; import '../database/database.dart'; import '../models/playlist_config.dart'; - -/// Active FFmpeg processes for VOD transcoding -final Map _vodProcesses = {}; +import '../services/ffmpeg_session_manager.dart'; +import '../utils/log_redactor.dart'; /// Directory for temporary HLS segments final Directory _hlsTempDir = 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) String _getFFmpegPath() { if (Platform.isWindows) { @@ -35,91 +34,149 @@ bool _isNvidiaGpuEnabled() { 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 Future initStreaming() async { - if (!_hlsTempDir.existsSync()) { - await _hlsTempDir.create(recursive: true); + await sessionManager.init(); +} + +/// 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 _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 -/// Some IPTV servers return 302 redirects that FFmpeg doesn't follow properly -Future _resolveRedirects(String url, {int maxRedirects = 5}) async { - String currentUrl = url; - final client = http.Client(); - - try { - for (int i = 0; i < maxRedirects; i++) { - final request = http.Request('GET', Uri.parse(currentUrl)); - request.headers['User-Agent'] = 'VLC/3.0.18 LibVLC/3.0.18'; - request.followRedirects = false; - - final response = await client.send(request).timeout( - const Duration(seconds: 30), - onTimeout: () => - throw TimeoutException('Redirect resolution timeout'), - ); - - // Check if it's a redirect (301, 302, 307, 308) - if (response.statusCode >= 300 && response.statusCode < 400) { - final location = response.headers['location']; - if (location != null && location.isNotEmpty) { - // Handle relative URLs - if (location.startsWith('/')) { - final uri = Uri.parse(currentUrl); - currentUrl = '${uri.scheme}://${uri.host}:${uri.port}$location'; - } else { - currentUrl = location; - } - 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(); +/// Video encoding args for the requested quality (VOD profile). +List _vodVideoArgs(String quality, bool gpu) { + switch (quality) { + case 'source': + return ['-c:v', 'copy']; + case 'medium': + return gpu + ? [ + '-c:v', 'h264_nvenc', '-preset', 'p4', '-tune', 'hq', + '-rc', 'cbr', '-b:v', '2500k', '-maxrate', '3000k', + '-bufsize', '5000k', '-g', '48', '-bf', '2', + '-pix_fmt', 'yuv420p', + ] + : [ + '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', + '-maxrate', '6000k', '-bufsize', '12000k', + '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0', + ]; + case 'low': + return gpu + ? [ + '-c:v', 'h264_nvenc', '-preset', 'p4', + '-rc', 'cbr', '-b:v', '1500k', '-maxrate', '2000k', + '-bufsize', '3000k', '-vf', 'scale=-2:720', + '-g', '48', '-pix_fmt', 'yuv420p', + ] + : [ + '-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', + ]; } - - return currentUrl; } -/// Helper to get current playlist configuration (mocked for now, implies single user) -/// In a real scenario, this should come from the request session/context -PlaylistConfig? _getCurrentPlaylist(Request request) { - // TODO: Retrieve from DB/Session based on Auth - // For now, we assume the frontend sends enough info or we look up the active one - return null; // Implemented later in server.dart injection +/// Audio args. Source mode keeps it simple (plain AAC); transcoded modes +/// keep the downmix + loudness normalization filter. +List _audioArgs({required bool withFilters}) { + return [ + '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-ar', '48000', + 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'], + ]; } -// ========================================== -// 1. LIVE TV HANDLER (Direct MPEG-TS Proxy) -// ========================================== +Map _hlsHeaders() => { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + }; -/// Active FFmpeg processes for Live HLS transcoding -final Map _liveProcesses = {}; +Map _segmentHeaders({int maxAge = 60}) => { + 'Content-Type': 'video/mp2t', + 'Cache-Control': 'max-age=$maxAge', + }; + +// ========================================== +// 1. LIVE TV HANDLER (FFmpeg HLS + direct TS proxy) +// ========================================== Handler createLiveStreamHandler( Future Function(Request) getPlaylist, { @@ -127,109 +184,89 @@ Handler createLiveStreamHandler( }) { final router = Router(); - // Route: /api/live/{streamId}/playlist.m3u8 (Master playlist) - router.get('//playlist.m3u8', - (Request request, String streamId) async { + Future servePlaylist( + Request request, + String streamId, + String quality, + ) async { + if (!_isValidStreamId(streamId)) { + return Response.badRequest(body: 'Invalid stream ID'); + } final playlist = await getPlaylist(request); if (playlist == null) return Response.forbidden('No playlist'); - final streamDir = Directory('${_hlsTempDir.path}/live_$streamId'); + final targetUrl = + '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + final sessionId = 'live_${streamId}_$quality'; - // 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); + if (!sessionManager.contains(sessionId)) { + print( + '[Live HLS] Starting $sessionId: ${LogRedactor.redactUrl(targetUrl)}', + ); + } - final targetUrl = - '${playlist.dns}/live/${playlist.username}/${playlist.password}/$streamId.ts'; - print('[Live HLS] Starting for $streamId: $targetUrl'); - - final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); - final ffmpegArgs = [ + final session = await sessionManager.getOrStart( + id: sessionId, + isLive: true, + ffmpegPath: _getFFmpegPath(), + argsBuilder: (dir) => [ '-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', '-reconnect', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '10', '-i', targetUrl, - // Video - if (useNvidiaGpu) ...[ - '-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', - ] 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 + ..._liveVideoArgs(quality, useNvidiaGpu), + ..._audioArgs(withFilters: false), + // HLS sliding window: 10 x 2s segments (lower live latency than the + // previous 20-segment window while still safe for iOS) '-f', 'hls', '-hls_time', '2', - '-hls_list_size', '20', + '-hls_list_size', '10', '-hls_flags', 'delete_segments+independent_segments', '-hls_segment_type', 'mpegts', '-hls_segment_filename', 'seg_%03d.ts', 'playlist.m3u8', - ]; - - final process = await Process.start( - _getFFmpegPath(), - ffmpegArgs, - workingDirectory: streamDir.path, - ); - _liveProcesses[streamId] = process; - - process.stderr - .transform(utf8.decoder) - .listen((d) => print('[Live HLS $streamId] $d')); - process.exitCode.then((_) => _liveProcesses.remove(streamId)); - } - - // 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'); - } - - return Response.ok( - file.openRead(), - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - 'X-Content-Type-Options': 'nosniff', - }, + ], ); + + final result = await sessionManager.waitForPlaylist(session); + if (!result.ready) { + sessionManager.killSession(sessionId); + return Response(502, body: 'Live transcoder failed: ${result.error}'); + } + + session.touch(); + return Response.ok( + File('${session.dir.path}/playlist.m3u8').openRead(), + headers: _hlsHeaders(), + ); + } + + // Route: /api/live/{streamId}/{quality}/playlist.m3u8 + router.get('///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('//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('/.ts', (Request request, String streamId) async { final playlist = await getPlaylist(request); if (playlist == null) return Response.forbidden('No playlist'); final targetUrl = '${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 proxyRequest = http.Request('GET', Uri.parse(targetUrl)); @@ -243,313 +280,143 @@ Handler createLiveStreamHandler( body: response.stream, headers: { 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', 'Connection': 'keep-alive', }, ); }); - // Route: /api/live/{streamId}/{segment} - router.get('//', - (Request request, String streamId, String segment) async { - final file = File('${_hlsTempDir.path}/live_$streamId/$segment'); + // Route: /api/live/{streamId}/{quality}/{segment} + router.get('///', + (Request request, String streamId, String quality, String 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'); - return Response.ok( - file.openRead(), - headers: { - 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'max-age=60', - }, - ); + return Response.ok(file.openRead(), headers: _segmentHeaders()); }); - return router; + return router.call; } // ========================================== // 2. VOD HANDLER (FFmpeg HLS Transcoding) // ========================================== -// ========================================== -// 2. VOD HANDLER (FFmpeg HLS Transcoding) -// ========================================== - -final _lastLogTime = {}; - Handler createVodStreamHandler( Future Function(Request) getPlaylist, { bool Function()? isGpuEnabled, }) { final router = Router(); - // Route: /api/vod/{streamId}/playlist.m3u8 - router.get('//playlist.m3u8', - (Request request, String streamId) async { + Future servePlaylist( + Request request, + String streamId, + String quality, + ) async { + if (!_isValidStreamId(streamId)) { + return Response.badRequest(body: 'Invalid stream ID'); + } final playlist = await getPlaylist(request); if (playlist == null) { return Response.internalServerError(body: 'No playlist'); } - // Validate streamId to prevent Path Traversal - 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) + // ?type=series or ?type=movie selects the upstream path final contentType = request.url.queryParameters['type'] ?? '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 = + '${playlist.dns}/$basePath/${playlist.username}/${playlist.password}/$streamId.mkv'; + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + final sessionId = 'vod_${streamId}_$quality'; - final targetUrl = - '${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(); - - if (useNvidiaGpu) { - print('[VOD] Using NVIDIA GPU acceleration (NVENC)'); - } - - // Build FFmpeg arguments - final ffmpegArgs = [ - '-hide_banner', - '-loglevel', - 'warning', - ]; - - // NVIDIA GPU Hardware Acceleration - if (useNvidiaGpu) { - ffmpegArgs.addAll([ - '-hwaccel', 'cuda', - // Note: Don't use hwaccel_output_format cuda - it can cause issues - ]); - } - - // Input and robustness flags - ffmpegArgs.addAll([ - '-headers', - 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', - '-reconnect', - '1', - '-reconnect_at_eof', - '1', - '-reconnect_streamed', - '1', - '-reconnect_delay_max', - '10', - '-rw_timeout', - '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', - ]); - - 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', + if (!sessionManager.contains(sessionId)) { + print( + '[VOD] Starting $sessionId ($contentType): ${LogRedactor.redactUrl(targetUrl)}', ); } - return Response.ok( - playlistFile.openRead(), - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', // Correct HLS Mime - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - }, + final session = await sessionManager.getOrStart( + id: sessionId, + isLive: false, + ffmpegPath: _getFFmpegPath(), + argsBuilder: (dir) => [ + '-hide_banner', '-loglevel', 'warning', + if (useNvidiaGpu && quality != 'source') ...['-hwaccel', 'cuda'], + '-headers', 'User-Agent: VLC/3.0.18 LibVLC/3.0.18\r\n', + '-reconnect', '1', + '-reconnect_at_eof', '1', + '-reconnect_streamed', '1', + '-reconnect_delay_max', '10', + '-rw_timeout', '30000000', + '-timeout', '30000000', + '-analyzeduration', '5000000', + '-probesize', '10000000', + '-i', targetUrl, + ..._vodVideoArgs(quality, useNvidiaGpu), + ..._audioArgs(withFilters: quality != 'source'), + '-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', + ], ); - }); - // Route: /api/vod/{streamId}/segment_{n}.ts (Serve segments) - router.get('//', - (Request request, String streamId, String segment) async { - final file = File('${_hlsTempDir.path}/$streamId/$segment'); - - if (!file.existsSync()) { - return Response.notFound('Segment not found'); + 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( - file.openRead(), - headers: { - 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'max-age=3600', // Cache segments - }, + File('${session.dir.path}/playlist.m3u8').openRead(), + headers: _hlsHeaders(), ); + } + + // Route: /api/vod/{streamId}/{quality}/playlist.m3u8 + router.get('///playlist.m3u8', + (Request request, String streamId, String quality) { + return servePlaylist(request, streamId, _sanitizeQuality(quality)); }); - return router; + // Back-compat route: /api/vod/{streamId}/playlist.m3u8 (?quality=...) + router.get('//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('///', + (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(); - router.get('//playlist.m3u8', (Request request, String streamId) async { + router.get('//playlist.m3u8', + (Request request, String streamId) async { final recording = db.getRecordingById(streamId); if (recording == null) { return Response.notFound('Recording not found'); @@ -573,121 +441,79 @@ Handler createRecordingStreamHandler( return Response(202, body: 'Recording not yet started'); } 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') { - 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) { return Response.internalServerError(body: 'Recording file path not set'); } final targetUrl = recording.filePath!; 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'); + final useNvidiaGpu = isGpuEnabled?.call() ?? _isNvidiaGpuEnabled(); + final sessionId = '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 ffmpegArgs = [ + final session = await sessionManager.getOrStart( + id: sessionId, + isLive: false, + ffmpegPath: _getFFmpegPath(), + argsBuilder: (dir) => [ '-hide_banner', '-loglevel', 'warning', if (useNvidiaGpu) ...['-hwaccel', 'cuda'], '-i', targetUrl, - ]; - - if (useNvidiaGpu) { - ffmpegArgs.addAll([ + if (useNvidiaGpu) ...[ '-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', - ]); - } else { - ffmpegArgs.addAll([ + ] else ...[ '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-maxrate', '12000k', '-bufsize', '24000k', '-pix_fmt', 'yuv420p', '-g', '48', '-threads', '0', - ]); - } - - 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', + ], + ..._audioArgs(withFilters: true), '-f', 'hls', '-hls_time', '4', '-hls_list_size', '0', '-hls_playlist_type', 'vod', '-hls_allow_cache', '1', '-hls_flags', 'independent_segments', '-hls_segment_type', 'mpegts', '-hls_segment_filename', 'segment_%03d.ts', '-start_number', '0', 'playlist.m3u8', - ]); + ], + ); - Process.start(_getFFmpegPath(), ffmpegArgs, workingDirectory: streamDir.path).then((process) { - _vodProcesses['rec_$streamId'] = process; - process.stderr.transform(utf8.decoder).listen((data) => print('[FFmpeg Rec $streamId] $data')); - process.exitCode.then((code) { - _vodProcesses.remove('rec_$streamId'); - }); - }); + final result = await sessionManager.waitForPlaylist(session); + if (!result.ready) { + sessionManager.killSession(sessionId); + return Response(502, + body: 'Recording transcoder failed: ${result.error}'); } - final playlistFile = File('${streamDir.path}/playlist.m3u8'); - 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'); - + session.touch(); return Response.ok( - playlistFile.openRead(), - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache', - }, + File('${session.dir.path}/playlist.m3u8').openRead(), + headers: _hlsHeaders(), ); }); - router.get('//', (Request request, String streamId, String segment) async { - final file = File('${_hlsTempDir.path}/rec_$streamId/$segment'); + router.get('//', + (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'); - return Response.ok( - file.openRead(), - headers: { - 'Content-Type': 'video/mp2t', - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'max-age=3600', - }, - ); + return Response.ok(file.openRead(), headers: _segmentHeaders(maxAge: 3600)); }); - return router; + return router.call; } diff --git a/bin/api/xtream_api_handler.dart b/bin/api/xtream_api_handler.dart new file mode 100644 index 0000000..366c8a9 --- /dev/null +++ b/bin/api/xtream_api_handler.dart @@ -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 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 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 = { + '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'}, + ); + } + } +} diff --git a/bin/database/database.dart b/bin/database/database.dart index 3366ba5..6c6f248 100644 --- a/bin/database/database.dart +++ b/bin/database/database.dart @@ -186,9 +186,27 @@ class AppDatabase { 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); } + /// 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 User createUser(String username, String password, {bool isAdmin = false}) { final userId = _uuid.v4(); diff --git a/bin/middleware/auth_middleware.dart b/bin/middleware/auth_middleware.dart index 96327d3..6283f35 100644 --- a/bin/middleware/auth_middleware.dart +++ b/bin/middleware/auth_middleware.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:shelf/shelf.dart'; import '../database/database.dart'; @@ -16,16 +17,20 @@ Middleware authMiddleware(AppDatabase db) { return Response(401, body: 'Unauthorized'); } - // Verify session + // Verify session (findSessionByToken enforces expires_at) final session = db.findSessionByToken(token); if (session == null) { 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: { ...request.context, 'userId': session.userId, + if (user != null) 'user': user, },); 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/.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 String? _extractToken(Request request) { // Try Authorization header first diff --git a/bin/middleware/security_middleware.dart b/bin/middleware/security_middleware.dart index 90262ce..259d9e4 100644 --- a/bin/middleware/security_middleware.dart +++ b/bin/middleware/security_middleware.dart @@ -1,12 +1,27 @@ import 'package:shelf/shelf.dart'; import 'dart:async'; +import 'dart:io'; /// Security Middleware Collection -/// +/// /// Includes: /// - Honeypot Routes (Trap for bots) -/// - Security Headers (HSTS, XSS Protection) +/// - Security Headers (HSTS, XSS Protection, CSP Report-Only) /// - 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 /// Adds standard security headers to every response. @@ -14,15 +29,24 @@ Middleware securityHeadersMiddleware() { return (Handler handler) { return (Request request) async { final response = await handler(request); - + return response.change(headers: { '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', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', 'Referrer-Policy': 'strict-origin-when-cross-origin', - // Note: CSP is tricky with Flutter Web (requires 'unsafe-eval' for Dart), - // so we omit it here to avoid breaking the app, or use a permissive one. + // Report-Only first: Flutter Web (CanvasKit/wasm) and google_fonts + // 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", },); }; }; @@ -48,15 +72,15 @@ Middleware honeypotMiddleware() { return (Handler handler) { return (Request request) { final path = request.url.path; - + // Check if path contains any honeypot target for (final trap in honeypotPaths) { 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 handler(request); }; }; @@ -64,10 +88,10 @@ Middleware honeypotMiddleware() { /// 3. Rate Limit Middleware (In-Memory) /// Limits requests per IP address. -/// Default: 100 requests per minute per IP. +/// Default: 200 requests per minute per IP. Middleware rateLimitMiddleware({int requestsPerMinute = 200}) { final clientRequests = >{}; - + // Cleanup timer to remove old entries and prevent memory leaks Timer.periodic(const Duration(minutes: 5), (_) { final now = DateTime.now(); @@ -80,26 +104,23 @@ Middleware rateLimitMiddleware({int requestsPerMinute = 200}) { return (Handler handler) { return (Request request) { - // Identify client by IP (passed from main server or headers) - // 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'; - + final clientIp = clientIpOf(request); + if (clientIp != 'unknown' && clientIp != '127.0.0.1') { final now = DateTime.now(); - + // Get or create history for this IP final history = clientRequests.putIfAbsent(clientIp, () => []); - + // Clean old requests (older than 1 minute) history.removeWhere((t) => now.difference(t).inMinutes >= 1); - + // Check limit if (history.length >= requestsPerMinute) { print('SECURITY WARN: Rate limit exceeded for $clientIp'); return Response(429, body: 'Too Many Requests'); } - + // Add current request history.add(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 = >{}; + + 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; + }; + }; +} diff --git a/bin/models/playlist.dart b/bin/models/playlist.dart index 2022e15..c7fcd08 100644 --- a/bin/models/playlist.dart +++ b/bin/models/playlist.dart @@ -42,7 +42,9 @@ class Playlist { 'name': name, 'serverUrl': serverUrl, '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, 'createdAt': createdAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(), diff --git a/bin/pubspec.yaml b/bin/pubspec.yaml index cd99d09..9ad22f9 100644 --- a/bin/pubspec.yaml +++ b/bin/pubspec.yaml @@ -14,6 +14,11 @@ dependencies: args: ^2.4.2 sqlite3: ^2.4.0 crypto: ^3.0.3 + bcrypt: ^1.1.3 + path: ^1.9.0 uuid: ^4.3.3 hive: ^2.2.3 equatable: ^2.0.5 + +dev_dependencies: + test: ^1.25.0 diff --git a/bin/server.dart b/bin/server.dart index b290655..5f7dfbc 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -5,7 +5,6 @@ import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_static/shelf_static.dart'; import 'package:shelf_router/shelf_router.dart'; -import 'package:http/http.dart' as http; import 'package:args/args.dart'; import 'database/database.dart'; import 'models/user.dart'; @@ -20,6 +19,7 @@ import 'api/proxy_handler.dart'; import 'api/recordings_api.dart'; import 'api/epg_api.dart'; import 'api/season_passes_api.dart'; +import 'api/xtream_api_handler.dart'; import 'middleware/auth_middleware.dart'; import 'middleware/security_middleware.dart'; import 'services/cleanup_service.dart'; @@ -113,15 +113,46 @@ void main(List args) async { final playlistsHandler = PlaylistsHandler(db); final usersHandler = UsersHandler(db); final settingsHandler = SettingsHandler(db); - final proxyHandler = ProxyHandler(getPlaylist, db); + final proxyHandler = ProxyHandler(getPlaylist); final recordingsApi = RecordingsApi(db, recordingScheduler); - final epgApi = EpgApi(db, getPlaylist); + final epgApi = EpgApi(getPlaylist); 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('/', recordingsApi.handleDelete) + ..post('/stop/', recordingsApi.handleStop) + ..get('/logs/', recordingsApi.getLogHandler); + + final epgRouter = Router()..get('/', epgApi.handleGetEpg); + + final seasonPassesRouter = Router() + ..get('/', seasonPassesApi.handleGetAll) + ..post('/', seasonPassesApi.handlePost) + ..delete('/', seasonPassesApi.handleDelete); // Setup router final apiRouter = Router() - // Auth endpoints - ..mount('/api/auth', authHandler.router) + // Auth endpoints (with per-IP brute-force protection on login) + ..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 ..mount( '/api/playlists', @@ -143,18 +174,27 @@ void main(List args) async { .addMiddleware(authMiddleware(db)) .addHandler(settingsHandler.router.call), ) - // TV Recordings - routes explicites - ..get('/api/recordings', recordingsApi.handleGetAll) - ..post('/api/recordings', recordingsApi.handlePost) - ..delete('/api/recordings/', recordingsApi.handleDelete) - ..post('/api/recordings/stop/', recordingsApi.handleStop) - ..get('/api/recordings/logs/', recordingsApi.getLogHandler) - // EPG - guide TV - ..get('/api/epg/', epgApi.handleGetEpg) - // Season Passes - enregistrements répétés - ..get('/api/season-passes', seasonPassesApi.handleGetAll) - ..post('/api/season-passes', seasonPassesApi.handlePost) - ..delete('/api/season-passes/', seasonPassesApi.handleDelete); + // TV Recordings (auth required) + ..mount( + '/api/recordings', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(recordingsRouter.call), + ) + // EPG - guide TV (auth required) + ..mount( + '/api/epg', + const Pipeline() + .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 // Do NOT mount here as it would intercept and block the actual proxy @@ -256,11 +296,17 @@ void main(List args) async { // This allows non-/api/xtream requests to fall through to static 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 final handler = Cascade() .add(apiRouter.call) /* Standard API endpoints */ .add(proxyPipeline) /* Xtream Proxy (auth inside handler) */ - .add(streamingRouter.call) /* Streaming endpoints */ + .add(protectedStreaming) /* Streaming endpoints */ .add(staticHandler) .handler; @@ -292,26 +338,48 @@ void main(List 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() { + final extraOrigin = Platform.environment['ALLOWED_ORIGIN']; + + bool isAllowed(String origin) { + if (extraOrigin != null && extraOrigin.isNotEmpty && origin == extraOrigin) { + 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'); + } + return (Handler handler) { return (Request request) async { + final origin = request.headers['origin']; + final headers = { + if (origin != null && isAllowed(origin)) ...{ + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': + 'Origin, Content-Type, Accept, Authorization, 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: _corsHeaders); + return Response.ok('', headers: headers); } // Process request and add CORS headers to response final response = await handler(request); - return response.change(headers: _corsHeaders); + return headers.isEmpty ? response : response.change(headers: headers); }; }; } - -final _corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': - 'Origin, Content-Type, Accept, Authorization, Range', - 'Access-Control-Expose-Headers': 'Content-Length, Content-Range', -}; diff --git a/bin/services/ffmpeg_session_manager.dart b/bin/services/ffmpeg_session_manager.dart new file mode 100644 index 0000000..7adb8f0 --- /dev/null +++ b/bin/services/ffmpeg_session_manager.dart @@ -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 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 _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 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 getOrStart({ + required String id, + required bool isLive, + required String ffmpegPath, + required List 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); + } + } + } +} diff --git a/bin/services/recording_decision.dart b/bin/services/recording_decision.dart new file mode 100644 index 0000000..3e385e6 --- /dev/null +++ b/bin/services/recording_decision.dart @@ -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; +} diff --git a/bin/services/recording_scheduler.dart b/bin/services/recording_scheduler.dart index 70772b6..395c8c8 100644 --- a/bin/services/recording_scheduler.dart +++ b/bin/services/recording_scheduler.dart @@ -4,17 +4,30 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../database/database.dart'; import '../models/recording.dart'; +import '../utils/log_redactor.dart'; +import 'recording_decision.dart'; import 'package:path/path.dart' as p; +class _ActiveRecording { + final Recording recording; + final Process process; + _ActiveRecording(this.recording, this.process); +} + class RecordingScheduler { final AppDatabase _db; Timer? _timer; Timer? _seasonPassTimer; bool _isRunning = false; - // Stocker l'enregistrement actuellement en cours d'exécution - Recording? _currentRecording; - Process? _ffmpegProcess; + /// Enregistrements actuellement en cours, indexés par id. + final Map _active = {}; + + /// 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 // Rempli depuis server.dart après initialisation @@ -26,7 +39,8 @@ class RecordingScheduler { void start() { 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) _timer = Timer.periodic( @@ -45,7 +59,9 @@ class RecordingScheduler { void stop() { _timer?.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é'); } @@ -58,17 +74,15 @@ class RecordingScheduler { final now = DateTime.now().toUtc(); 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 - if (_currentRecording != null) { - if (now.isAfter(_currentRecording!.endTime.toUtc())) { + // Arrêter les enregistrements actifs dont l'heure de fin est passée + for (final id in _active.keys.toList()) { + final active = _active[id]!; + if (now.isAfter(active.recording.endTime.toUtc())) { 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) { // Nettoyer les enregistrements bloqués "recording" suite à un crash serveur if (recording.status == 'recording' && - _currentRecording?.id != recording.id) { + !_active.containsKey(recording.id)) { print( '[RecordingScheduler] Enregistrement orphelin détecté: ${recording.id}', ); @@ -89,41 +103,38 @@ class RecordingScheduler { } if (recording.status == 'scheduled') { - final startUtc = recording.startTime.toUtc(); - final endUtc = recording.endTime.toUtc(); - print( - '[RecordingScheduler] "${recording.title}" start=$startUtc end=$endUtc now=$now isAfterStart=${now.isAfter(startUtc)} isBeforeEnd=${now.isBefore(endUtc)}', + final action = decideRecordingAction( + now: now, + startTime: recording.startTime, + 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 - if (now.isAfter(startUtc) && now.isBefore(endUtc)) { - if (_currentRecording != null) { + switch (action) { + case RecordingAction.start: print( - '[RecordingScheduler] Conflit: "${recording.title}" ne peut pas démarrer, "${_currentRecording!.title}" est en cours.', + '[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***', + ); + await _startRecording(recording); + case RecordingAction.wait: + // Capacité atteinte mais la fenêtre est encore ouverte : + // on garde le statut "scheduled" et on réessaiera au prochain tick. + print( + '[RecordingScheduler] Capacité max atteinte (${_active.length}/$maxConcurrent), ' + '"${recording.title}" en attente.', + ); + case RecordingAction.fail: + print( + '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).', ); _db.updateRecordingStatus( recording.id, 'failed', - errorReason: 'Un autre enregistrement était déjà en cours.', + errorReason: 'Heure de fin dépassée avant le démarrage', ); - continue; - } - print( - '[RecordingScheduler] *** DÉMARRAGE DE L\'ENREGISTREMENT : ${recording.title} ***', - ); - await _startRecording(recording); - } - - // Si l'enregistrement est planifié mais que la date de fin est dépassée (loupé) - if (now.isAfter(endUtc)) { - print( - '[RecordingScheduler] Enregistrement "${recording.title}" manqué (fin dépassée).', - ); - _db.updateRecordingStatus( - recording.id, - 'failed', - errorReason: 'Heure de fin dépassée avant le démarrage', - ); + case RecordingAction.none: + break; } } } @@ -223,7 +234,6 @@ class RecordingScheduler { } Future _startRecording(Recording recording) async { - _currentRecording = recording; _db.updateRecordingStatus(recording.id, 'recording'); // 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) + // Le contenu du log est exposé via l'API : ne jamais y écrire de credentials. IOSink? logSink; try { logSink = File(logFilePath).openWrite(); logSink.writeln('[${DateTime.now()}] Démarrage: ${recording.title}'); - logSink.writeln('URL: $streamUrl'); + logSink.writeln('URL: ${LogRedactor.redactUrl(streamUrl)}'); logSink.writeln('Destination: $filePath'); } catch (logError) { // 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'; } - logSink?.writeln('Commande: $ffmpegPath ${args.join(' ')}\n'); - print('[RecordingScheduler] Exécution: $ffmpegPath ${args.join(' ')}'); + final redactedArgs = + 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 - _ffmpegProcess!.stdout.listen((event) => logSink?.add(event)); - _ffmpegProcess!.stderr.listen((event) => logSink?.add(event)); + process.stdout.listen((event) => logSink?.add(event)); + process.stderr.listen((event) => logSink?.add(event)); // Enregistrer le chemin du fichier dans la BDD _db.updateRecordingStatus(recording.id, 'recording', filePath: filePath); // Écouter la fin du processus FFmpeg de manière asynchrone - _ffmpegProcess!.exitCode.then((exitCode) async { - if (_currentRecording?.id == recording.id) { + process.exitCode.then((exitCode) async { + if (_active.containsKey(recording.id)) { logSink?.writeln( '\n[${DateTime.now()}] FFmpeg terminé avec le code $exitCode', ); @@ -329,8 +343,7 @@ class RecordingScheduler { errorReason: 'Erreur FFmpeg code $exitCode. Voir logs.', ); } - _currentRecording = null; - _ffmpegProcess = null; + _active.remove(recording.id); } else { await logSink?.close(); } @@ -343,34 +356,30 @@ class RecordingScheduler { 'failed', errorReason: 'Erreur au lancement: $e', ); - _currentRecording = null; - _ffmpegProcess = null; + _active.remove(recording.id); } } /// Arrêter un enregistrement en cours (appelé depuis l'API) Future stopRecording(String id) async { - if (_ffmpegProcess != null && _currentRecording?.id == id) { + if (_active.containsKey(id)) { print( - '[RecordingScheduler] Arrêt demandé pour: ${_currentRecording!.title}', + '[RecordingScheduler] Arrêt demandé pour: ${_active[id]!.recording.title}', ); - _ffmpegProcess!.kill(ProcessSignal.sigterm); - _db.updateRecordingStatus(id, 'completed'); - _ffmpegProcess = null; - _currentRecording = null; + _stopActiveRecording(id); return true; } return false; // Pas d'enregistrement actif avec cet ID } - Future _stopCurrentRecording({String? reason}) async { - if (_ffmpegProcess != null && _currentRecording != null) { - print('[RecordingScheduler] Arrêt auto: ${reason ?? "Fin programmée"}'); - _ffmpegProcess!.kill(ProcessSignal.sigterm); - _db.updateRecordingStatus(_currentRecording!.id, 'completed'); - _ffmpegProcess = null; - _currentRecording = null; + void _stopActiveRecording(String id, {String? reason}) { + final active = _active.remove(id); + if (active == null) return; + if (reason != null) { + print('[RecordingScheduler] Arrêt: $reason (${active.recording.title})'); } + active.process.kill(ProcessSignal.sigterm); + _db.updateRecordingStatus(id, 'completed'); } Future _checkDiskSpaceAndRotate(Directory dir) async { diff --git a/bin/test/log_redactor_test.dart b/bin/test/log_redactor_test.dart new file mode 100644 index 0000000..a7ddaec --- /dev/null +++ b/bin/test/log_redactor_test.dart @@ -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); + }); + }); +} diff --git a/bin/test/password_hasher_test.dart b/bin/test/password_hasher_test.dart new file mode 100644 index 0000000..47484c6 --- /dev/null +++ b/bin/test/password_hasher_test.dart @@ -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); + }); + }); +} diff --git a/bin/test/recording_decision_test.dart b/bin/test/recording_decision_test.dart new file mode 100644 index 0000000..57d3a40 --- /dev/null +++ b/bin/test/recording_decision_test.dart @@ -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, + ); + }); + }); +} diff --git a/bin/test/safe_path_test.dart b/bin/test/safe_path_test.dart new file mode 100644 index 0000000..d3961ab --- /dev/null +++ b/bin/test/safe_path_test.dart @@ -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, + ); + }); + }); +} diff --git a/bin/test/ssrf_test.dart b/bin/test/ssrf_test.dart new file mode 100644 index 0000000..5bf29a2 --- /dev/null +++ b/bin/test/ssrf_test.dart @@ -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); + }); + }); +} diff --git a/bin/utils/log_redactor.dart b/bin/utils/log_redactor.dart new file mode 100644 index 0000000..e8d5593 --- /dev/null +++ b/bin/utils/log_redactor.dart @@ -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///.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; + } +} diff --git a/bin/utils/password_hasher.dart b/bin/utils/password_hasher.dart index ba4d109..416f010 100644 --- a/bin/utils/password_hasher.dart +++ b/bin/utils/password_hasher.dart @@ -1,17 +1,35 @@ import 'dart:convert'; +import 'package:bcrypt/bcrypt.dart'; import 'package:crypto/crypto.dart'; class PasswordHasher { - /// Hash a password using SHA256 (simple implementation) - /// Note: In production, use a proper bcrypt implementation + /// Cost factor 10: ~100-300ms per hash in pure Dart, acceptable for login frequency. + 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) { - final bytes = utf8.encode(password); - final digest = sha256.convert(bytes); - return digest.toString(); + return BCrypt.hashpw(password, BCrypt.gensalt(logRounds: _bcryptRounds)); } - /// 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) { - 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); } diff --git a/bin/utils/safe_path.dart b/bin/utils/safe_path.dart new file mode 100644 index 0000000..21460d6 --- /dev/null +++ b/bin/utils/safe_path.dart @@ -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; + } +} diff --git a/build_log.txt b/build_log.txt deleted file mode 100644 index 8a9c5f9..0000000 Binary files a/build_log.txt and /dev/null differ diff --git a/build_log_2.txt b/build_log_2.txt deleted file mode 100644 index 7892e8f..0000000 Binary files a/build_log_2.txt and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index f3e6104..563d18a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,14 @@ services: # Exemple Windows : - D:\MesVideos\TV:/app/recordings # Exemple Linux : - /mnt/nas/videos:/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 volumes: diff --git a/ANALYSIS_AND_IMPROVEMENTS.md b/docs/archive/ANALYSIS_AND_IMPROVEMENTS.md similarity index 100% rename from ANALYSIS_AND_IMPROVEMENTS.md rename to docs/archive/ANALYSIS_AND_IMPROVEMENTS.md diff --git a/APPLE_TV_ASSESSMENT_SUMMARY.md b/docs/archive/APPLE_TV_ASSESSMENT_SUMMARY.md similarity index 100% rename from APPLE_TV_ASSESSMENT_SUMMARY.md rename to docs/archive/APPLE_TV_ASSESSMENT_SUMMARY.md diff --git a/APPLE_TV_CODE_REFERENCE.md b/docs/archive/APPLE_TV_CODE_REFERENCE.md similarity index 100% rename from APPLE_TV_CODE_REFERENCE.md rename to docs/archive/APPLE_TV_CODE_REFERENCE.md diff --git a/APPLE_TV_DESIGN_SPECIFICATION.md b/docs/archive/APPLE_TV_DESIGN_SPECIFICATION.md similarity index 100% rename from APPLE_TV_DESIGN_SPECIFICATION.md rename to docs/archive/APPLE_TV_DESIGN_SPECIFICATION.md diff --git a/APPLE_TV_VISUAL_REFERENCE.md b/docs/archive/APPLE_TV_VISUAL_REFERENCE.md similarity index 100% rename from APPLE_TV_VISUAL_REFERENCE.md rename to docs/archive/APPLE_TV_VISUAL_REFERENCE.md diff --git a/COMPLETION_REPORT.md b/docs/archive/COMPLETION_REPORT.md similarity index 100% rename from COMPLETION_REPORT.md rename to docs/archive/COMPLETION_REPORT.md diff --git a/COMPLETION_SUMMARY.txt b/docs/archive/COMPLETION_SUMMARY.txt similarity index 100% rename from COMPLETION_SUMMARY.txt rename to docs/archive/COMPLETION_SUMMARY.txt diff --git a/DEVELOPER_INTEGRATION_CHECKLIST.md b/docs/archive/DEVELOPER_INTEGRATION_CHECKLIST.md similarity index 100% rename from DEVELOPER_INTEGRATION_CHECKLIST.md rename to docs/archive/DEVELOPER_INTEGRATION_CHECKLIST.md diff --git a/INTEGRATION_GUIDE.md b/docs/archive/INTEGRATION_GUIDE.md similarity index 100% rename from INTEGRATION_GUIDE.md rename to docs/archive/INTEGRATION_GUIDE.md diff --git a/OPTIMIZATIONS_COMPLETED.md b/docs/archive/OPTIMIZATIONS_COMPLETED.md similarity index 100% rename from OPTIMIZATIONS_COMPLETED.md rename to docs/archive/OPTIMIZATIONS_COMPLETED.md diff --git a/QUICK_REFERENCE.md b/docs/archive/QUICK_REFERENCE.md similarity index 100% rename from QUICK_REFERENCE.md rename to docs/archive/QUICK_REFERENCE.md diff --git a/RECORDING_BEFORE_AFTER.md b/docs/archive/RECORDING_BEFORE_AFTER.md similarity index 100% rename from RECORDING_BEFORE_AFTER.md rename to docs/archive/RECORDING_BEFORE_AFTER.md diff --git a/RECORDING_MIGRATION_GUIDE.md b/docs/archive/RECORDING_MIGRATION_GUIDE.md similarity index 100% rename from RECORDING_MIGRATION_GUIDE.md rename to docs/archive/RECORDING_MIGRATION_GUIDE.md diff --git a/RECORDING_REFACTORING_SUMMARY.txt b/docs/archive/RECORDING_REFACTORING_SUMMARY.txt similarity index 100% rename from RECORDING_REFACTORING_SUMMARY.txt rename to docs/archive/RECORDING_REFACTORING_SUMMARY.txt diff --git a/RECORDING_SYSTEM_NEW.md b/docs/archive/RECORDING_SYSTEM_NEW.md similarity index 100% rename from RECORDING_SYSTEM_NEW.md rename to docs/archive/RECORDING_SYSTEM_NEW.md diff --git a/RECORDING_SYSTEM_UPGRADE.txt b/docs/archive/RECORDING_SYSTEM_UPGRADE.txt similarity index 100% rename from RECORDING_SYSTEM_UPGRADE.txt rename to docs/archive/RECORDING_SYSTEM_UPGRADE.txt diff --git a/SIMPLE_RECORDING.md b/docs/archive/SIMPLE_RECORDING.md similarity index 100% rename from SIMPLE_RECORDING.md rename to docs/archive/SIMPLE_RECORDING.md diff --git a/THEME_INTEGRATION_GUIDE.md b/docs/archive/THEME_INTEGRATION_GUIDE.md similarity index 100% rename from THEME_INTEGRATION_GUIDE.md rename to docs/archive/THEME_INTEGRATION_GUIDE.md diff --git a/THEME_REDESIGN_COMPLETE.md b/docs/archive/THEME_REDESIGN_COMPLETE.md similarity index 100% rename from THEME_REDESIGN_COMPLETE.md rename to docs/archive/THEME_REDESIGN_COMPLETE.md diff --git a/THEME_REDESIGN_SUMMARY.md b/docs/archive/THEME_REDESIGN_SUMMARY.md similarity index 100% rename from THEME_REDESIGN_SUMMARY.md rename to docs/archive/THEME_REDESIGN_SUMMARY.md diff --git a/THEME_VISUAL_SUMMARY.txt b/docs/archive/THEME_VISUAL_SUMMARY.txt similarity index 100% rename from THEME_VISUAL_SUMMARY.txt rename to docs/archive/THEME_VISUAL_SUMMARY.txt diff --git a/task.md b/docs/archive/task.md similarity index 100% rename from task.md rename to docs/archive/task.md diff --git a/entrypoint.sh b/entrypoint.sh index ff3118b..04dcad3 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,8 +11,13 @@ chown -R xtremuser:xtremuser /app/data 2>/dev/null || true # Create subdirectories if they don't exist 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 -# Garantir les permissions en écriture même si chown échoue (volume monté depuis l'hôte) -chmod -R 777 /app/recordings 2>/dev/null || true +# Garantir les permissions en écriture (770 si chown a réussi, 775 en +# 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 echo "Starting server as xtremuser..." diff --git a/glass_usages.txt b/glass_usages.txt deleted file mode 100644 index ee8d16a..0000000 Binary files a/glass_usages.txt and /dev/null differ diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 1f4884b..a34a765 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'dart:html' as html; /// API Client for communicating with the backend @@ -17,11 +18,14 @@ class ApiClient { receiveTimeout: const Duration(seconds: 60), ),); - // Add interceptor for logging - _dio.interceptors.add(LogInterceptor( - requestBody: true, - responseBody: true, - ),); + // Debug-only logging; request bodies are never logged (login payloads + // contain passwords). + if (kDebugMode) { + _dio.interceptors.add(LogInterceptor( + requestBody: false, + responseBody: false, + ),); + } // Restore token from localStorage on initialization _restoreTokenFromStorage(); diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart deleted file mode 100644 index 34c3620..0000000 --- a/lib/core/database/hive_service.dart +++ /dev/null @@ -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? _encryptionKey; - - /// Initialize Hive for Web with encryption - static Future 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(_usersBoxName); - await Hive.openBox(_playlistsBoxName); - - // Seed default admin if no users exist - await _seedDefaultAdmin(); - - _initialized = true; - } - - /// Get or create 256-bit encryption key (web-compatible) - static Future> _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 _seedDefaultAdmin() async { - final usersBox = Hive.box(_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 get usersBox => Hive.box(_usersBoxName); - - /// Get playlists box - static Box get playlistsBox => - Hive.box(_playlistsBoxName); - - /// Close all boxes (cleanup) - static Future dispose() async { - await Hive.close(); - _initialized = false; - } -} diff --git a/lib/core/services/adaptive_bitrate_service.dart b/lib/core/services/adaptive_bitrate_service.dart deleted file mode 100644 index ba4d34a..0000000 --- a/lib/core/services/adaptive_bitrate_service.dart +++ /dev/null @@ -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 getBalancedProfiles() => [ - mobile240p, - mobile360p, - hd480p, - hd720p, - fhd1080p, - ]; - - /// Get all available profiles - static List getAllProfiles() => [ - mobile240p, - mobile360p, - hd480p, - hd720p, - fhd1080p, - uhd2k, - uhd4k, - ]; - - /// Get profiles suitable for streaming type - static List getProfiles({ - required String type, // 'live', 'vod', 'low-bandwidth' - int? maxBitrateBps, - }) { - List 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 = {}; // 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 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 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 = []; - - 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(); diff --git a/lib/core/services/streaming_optimizer.dart b/lib/core/services/streaming_optimizer.dart deleted file mode 100644 index f73184d..0000000 --- a/lib/core/services/streaming_optimizer.dart +++ /dev/null @@ -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 = []; - late StreamingMetrics _currentMetrics; - final StreamController _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 get metricsStream => _metricsController.stream; - - /// Get metrics history - List 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((ref) { - final qualitySelector = ref.watch(qualitySelectorProvider); - return StreamingOptimizerNotifier(qualitySelector); -}); - -final qualitySelectorProvider = Provider((ref) { - return QualitySelector(initialQuality: QualityProfiles.hd720p); -}); - -class StreamingOptimizerNotifier extends StateNotifier { - 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 getHistory() => _optimizer.getMetricsHistory(); - - @override - void dispose() { - _optimizer.dispose(); - super.dispose(); - } -} diff --git a/lib/core/widgets/themed_loading_screen.dart b/lib/core/widgets/themed_loading_screen.dart index 79e3789..a29f1fd 100644 --- a/lib/core/widgets/themed_loading_screen.dart +++ b/lib/core/widgets/themed_loading_screen.dart @@ -14,8 +14,8 @@ class ThemedLoading extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Color(0xFF0F1014), // Deep Space Dark - Color(0xFF121317), // Stitch background + AppColors.baseLevel0, + AppColors.background, ], ), ), @@ -24,7 +24,7 @@ class ThemedLoading extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator( - color: Color(0xFF4b8eff), // AppColors.primaryContainer + color: AppColors.primaryContainer, strokeWidth: 3, ), const SizedBox(height: 16), diff --git a/lib/core/widgets/tv_channel_grid.dart b/lib/core/widgets/tv_channel_grid.dart deleted file mode 100644 index 5495e13..0000000 --- a/lib/core/widgets/tv_channel_grid.dart +++ /dev/null @@ -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 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 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], - ), - ), - ), - ], - ); - } -} diff --git a/lib/core/widgets/tv_focusable_card.dart b/lib/core/widgets/tv_focusable_card.dart index da85c82..f2b953c 100644 --- a/lib/core/widgets/tv_focusable_card.dart +++ b/lib/core/widgets/tv_focusable_card.dart @@ -14,6 +14,9 @@ class TvFocusableCard extends StatefulWidget { final bool autofocus; final double borderRadius; + /// Accessibility label announced by screen readers (e.g. channel title). + final String? semanticLabel; + const TvFocusableCard({ super.key, required this.child, @@ -24,6 +27,7 @@ class TvFocusableCard extends StatefulWidget { this.focusColor, this.autofocus = false, this.borderRadius = 12.0, + this.semanticLabel, }); @override @@ -84,7 +88,10 @@ class _TvFocusableCardState extends State final isActive = _isFocused || _isHovered; final focusColor = widget.focusColor ?? AppColors.primaryContainer; - return MouseRegion( + return Semantics( + button: widget.onTap != null, + label: widget.semanticLabel, + child: MouseRegion( onEnter: (_) => _handleHover(true), onExit: (_) => _handleHover(false), cursor: SystemMouseCursors.click, @@ -148,6 +155,7 @@ class _TvFocusableCardState extends State ), ), ), + ), ); } } diff --git a/lib/core/widgets/tv_modern_card.dart b/lib/core/widgets/tv_modern_card.dart deleted file mode 100644 index 72229a6..0000000 --- a/lib/core/widgets/tv_modern_card.dart +++ /dev/null @@ -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 createState() => _TvModernCardState(); -} - -class _TvModernCardState extends State - 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, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/core/widgets/tv_nav_widgets.dart b/lib/core/widgets/tv_nav_widgets.dart deleted file mode 100644 index ea067b4..0000000 --- a/lib/core/widgets/tv_nav_widgets.dart +++ /dev/null @@ -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? actions; - - const TvTopNavBar({ - super.key, - required this.title, - this.onSearch, - this.onProfile, - this.onNotifications, - this.notificationCount = 0, - this.leading, - this.actions, - }); - - @override - State createState() => _TvTopNavBarState(); -} - -class _TvTopNavBarState extends State { - 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 items; - final int selectedIndex; - final ValueChanged 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 createState() => _TvSideNavState(); -} - -class _TvSideNavState extends State { - @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 actions; - final VoidCallback? onClose; - - const TvFloatingMenu({ - super.key, - required this.actions, - this.onClose, - }); - - @override - State createState() => _TvFloatingMenuState(); -} - -class _TvFloatingMenuState extends State - 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, - }); -} diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 4aaad94..dd51063 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -58,9 +58,9 @@ class _LoginScreenState extends ConsumerState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFF050505), + AppColors.surfaceContainerLowest, AppColors.background, - Color(0xFF0A0A0A), + AppColors.baseLevel0, ], ), ), diff --git a/lib/features/iptv/models/xtream_models.dart b/lib/features/iptv/models/xtream_models.dart index cab3fcc..d6701aa 100644 --- a/lib/features/iptv/models/xtream_models.dart +++ b/lib/features/iptv/models/xtream_models.dart @@ -30,9 +30,6 @@ class LiveChannel { ); } - String getStreamUrl(String dns, String username, String password) { - return '$dns/live/$username/$password/$streamId.ts'; - } } 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 { @@ -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 diff --git a/lib/features/iptv/providers/recommendations_provider.dart b/lib/features/iptv/providers/recommendations_provider.dart deleted file mode 100644 index 82ca28f..0000000 --- a/lib/features/iptv/providers/recommendations_provider.dart +++ /dev/null @@ -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 getContinueWatching( - Map watchHistory, - List allContent, - ) { - final recommendations = []; - - 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 getTrending( - Map viewCounts, - List allContent, - ) { - final recommendations = []; - - 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 getRecentlyAdded(List allContent) { - final recommendations = []; - - // 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 getForYou( - Map watchHistory, - List allContent, - ) { - final recommendations = []; - - // Collect watched categories - final watchedCategories = {}; - 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 = {}; - final unique = []; - 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>((ref) { - return WatchHistoryNotifier(); -}); - -class WatchHistoryNotifier extends StateNotifier> { - 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>((ref) { - return TrendingNotifier(); -}); - -class TrendingNotifier extends StateNotifier> { - TrendingNotifier() : super({}); - - void incrementViewCount(int streamId) { - final key = streamId.toString(); - state = { - ...state, - key: (state[key] ?? 0) + 1, - }; - } -} - -/// Provider for recommendations -final recommendationsProvider = FutureProvider.family, - (Playlist, Map, Map, List)>( - (ref, params) async { - final (playlist, watchHistory, trending, allContent) = params; - - // Combine all recommendation types - final allRecommendations = [ - ...RecommendationService.getContinueWatching(watchHistory, allContent), - ...RecommendationService.getTrending(trending, allContent), - ...RecommendationService.getRecentlyAdded(allContent), - ...RecommendationService.getForYou(watchHistory, allContent), - ]; - - // Deduplicate and score - final seen = {}; - final scored = []; - - 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; - }, -); diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index e07e4cb..e66bb7f 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -54,9 +54,9 @@ class _DashboardScreenState extends ConsumerState { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFF050505), // Very dark top + AppColors.surfaceContainerLowest, // Very dark top AppColors.background, // Black bottom - Color(0xFF0A0A0A), // Slightly darker for depth + AppColors.baseLevel0, // Slightly darker for depth ], ), ), diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 9562b42..d204344 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:html' as html; 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 '../providers/xtream_provider.dart'; import '../providers/playback_positions_provider.dart'; +import '../widgets/quality_selector_widget.dart'; // ... (existing imports) // ... (existing imports) @@ -63,6 +65,12 @@ class _PlayerScreenState extends ConsumerState { bool _isMuted = 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 void initState() { super.initState(); @@ -109,8 +117,10 @@ class _PlayerScreenState extends ConsumerState { } catch (_) {} } - // Stable view ID for the instance to prevent iframe reload on rebuild (orientation change) - _viewId = 'iptv-player-$currentStreamId-$_viewIdPrefix'; + // Stable view ID for the instance to prevent iframe reload on rebuild + // (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)); String streamUrl = ''; @@ -118,14 +128,21 @@ class _PlayerScreenState extends ConsumerState { // Generate Stream URL based on type // NOTE: Logic is now handled by the backend routes /api/live/ and /api/vod/ if (widget.streamType == StreamType.live) { - streamUrl = service.getLiveStreamUrl(currentStreamId); + streamUrl = service.getLiveStreamUrl( + currentStreamId, + quality: _quality.value, + ); } else if (widget.streamType == StreamType.vod) { - streamUrl = - service.getVodStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = service.getVodStreamUrl( + currentStreamId, + widget.containerExtension, + quality: _quality.value, + ); } else if (widget.streamType == StreamType.series) { streamUrl = service.getSeriesStreamUrl( currentStreamId, widget.containerExtension, + quality: _quality.value, ); } else if (widget.streamType == StreamType.recording) { streamUrl = '${service.backendBaseUrl}/api/recordings/stream/$currentStreamId/playlist.m3u8'; @@ -135,8 +152,9 @@ class _PlayerScreenState extends ConsumerState { final isMobile = MediaQuery.of(context).size.width < 600; final isLiveTV = widget.streamType == StreamType.live; - // TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping - if (isLiveTV && !isMobile) { + // TURBO-START: in `source` quality, Live TV on desktop uses the direct + // MPEG-TS proxy (mpegts.js) for instant zapping with zero transcoding. + if (isLiveTV && !isMobile && _quality == StreamQuality.source) { streamUrl = service.getLiveStreamUrlTs(currentStreamId); } @@ -215,6 +233,8 @@ class _PlayerScreenState extends ConsumerState { void _setupMessageListener() { _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; if (data == null) return; @@ -271,8 +291,8 @@ class _PlayerScreenState extends ConsumerState { } if (iframe != null) { - print('[PlayerScreen] Sending message to iframe: $message'); - iframe.contentWindow?.postMessage(message, '*'); + iframe.contentWindow + ?.postMessage(message, html.window.location.origin); } else { print( '[PlayerScreen] Error: Could not find iframe to send message: $message', @@ -331,6 +351,16 @@ class _PlayerScreenState extends ConsumerState { _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() { if (widget.channels != null && widget.channels!.isNotEmpty) { setState(() { @@ -357,6 +387,68 @@ class _PlayerScreenState extends ConsumerState { } } + /// 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 twoDigits(int n) => n.toString().padLeft(2, '0'); final hours = twoDigits(d.inHours); @@ -371,7 +463,10 @@ class _PlayerScreenState extends ConsumerState { // LITE PLAYER MODE for Live TV - Simplified UI (no BackdropFilter) if (isLiveTV) { - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _onKeyEvent, + child: Scaffold( backgroundColor: AppColors.background, body: Stack( fit: StackFit.expand, @@ -425,6 +520,7 @@ class _PlayerScreenState extends ConsumerState { _buildSimpleIconButton( icon: Icons.arrow_back_rounded, onTap: () => Navigator.pop(context), + tooltip: 'Retour', ), const SizedBox(width: 16), Expanded( @@ -554,11 +650,15 @@ class _PlayerScreenState extends ConsumerState { ), ], ), + ), ); } // STANDARD PLAYER MODE (VOD/Series) - return Scaffold( + return Focus( + autofocus: true, + onKeyEvent: _onKeyEvent, + child: Scaffold( backgroundColor: Colors.black, body: Stack( fit: StackFit.expand, @@ -607,6 +707,7 @@ class _PlayerScreenState extends ConsumerState { _buildGlassIconButton( icon: Icons.arrow_back_rounded, onTap: () => Navigator.pop(context), + tooltip: 'Retour', ), const SizedBox(width: 16), Expanded( @@ -725,6 +826,7 @@ class _PlayerScreenState extends ConsumerState { .clamp(0, _totalDuration), }), transparent: true, + tooltip: 'Reculer de 10 s', ), const SizedBox(width: 24), _buildGlassIconButton( @@ -734,6 +836,7 @@ class _PlayerScreenState extends ConsumerState { onTap: _togglePlayPause, size: 56, iconSize: 32, + tooltip: _isPlaying ? 'Pause' : 'Lecture', ), const SizedBox(width: 24), _buildGlassIconButton( @@ -744,20 +847,30 @@ class _PlayerScreenState extends ConsumerState { .clamp(0, _totalDuration), }), transparent: true, + tooltip: 'Avancer de 10 s', ), const Spacer(), + QualitySelectorButton( + current: _quality, + onSelected: _changeQuality, + ), + const SizedBox(width: 16), _buildGlassIconButton( icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, onTap: _toggleMute, transparent: true, + tooltip: _isMuted + ? 'Activer le son' + : 'Couper le son', ), const SizedBox(width: 16), _buildGlassIconButton( icon: Icons.aspect_ratio_rounded, onTap: _toggleFullscreen, transparent: true, + tooltip: 'Plein écran', ), ], ), @@ -772,6 +885,7 @@ class _PlayerScreenState extends ConsumerState { ), ], ), + ), ); } @@ -781,16 +895,14 @@ class _PlayerScreenState extends ConsumerState { bool transparent = false, double size = 48, double iconSize = 24, + String? tooltip, }) { // Button with direct Material/InkWell - PointerInterceptor is on outer container - return Material( + final button = Material( color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1), shape: const CircleBorder(), child: InkWell( - onTap: () { - print('[PlayerScreen] Button tapped: $icon'); - onTap(); - }, + onTap: onTap, customBorder: const CircleBorder(), child: Container( width: size, @@ -806,6 +918,7 @@ class _PlayerScreenState extends ConsumerState { ), ), ); + return tooltip != null ? Tooltip(message: tooltip, child: button) : button; } List _buildControlButtons(bool isSmallScreen) { @@ -820,6 +933,7 @@ class _PlayerScreenState extends ConsumerState { icon: Icons.skip_previous_rounded, onTap: _previousChannel, size: buttonSize, + tooltip: 'Chaîne précédente', ), if (widget.channels != null && widget.channels!.length > 1) @@ -832,6 +946,7 @@ class _PlayerScreenState extends ConsumerState { size: playButtonSize, iconSize: isSmallScreen ? 24 : 28, highlighted: true, + tooltip: _isPlaying ? 'Pause' : 'Lecture', ), if (widget.channels != null && widget.channels!.length > 1) @@ -843,6 +958,7 @@ class _PlayerScreenState extends ConsumerState { icon: Icons.skip_next_rounded, onTap: _nextChannel, size: buttonSize, + tooltip: 'Chaîne suivante', ), SizedBox(width: spacing), @@ -852,6 +968,16 @@ class _PlayerScreenState extends ConsumerState { icon: _isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded, onTap: _toggleMute, 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 { double size = 48, double iconSize = 24, bool highlighted = false, + String? tooltip, }) { - return Material( + final button = Material( color: highlighted ? AppColors.primary.withOpacity(0.2) : Colors.white.withOpacity(0.1), shape: const CircleBorder(), child: InkWell( - onTap: () { - print('[PlayerScreen] Simple button tapped: $icon'); - onTap(); - }, + onTap: onTap, customBorder: const CircleBorder(), splashColor: AppColors.primary.withOpacity(0.3), highlightColor: AppColors.primary.withOpacity(0.1), @@ -898,6 +1022,7 @@ class _PlayerScreenState extends ConsumerState { ), ), ); + return tooltip != null ? Tooltip(message: tooltip, child: button) : button; } /// Build inline EPG info for the unified control bar diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index f0b9f98..6d39464 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -62,9 +62,9 @@ class PlaylistSelectionScreen extends ConsumerWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Color(0xFF050505), + AppColors.surfaceContainerLowest, AppColors.background, - Color(0xFF0A0A0A), + AppColors.baseLevel0, ], ), ), diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index dc23dd4..16eb1c8 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -42,6 +42,36 @@ class XtreamService { ); _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> _apiGet( + Map params, { + Options? options, + }) { + return _dio.get( + '$_backendBaseUrl/api/xtream-api', + queryParameters: params, + options: options ?? Options(extra: _cacheOptions.toExtra()), + ); } String? _manualBackendUrl; @@ -84,47 +114,45 @@ class XtreamService { _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 void setPlaylist(PlaylistConfig playlist) { _currentPlaylist = playlist; } /// 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'); - return '$_backendBaseUrl/api/live/$streamId/playlist.m3u8'; + return '$_backendBaseUrl/api/live/$streamId/$quality/playlist.m3u8'; } /// Generate stream URL for live TV (Direct MPEG-TS) /// Faster zapping on compatible players (mpegts.js) String getLiveStreamUrlTs(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - // Direct link to the .ts stream through our proxy - final dns = _currentPlaylist!.dns; - final user = _currentPlaylist!.username; - final pass = _currentPlaylist!.password; - final url = '$dns/live/$user/$pass/$streamId.ts'; - return '$_backendBaseUrl/api/xtream/${Uri.encodeComponent(url)}'; + // Backend route injects the Xtream credentials server-side + return '$_backendBaseUrl/api/live/$streamId.ts'; } /// 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'); - return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8'; + return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8'; } /// 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'); - return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8?type=series'; + return '$_backendBaseUrl/api/vod/$streamId/$quality/playlist.m3u8?type=series'; } /// Authenticate and get server info @@ -132,14 +160,7 @@ class XtreamService { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({}); return response.data as Map; } catch (e) { @@ -152,15 +173,7 @@ class XtreamService { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_live_categories', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_live_categories'}); final List categories = response.data as List; final Map categoryMap = {}; @@ -198,13 +211,8 @@ class XtreamService { // Parallelize categories and streams fetching final results = await Future.wait([ _getLiveCategories(), - _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_live_streams', - }, + _apiGet( + {'action': 'get_live_streams'}, options: Options(extra: options), ), ]); @@ -279,15 +287,7 @@ class XtreamService { if (_currentPlaylist == null) return {}; try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_categories', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_vod_categories'}); final List categories = response.data as List; final Map categoryMap = {}; @@ -319,13 +319,8 @@ class XtreamService { // Parallelize VOD categories and streams fetching final results = await Future.wait([ _getVodCategories(), - _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_streams', - }, + _apiGet( + {'action': 'get_vod_streams'}, options: Options(extra: options), ), ]); @@ -364,15 +359,7 @@ class XtreamService { if (_currentPlaylist == null) return {}; try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series_categories', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_series_categories'}); final List categories = response.data as List; final Map categoryMap = {}; @@ -404,13 +391,8 @@ class XtreamService { // Parallelize Series categories and streams fetching final results = await Future.wait([ _getSeriesCategories(), - _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series', - }, + _apiGet( + {'action': 'get_series'}, options: Options(extra: options), ), ]); @@ -455,15 +437,7 @@ class XtreamService { // Load categories for mapping final categoryMap = await _getVodCategories(); - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_streams', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_vod_streams'}); final List allMovies = response.data as List; @@ -494,15 +468,7 @@ class XtreamService { try { final categoryMap = await _getVodCategories(); - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_streams', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_vod_streams'}); final List allMovies = response.data as List; final queryLower = query.toLowerCase(); @@ -537,15 +503,7 @@ class XtreamService { // Load categories for mapping final categoryMap = await _getSeriesCategories(); - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_series'}); final List allSeries = response.data as List; @@ -576,15 +534,7 @@ class XtreamService { try { final categoryMap = await _getSeriesCategories(); - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series', - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({'action': 'get_series'}); final List allSeries = response.data as List; final queryLower = query.toLowerCase(); @@ -613,16 +563,10 @@ class XtreamService { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_series_info', - 'series_id': seriesId, - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({ + 'action': 'get_series_info', + 'series_id': seriesId, + }); return xm.SeriesInfo.fromJson(response.data as Map); } catch (e) { @@ -637,16 +581,10 @@ class XtreamService { if (_currentPlaylist == null) return null; try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, - 'action': 'get_vod_info', - 'vod_id': vodId, - }, - options: Options(extra: _cacheOptions.toExtra()), - ); + final response = await _apiGet({ + 'action': 'get_vod_info', + 'vod_id': vodId, + }); final data = response.data as Map; @@ -698,11 +636,8 @@ class XtreamService { } try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, + final response = await _apiGet( + { 'action': 'get_short_epg', 'stream_id': streamId, }, @@ -740,11 +675,8 @@ class XtreamService { } try { - final response = await _dio.get( - _wrapWithProxy(_currentPlaylist!.apiBaseUrl), - queryParameters: { - 'username': _currentPlaylist!.username, - 'password': _currentPlaylist!.password, + final response = await _apiGet( + { 'action': 'get_short_epg', 'stream_id': streamId, }, diff --git a/lib/features/iptv/widgets/channel_card.dart b/lib/features/iptv/widgets/channel_card.dart deleted file mode 100644 index ff3e7cb..0000000 --- a/lib/features/iptv/widgets/channel_card.dart +++ /dev/null @@ -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 createState() => _ChannelCardState(); -} - -class _ChannelCardState extends State - with SingleTickerProviderStateMixin { - late AnimationController _pulseController; - late Animation _pulseAnimation; - bool _isHovered = false; - - @override - void initState() { - super.initState(); - _pulseController = AnimationController( - vsync: this, - duration: AppTheme.durationMd, - )..repeat(reverse: true); - - _pulseAnimation = Tween(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, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/features/iptv/widgets/continue_watching_widget.dart b/lib/features/iptv/widgets/continue_watching_widget.dart deleted file mode 100644 index 4b8568c..0000000 --- a/lib/features/iptv/widgets/continue_watching_widget.dart +++ /dev/null @@ -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 content; - final VoidCallback? onItemTap; - - const ContinueWatchingWidget({ - super.key, - required this.playlist, - required this.content, - this.onItemTap, - }); - - @override - ConsumerState createState() => - _ContinueWatchingWidgetState(); -} - -class _ContinueWatchingWidgetState - extends ConsumerState { - @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( - _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 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 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]), - ), - ), - ), - ); - } -} diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index 513ed66..5218294 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -49,7 +49,7 @@ class EpgOverlay extends ConsumerWidget { margin: const EdgeInsets.fromLTRB(0, 0, 24, 0), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: const Color(0xFF1A1A2E) + color: AppColors.surfaceContainerLow .withOpacity(0.85), // Match Native player controls borderRadius: BorderRadius.circular(24), border: Border.all(color: Colors.white.withOpacity(0.1)), diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index f5ee70e..9f1c700 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -371,6 +371,7 @@ class _LiveTVTabState extends ConsumerState return TvFocusableCard( onTap: () => _playChannel(channel, channels), borderRadius: 12, + semanticLabel: 'Chaîne ${channel.name}', child: Container( decoration: BoxDecoration( color: AppColors.surfaceVariant, @@ -481,7 +482,7 @@ class _LiveTVTabState extends ConsumerState return Text( currentProgram.title, style: GoogleFonts.inter( - color: const Color(0xFFFFD700), + color: AppColors.ratingGold, fontSize: 10, fontWeight: FontWeight.w600, ), diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 7b8ba1f..a902cef 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -341,6 +341,7 @@ class _MoviesTabState extends ConsumerState { return TvFocusableCard( onTap: () => _playMovie(movie), borderRadius: 12, + semanticLabel: 'Film ${movie.name}', child: Stack( fit: StackFit.expand, children: [ @@ -420,7 +421,7 @@ class _MoviesTabState extends ConsumerState { const Icon( Icons.star, size: 10, - color: Color(0xFFFFD700), + color: AppColors.ratingGold, ), const SizedBox(width: 4), Text( diff --git a/lib/features/iptv/widgets/quality_selector_widget.dart b/lib/features/iptv/widgets/quality_selector_widget.dart index 2207dcf..9684095 100644 --- a/lib/features/iptv/widgets/quality_selector_widget.dart +++ b/lib/features/iptv/widgets/quality_selector_widget.dart @@ -1,253 +1,107 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../core/services/adaptive_bitrate_service.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../core/theme/app_colors.dart'; -/// Widget for displaying and selecting video quality during playback -class QualitySelectorWidget extends ConsumerStatefulWidget { - final QualitySelector qualitySelector; - final VoidCallback onClose; +/// Stream quality presets handled server-side by FFmpeg +/// (see bin/api/streaming_handler.dart). +enum StreamQuality { + 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({ - super.key, - required this.qualitySelector, - required this.onClose, - }); + const StreamQuality(this.value, this.label, this.description); - @override - ConsumerState createState() => - _QualitySelectorWidgetState(); + final String value; + final String label; + final String description; } -class _QualitySelectorWidgetState extends ConsumerState { - late List _availableQualities; - late QualityLevel _selectedQuality; +/// Popup button to switch the transcoding quality of the current stream. +class QualitySelectorButton extends StatelessWidget { + final StreamQuality current; + final ValueChanged onSelected; + final double size; - @override - void initState() { - super.initState(); - _availableQualities = QualityProfiles.getBalancedProfiles(); - _selectedQuality = widget.qualitySelector.currentQuality; - } + const QualitySelectorButton({ + super.key, + required this.current, + required this.onSelected, + this.size = 44, + }); @override Widget build(BuildContext context) { - final currentQuality = widget.qualitySelector.currentQuality; - final bandwidthMbps = widget.qualitySelector.getEstimatedBandwidthMbps(); - - return Dialog( - backgroundColor: Colors.black87, - insetPadding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - 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( - '${bandwidthMbps.toStringAsFixed(1)} Mbps', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.bold, + return Tooltip( + message: 'Qualité : ${current.label}', + child: PopupMenuButton( + initialValue: current, + color: AppColors.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + onSelected: onSelected, + itemBuilder: (context) => StreamQuality.values + .map( + (q) => PopupMenuItem( + 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, ), - ), - ], - ), - ), - ), - - // 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, + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + q.label, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + Text( + q.description, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + color: AppColors.textTertiary, + fontSize: 11, + ), + ), + ], ), - 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; - }); - }, - ); - }, + ), + ) + .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)), ), - - // 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(); - }, - ), - ), + child: const Icon( + Icons.high_quality_rounded, + color: Colors.white, + size: 22, ), - - 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( - quality.resolution.split('x')[1], // Show just the height - style: const TextStyle( - color: Colors.white, - fontSize: 12, - 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, - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/iptv/widgets/recordings_tab.dart b/lib/features/iptv/widgets/recordings_tab.dart index 070b377..b0c5a1c 100644 --- a/lib/features/iptv/widgets/recordings_tab.dart +++ b/lib/features/iptv/widgets/recordings_tab.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:http/http.dart' as http; import '../../../core/models/iptv_models.dart'; import '../../../core/models/playlist_config.dart'; +import '../../../core/theme/app_colors.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; import '../screens/player_screen.dart'; @@ -363,7 +364,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView> const Icon( Icons.tv_off, size: 64, - color: Color(0x1FFFFFFF), + color: AppColors.glassLevel1Border, ), const SizedBox(height: 16), Text( @@ -512,7 +513,7 @@ class _ProgrammeCard extends StatelessWidget { showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E2E), + backgroundColor: AppColors.surfaceContainer, title: Row( children: [ const Icon( @@ -823,7 +824,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> { showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E2E), + backgroundColor: AppColors.surfaceContainer, title: Text( title, style: GoogleFonts.outfit(color: Colors.white, fontSize: 14), @@ -918,7 +919,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> { const Icon( Icons.videocam_off, size: 64, - color: Color(0x1FFFFFFF), + color: AppColors.glassLevel1Border, ), const SizedBox(height: 16), Text( @@ -1124,7 +1125,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFF1E1E2E), + backgroundColor: AppColors.surfaceContainer, title: Row( children: [ const Icon(Icons.repeat, color: Colors.purpleAccent), @@ -1282,7 +1283,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> { const Icon( Icons.repeat, size: 64, - color: Color(0x1FFFFFFF), + color: AppColors.glassLevel1Border, ), const SizedBox(height: 16), Text( diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index 522ffb2..55913e8 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -333,6 +333,7 @@ class _SeriesTabState extends ConsumerState { return TvFocusableCard( onTap: () => _openSeries(serie), borderRadius: 12, + semanticLabel: 'Série ${serie.name}', child: Stack( fit: StackFit.expand, children: [ @@ -434,7 +435,7 @@ class _SeriesTabState extends ConsumerState { const Icon( Icons.star, size: 10, - color: Color(0xFFFFD700), + color: AppColors.ratingGold, ), const SizedBox(width: 4), Text( diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index 9c45c1b..7a4740d 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -482,7 +482,7 @@ class _SettingsTabState extends ConsumerState showDialog( context: context, builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), + backgroundColor: AppColors.surfaceContainer, title: const Text( 'Vider le cache ?', style: TextStyle(color: AppColors.onSurface), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index 4cfa2e9..e397aa6 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -287,7 +287,7 @@ class StreamingSettingsTab extends ConsumerWidget { trailing: DropdownButton( value: value, underline: const SizedBox(), - dropdownColor: const Color(0xFF1C1C1E), // Dark background for dropdown + dropdownColor: AppColors.surfaceContainer, // Dark background for dropdown items: items.map((item) { return DropdownMenuItem( value: item, diff --git a/lib/main.dart b/lib/main.dart index 5523572..517c346 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; @@ -24,7 +25,21 @@ class MyApp extends ConsumerWidget { darkTheme: AppTheme.darkTheme, themeMode: themeState.themeMode, 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(), + }, ); } } - diff --git a/lib/mobile/features/auth/screens/mobile_login_screen.dart b/lib/mobile/features/auth/screens/mobile_login_screen.dart index 059281f..f503f73 100644 --- a/lib/mobile/features/auth/screens/mobile_login_screen.dart +++ b/lib/mobile/features/auth/screens/mobile_login_screen.dart @@ -68,9 +68,9 @@ class _MobileLoginScreenState extends ConsumerState { begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Color(0xFF000000), // Deep Black - Color(0xFF0F0F12), // Subtle Gray - Color(0xFF000000), // Back to Black + AppColors.baseLevel0, + AppColors.surfaceContainerLowest, + AppColors.baseLevel0, ], ), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 0d72d83..ce6e064 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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/material.dart'; -import 'package:xtremflow/main.dart'; +import 'package:xtremflow/features/iptv/widgets/quality_selector_widget.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('QualitySelectorButton shows all presets when opened', + (tester) async { + StreamQuality? selected; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: QualitySelectorButton( + current: StreamQuality.high, + onSelected: (q) => selected = q, + ), + ), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + await tester.tap(find.byType(QualitySelectorButton)); + await tester.pumpAndSettle(); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + expect(find.text('Source'), findsOneWidget); + expect(find.text('Haute'), findsOneWidget); + expect(find.text('Moyenne'), findsOneWidget); + expect(find.text('Basse'), findsOneWidget); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + await tester.tap(find.text('Basse')); + await tester.pumpAndSettle(); + expect(selected, StreamQuality.low); }); } diff --git a/web/index.html b/web/index.html index 951d7cd..6fbc7a7 100644 --- a/web/index.html +++ b/web/index.html @@ -106,8 +106,8 @@
Loading...
- - + + diff --git a/web/player.html b/web/player.html index d985313..a86ae47 100644 --- a/web/player.html +++ b/web/player.html @@ -4,9 +4,10 @@ XtremFlow Player - - - + + + +