7 Commits
Author SHA1 Message Date
MichaelandClaude Fable 5 186f9f89f9 chore: bump version to 1.7.1+15
The channel sidebar is an incremental change on top of v1.7.0, so ship it
as a patch release instead of 1.8.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8c4kaH8AXVT5UUie3zR4p
2026-09-01 08:09:13 +02:00
MichaelandClaude Fable 5 3c37c6a6e6 feat(player): channel list sidebar accessible from the live OSD
Add a visible playlist button in both players (native + lite) that opens
the TiviMate-style channel sidebar (channels of the current category).
The sidebar existed in the native player but was only reachable via an
edge swipe or the M/Menu key; the lite player had none.

- New OSD button next to channel zapping (live only)
- Lite player: full sidebar port (swipe from right edge, M/Menu key)
- D-pad support: focus lands on the current channel when the list opens,
  arrows navigate, OK switches channel, Back/Menu closes
- Auto-hide timer (6s) restarted on D-pad focus and touch scroll
- Bump version to 1.8.0+15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EG1frWE7suYF7hp4q2sDiR
2026-09-01 08:05:34 +02:00
MichaelandClaude Fable 5 c096532fec fix(android): sign release APK with a stable keystore in CI
Release builds were signed with the debug keystore. On the GitHub runner
that keystore is generated fresh on every build, so each CI APK carried a
different signature and Android refused to install it over an existing
version ("App not installed").

- build.gradle.kts: read android/key.properties when present and use it
  as the release signing config; fall back to debug otherwise so local
  `flutter run --release` keeps working.
- build-apk.yml: decode the keystore from ANDROID_KEYSTORE_* secrets into
  android/key.properties before building, and fail early if any secret
  is missing instead of silently signing with a throwaway key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3E5JovvDaQQ19D4UR258o
2026-08-30 16:21:51 +02:00
LogiFlow 05ca6f85be Merge pull request #1 from R0m1k3/claude/video-app-theme-latency-remote-xczp28
Thème gris Apple Glass, latence live réduite, correctif audio, mémoire de focus télécommande
2026-08-30 08:28:25 +02:00
Claude 04e9ab5730 ci: build release APK and publish a GitHub Release on main
Adds a Build APK workflow: on every push to main it builds the release
APK with Flutter 3.35.3 and publishes it as a GitHub Release tagged
with the pubspec version (v1.7.0 → xtremobile-v1.7.0.apk). On pull
requests and manual runs the APK is built and uploaded as an artifact
only. Bumps app version to 1.7.0+14 for this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:43:21 +00:00
Claude 9b6b7faf4a feat: Apple glass gray theme, lower live latency, audio fix, remote focus memory
Theme — replace the near-black "Warm Cinema" palette with a neutral
"Apple Glass Gray" system: graphite surfaces (#26262C base, lighter
containers), Apple-white text, Apple system blue accent, white glass
borders/highlights. Noticeably less dark overall; light theme moves
from kraft paper to iOS light gray. All AppColors names kept so no
call site changes.

Player latency — live buffering drops from 8s cache / 10s readahead /
150MB to 3s / 5s / 60MB, keeping enough headroom for network jitter
while staying much closer to the live edge.

Audio — the forced audio filter chain
(af=lavrresample,format=channels=stereo:sample_fmts=s16) mixed FFmpeg
and mpv syntax; when it failed to init for a channel's codec, mpv
muted the stream entirely. Replaced by mpv's native
audio-channels=stereo downmix and AO-negotiated output format. Probe
caps raised (2MB/3s) so late-declared audio tracks (AAC-LATM) are
detected without slowing healthy streams.

Remote control — the player OSD now remembers the last focused button
and restores focus to it whenever the OSD reopens (arrows, OK) and
across channel switches, so one press re-zaps instead of five. On
live TV, OK with hidden controls only reopens the OSD instead of
pausing the stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:41:18 +00:00
MichaelandClaude Opus 5 694b1257b9 fix(theme): remove every hardcoded Apple/Material colour left in the UI
Porting the palette was not enough: the Warm Cinema charter only reached the
widgets that read from AppColors. Nineteen screen and widget files still painted
Apple TV values inline, so the app kept showing blue — most visibly on the
selected navigation pill.

Swept out:
- 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the
  "add playlist" button
- 0xFF4FC3F7 and 0xFF1565C0 in the player overlays
- Colors.blueAccent / Colors.blue in dialogs
- Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals
- 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm
  ground reads blue, which is the charter's whole point)
- Colors.red / green / amber / yellow / grey* -> error, success, ratingGold,
  outlineVariant
- pure black ink on light fills -> onPrimaryFixed, a warm near-black
- the white focus glow -> an ember glow

Deliberately left true black: the video surface backgrounds and the scrims over
posters and video, where a warm tint would clash with the picture.

Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed.

Verified: no Material or Apple colour literal remains outside lib/core/theme;
flutter analyze reports no errors; 19/19 tests pass; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:39:58 +02:00
24 changed files with 930 additions and 361 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# Construit l'APK release et publie une GitHub Release à chaque mise à jour
# de main (nouvelle version de l'app). Sur une pull request ou un lancement
# manuel (workflow_dispatch), l'APK est seulement construit et déposé en
# artifact — aucune release n'est publiée.
name: Build APK
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.35.3
cache: true
- name: Read app version from pubspec.yaml
id: version
run: echo "version=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d+ -f1)" >> "$GITHUB_OUTPUT"
- run: flutter pub get
# Keystore fixe injecté via secrets : sans lui, Gradle signerait avec une
# clé debug générée sur le runner (différente à chaque build) et l'APK ne
# s'installerait pas par-dessus une version déjà présente sur l'appareil.
- name: Set up release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for v in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
if [ -z "${!v}" ]; then
echo "::error::Secret ANDROID_$v manquant — impossible de signer l'APK avec une clé stable."
exit 1
fi
done
echo "$KEYSTORE_BASE64" | base64 -d > android/app/release.jks
{
echo "storeFile=release.jks"
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "keyPassword=$KEY_PASSWORD"
} > android/key.properties
- name: Build release APK
run: flutter build apk --release
- name: Rename APK with version
run: cp build/app/outputs/flutter-apk/app-release.apk "xtremobile-v${{ steps.version.outputs.version }}.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: xtremobile-v${{ steps.version.outputs.version }}-apk
path: xtremobile-v${{ steps.version.outputs.version }}.apk
# La release n'est créée que sur main : un même numéro de version met à
# jour la release existante au lieu d'en créer une seconde.
- name: Publish GitHub Release
if: github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: XtremMobile v${{ steps.version.outputs.version }}
files: xtremobile-v${{ steps.version.outputs.version }}.apk
generate_release_notes: true
+30 -3
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +7,18 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// Signature release stable : android/key.properties (ignoré par git) pointe vers
// un keystore fixe. Sans ce fichier, on retombe sur la clé debug locale pour que
// `flutter run --release` continue de marcher. La CI DOIT fournir key.properties,
// sinon chaque build serait signé avec une clé debug aléatoire et l'APK ne
// s'installerait pas par-dessus une version existante.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "com.example.xtremflow"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +44,24 @@ android {
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+124 -117
View File
@@ -1,115 +1,121 @@
import 'package:flutter/material.dart';
/// XtremFlow — "Warm Cinema" Design System (ported from the web app)
/// XtremFlow — "Apple Glass Gray" Design System
///
/// Palette: charbon chaud + crème + orange brûlé (ember).
/// Inspiration: salle de projection, pellicule, affiches 70s.
/// Palette : graphite neutre + blanc cassé + bleu système Apple.
/// Inspiration : verre dépoli iOS/visionOS — des gris francs, jamais un noir
/// pur, pour une interface nettement moins sombre que l'ancien thème.
///
/// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur
/// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`,
/// coûteux à chaque frame sur les Android d'entrée de gamme.
///
/// Les noms hérités de l'ancienne charte Apple TV (`appleBlue`,
/// `glossyCardGradient`, `appleTvGradient`, …) sont conservés en alias pour ne
/// pas casser les sites d'appel existants ; ils pointent désormais vers les
/// teintes chaudes.
/// coûteux à chaque frame sur les Android d'entrée de gamme. L'effet "glass"
/// est simulé par des gris élevés + liserés blancs à faible opacité.
class AppColors {
AppColors._();
// ============ BASE LEVEL 0 ============
/// Noir chaud — le "noir de salle", jamais un noir pur bleuté.
static const Color baseLevel0 = Color(0xFF0B0908);
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
static const Color baseLevel0 = Color(0xFF1B1B1F);
// ============ BACKGROUNDS ============
static const Color background = Color(0xFF100C0A);
static const Color onBackground = Color(0xFFF3E9DF);
static const Color background = Color(0xFF26262C);
static const Color onBackground = Color(0xFFF5F5F7);
static const Color surface = Color(0xFF100C0A);
static const Color surfaceDim = Color(0xFF0B0908);
static const Color surfaceBright = Color(0xFF3C322C);
static const Color surface = Color(0xFF26262C);
static const Color surfaceDim = Color(0xFF1F1F24);
static const Color surfaceBright = Color(0xFF54545E);
// Surface Containers (hiérarchie Material 3, toutes opaques)
static const Color surfaceContainerLowest = Color(0xFF070505);
static const Color surfaceContainerLow = Color(0xFF181310);
static const Color surfaceContainer = Color(0xFF1F1815);
static const Color surfaceContainerHigh = Color(0xFF2A211C);
static const Color surfaceContainerHighest = Color(0xFF352A24);
static const Color surfaceVariant = Color(0xFF1F1815);
static const Color surfaceContainerLowest = Color(0xFF1B1B1F);
static const Color surfaceContainerLow = Color(0xFF2C2C33);
static const Color surfaceContainer = Color(0xFF32323A);
static const Color surfaceContainerHigh = Color(0xFF3B3B44);
static const Color surfaceContainerHighest = Color(0xFF45454F);
static const Color surfaceVariant = Color(0xFF32323A);
// ============ SURFACE / ON SURFACE ============
static const Color onSurface = Color(0xFFF3E9DF); // crème
static const Color onSurfaceVariant = Color(0xFFC5B3A5); // crème atténué
static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
// ============ PRIMARY (Ember / orange brûlé) ============
static const Color primary = Color(0xFFFFB68C); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF521B00);
static const Color primaryContainer = Color(0xFFD9541F); // remplissage ember
static const Color onPrimaryContainer = Color(0xFF2B0A00);
static const Color inversePrimary = Color(0xFFA63B10);
// ============ PRIMARY (Bleu système Apple) ============
static const Color primary = Color(0xFF8AB9FF); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF00295C);
static const Color primaryContainer = Color(0xFF0A84FF); // remplissage bleu
static const Color onPrimaryContainer = Color(0xFFEAF2FF);
static const Color inversePrimary = Color(0xFF0066CC);
/// Braise profonde — réservée aux **remplissages portant du texte**.
/// `primaryContainer` (#D9541F) est trop lumineux : du blanc dessus plafonne
/// vers 3,9:1, sous le seuil AA de 4,5:1. Cette variante passe le seuil tout
/// en restant la même couleur perçue.
static const Color primaryFill = Color(0xFFA8360C);
/// Bleu profond — réservé aux **remplissages portant du texte**.
/// `primaryContainer` (#0A84FF) est trop lumineux : du blanc dessus plafonne
/// sous le seuil AA de 4,5:1. Cette variante passe le seuil tout en restant
/// la même couleur perçue.
static const Color primaryFill = Color(0xFF0A6FDC);
// ============ SECONDARY (Terre cuite / argile) ============
static const Color secondary = Color(0xFFE7C3AC);
static const Color onSecondary = Color(0xFF442A1B);
static const Color secondaryContainer = Color(0xFF5D4030);
static const Color onSecondaryContainer = Color(0xFFFFDCC8);
/// Encre froide — remplace le noir pur pour du texte/des icônes posés sur un
/// remplissage clair (blanc cassé, bleu clair). Garde le contraste sans le
/// décalage d'un noir pur.
static const Color onPrimaryFixed = Color(0xFF002451);
static const Color primaryFixed = Color(0xFFD6E5FF);
static const Color primaryFixedDim = Color(0xFF8AB9FF);
static const Color onPrimaryFixedVariant = Color(0xFF0A5BB5);
// ============ TERTIARY (Laiton / doré patiné) ============
static const Color tertiary = Color(0xFFD9C88C);
static const Color onTertiary = Color(0xFF3A3016);
static const Color tertiaryContainer = Color(0xFF52472A);
static const Color onTertiaryContainer = Color(0xFFF6E4A6);
// ============ SECONDARY (Argent / gris clair) ============
static const Color secondary = Color(0xFFD1D1D6);
static const Color onSecondary = Color(0xFF303036);
static const Color secondaryContainer = Color(0xFF44444D);
static const Color onSecondaryContainer = Color(0xFFE8E8ED);
// ============ TERTIARY (Cyan glacé) ============
static const Color tertiary = Color(0xFF7DD3F2);
static const Color onTertiary = Color(0xFF003544);
static const Color tertiaryContainer = Color(0xFF1C4D60);
static const Color onTertiaryContainer = Color(0xFFC8ECFA);
// ============ ERROR ============
static const Color error = Color(0xFFFFB4A6);
static const Color onError = Color(0xFF5F1409);
static const Color errorContainer = Color(0xFF8C2416);
static const Color onErrorContainer = Color(0xFFFFDAD4);
static const Color error = Color(0xFFFFB4AB);
static const Color onError = Color(0xFF690005);
static const Color errorContainer = Color(0xFF93000A);
static const Color onErrorContainer = Color(0xFFFFDAD6);
// ============ OUTLINE ============
static const Color outline = Color(0xFF9C8878);
static const Color outlineVariant = Color(0xFF4A3C33);
static const Color outline = Color(0xFF8E8E93);
static const Color outlineVariant = Color(0xFF48484E);
// ============ INVERSE SURFACE ============
static const Color inverseSurface = Color(0xFFF3E9DF);
static const Color inverseOnSurface = Color(0xFF2A211C);
static const Color surfaceTint = Color(0xFFFFB68C);
static const Color inverseSurface = Color(0xFFF2F2F7);
static const Color inverseOnSurface = Color(0xFF2C2C31);
static const Color surfaceTint = Color(0xFF8AB9FF);
// ============ TEXT / CONTENT ============
static const Color textPrimary = Color(0xFFF3E9DF);
static const Color textSecondary = Color(0xFFC5B3A5);
static const Color textTertiary = Color(0xFF9C8878);
static const Color textPrimary = Color(0xFFF5F5F7);
static const Color textSecondary = Color(0xFFC7C7CE);
static const Color textTertiary = Color(0xFF98989F);
// ============ CRÈME À OPACITÉ ============
// Un blanc pur sur fond chaud tire vers le bleu : on décline la crème.
static const Color onSurface06 = Color(0x0FF3E9DF);
static const Color onSurface12 = Color(0x1FF3E9DF);
static const Color onSurface24 = Color(0x3DF3E9DF);
static const Color onSurface38 = Color(0x61F3E9DF);
static const Color onSurface54 = Color(0x8AF3E9DF);
// ============ BLANC À OPACITÉ ============
// Blanc Apple (#F5F5F7) décliné en opacités pour rester cohérent partout.
static const Color onSurface06 = Color(0x0FF5F5F7);
static const Color onSurface12 = Color(0x1FF5F5F7);
static const Color onSurface24 = Color(0x3DF5F5F7);
static const Color onSurface38 = Color(0x61F5F5F7);
static const Color onSurface54 = Color(0x8AF5F5F7);
static const Color onSurface70 = Color(0xB3F5F5F7);
// ============ STATUS ============
static const Color success = Color(0xFF9BC46A); // olive lumineux
static const Color warning = Color(0xFFE9B23C); // ambre
static const Color info = Color(0xFFA3B8C4); // bleu-gris fumé
static const Color success = Color(0xFF32D74B); // vert système
static const Color warning = Color(0xFFFFD60A); // jaune système
static const Color info = Color(0xFF64D2FF); // cyan système
// ============ CATÉGORIES ============
static const Color live = Color(0xFFE5484D); // rouge enseigne
static const Color movies = Color(0xFFD9541F); // ember
static const Color series = Color(0xFFD9C88C); // laiton
static const Color live = Color(0xFFFF453A); // rouge système
static const Color movies = Color(0xFF0A84FF); // bleu système
static const Color series = Color(0xFFBF5AF2); // violet système
// ============ RATING / PREMIUM ============
static const Color ratingGold = Color(0xFFE9B23C);
static const Color premiumGold = Color(0xFFC9922A);
static const Color ratingGold = Color(0xFFFFD60A);
static const Color premiumGold = Color(0xFFE0B84C);
// ============ SHIMMER (placeholders de chargement) ============
static const Color shimmer = Color(0xFF2A211C);
static const Color shimmerHighlight = Color(0xFF3C302A);
static const Color shimmer = Color(0xFF32323A);
static const Color shimmerHighlight = Color(0xFF45454F);
// ============ BORDERS / FOCUS ============
static const Color border = outlineVariant;
@@ -117,61 +123,62 @@ class AppColors {
static const Color focusBorder = primaryContainer;
static const Color borderFocused = primaryContainer;
/// Liseré crème 8 % — définit l'arête d'une surface sans la faire flotter.
static const Color glassBorderColor = Color(0x14F3E9DF);
/// Liseré blanc 10 % — définit l'arête d'une surface "verre" sans la faire
/// flotter.
static const Color glassBorderColor = Color(0x1AFFFFFF);
// ============ SURFACES "GLASS" — OPAQUES ============
static const Color glassLevel1Bg = Color(0xFF181310);
static const Color glassLevel1Border = Color(0x14F3E9DF); // crème 8 %
static const Color glassLevel2Bg = Color(0xFF1F1815);
static const Color glassLevel2Border = Color(0x1FF3E9DF); // crème 12 %
static const Color glassLevel2InnerGlow = Color(0x3DD9541F); // ember 24 %
static const Color glassLevel1Bg = Color(0xFF2C2C33);
static const Color glassLevel1Border = Color(0x1AFFFFFF); // blanc 10 %
static const Color glassLevel2Bg = Color(0xFF32323A);
static const Color glassLevel2Border = Color(0x26FFFFFF); // blanc 15 %
static const Color glassLevel2InnerGlow = Color(0x3D0A84FF); // bleu 24 %
static const Color glassBackground = glassLevel2Bg;
static const Color glassBorder = glassLevel2Border;
// ============ GRADIENTS ============
/// Braise : orange brûlé vers rouge profond.
/// Bleu système : clair vers profond.
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFFE9701F), Color(0xFFC13A1C)],
colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Fond de salle : projecteur chaud en haut, obscurité en bas.
/// Fond "verre dépoli" : gris clair en haut, graphite en bas.
static const LinearGradient backgroundGradient = LinearGradient(
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// Halo de projection depuis le centre de l'écran.
/// Halo doux depuis le centre de l'écran.
static const RadialGradient projectorGradient = RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
stops: [0.0, 1.0],
);
/// Carte : arête éclairée en haut, matière sombre en bas.
/// Carte : arête éclairée en haut, matière grise en bas.
static const LinearGradient cardGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF352A24), Color(0xFF181310)],
colors: [Color(0xFF45454F), Color(0xFF2C2C33)],
stops: [0.0, 1.0],
);
/// Reflet chaud posé sur le haut d'une carte.
/// Reflet posé sur le haut d'une carte — l'effet "verre".
static const LinearGradient cardHighlight = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x1FF3E9DF), Color(0x00F3E9DF)],
colors: [Color(0x29FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
);
/// Voile d'affiche : lecture du texte par-dessus une jaquette.
static const LinearGradient posterScrim = LinearGradient(
colors: [Color(0x000B0908), Color(0xCC0B0908), Color(0xFF0B0908)],
colors: [Color(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
stops: [0.0, 0.55, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -179,7 +186,7 @@ class AppColors {
/// Voile latéral pour les héros pleine largeur.
static const LinearGradient heroScrim = LinearGradient(
colors: [Color(0xF20B0908), Color(0x990B0908), Color(0x000B0908)],
colors: [Color(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
stops: [0.0, 0.45, 1.0],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
@@ -187,43 +194,43 @@ class AppColors {
// ============ GLOW / OMBRES ============
static Color glowPrimary(double opacity) =>
const Color(0xFFD9541F).withValues(alpha: opacity);
const Color(0xFF0A84FF).withValues(alpha: opacity);
static Color glowPrimaryDim(double opacity) =>
const Color(0xFFFFB68C).withValues(alpha: opacity);
const Color(0xFF8AB9FF).withValues(alpha: opacity);
/// Ombre portée « projecteur » : profonde, chaude, jamais grise.
/// Ombre portée douce — moins dense qu'avant : le fond est plus clair.
static List<BoxShadow> lift({double intensity = 1.0}) => [
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.55 * intensity),
color: const Color(0xFF000000).withValues(alpha: 0.40 * intensity),
blurRadius: 28 * intensity,
spreadRadius: -8,
offset: Offset(0, 12 * intensity),
),
];
/// Halo ember pour l'élément focalisé (navigation TV / télécommande).
/// Halo bleu pour l'élément focalisé (navigation TV / télécommande).
/// Le nom historique `emberFocus` est conservé pour les sites d'appel.
static List<BoxShadow> emberFocus({double intensity = 1.0}) => [
BoxShadow(
color: const Color(0xFFD9541F).withValues(alpha: 0.45 * intensity),
color: const Color(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
blurRadius: 32 * intensity,
spreadRadius: -4,
),
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.6),
color: const Color(0xFF000000).withValues(alpha: 0.45),
blurRadius: 24,
spreadRadius: -10,
offset: const Offset(0, 14),
),
];
// ============ ALIAS DE COMPATIBILITÉ (ancienne charte Apple TV) ============
// Conservés pour ne pas casser les sites d'appel ; remappés sur les teintes
// chaudes. À retirer au fil des réécritures d'écrans.
// ============ ALIAS DE COMPATIBILITÉ ============
// Conservés pour ne pas casser les sites d'appel.
static const Color backgroundSecondary = surfaceContainerLow;
static const Color surfaceElevated = surfaceContainerHigh;
static const Color accent = secondary;
/// Ancien accent bleu Apple → braise portant du texte blanc (contraste AA).
/// Accent bleu Apple portant du texte blanc (contraste AA).
static const Color appleBlue = primaryFill;
static const RadialGradient appleTvGradient = projectorGradient;
@@ -266,27 +273,27 @@ class AppColors {
brightness: Brightness.dark,
);
/// Thème clair : « papier kraft » — même famille chaude, inversée.
/// Thème clair : gris système iOS, même famille neutre.
static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Color(0xFFA63B10),
primary: Color(0xFF0066CC),
onPrimary: Colors.white,
primaryContainer: Color(0xFFFFDBC8),
onPrimaryContainer: Color(0xFF360F00),
secondary: Color(0xFF765847),
primaryContainer: Color(0xFFD6E5FF),
onPrimaryContainer: Color(0xFF002451),
secondary: Color(0xFF54545E),
onSecondary: Colors.white,
secondaryContainer: Color(0xFFFFDCC8),
onSecondaryContainer: Color(0xFF2B160A),
tertiary: Color(0xFF6A5F3F),
secondaryContainer: Color(0xFFE3E3E8),
onSecondaryContainer: Color(0xFF1B1B1F),
tertiary: Color(0xFF00658E),
onTertiary: Colors.white,
error: Color(0xFFB3261E),
onError: Colors.white,
errorContainer: Color(0xFFFFDAD4),
onErrorContainer: Color(0xFF410E05),
surface: Color(0xFFFBF3EC),
onSurface: Color(0xFF201A16),
onSurfaceVariant: Color(0xFF52443B),
outline: Color(0xFF847369),
outlineVariant: Color(0xFFD6C3B7),
surface: Color(0xFFF2F2F7),
onSurface: Color(0xFF1C1C1E),
onSurfaceVariant: Color(0xFF48484E),
outline: Color(0xFF8E8E93),
outlineVariant: Color(0xFFD1D1D6),
brightness: Brightness.light,
);
}
+11 -12
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// XtremFlow — "Warm Cinema" theme (ported from the web app).
/// XtremFlow — "Apple Glass Gray" theme.
///
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
@@ -217,10 +217,9 @@ class AppTheme {
secondary: AppColors.textSecondary,
),
// Curseur crème — un curseur blanc pur jure sur fond chaud.
textSelectionTheme: const TextSelectionThemeData(
cursorColor: AppColors.primary,
selectionColor: Color(0x59D9541F),
selectionColor: Color(0x590A84FF),
selectionHandleColor: AppColors.primaryContainer,
),
@@ -334,7 +333,7 @@ class AppTheme {
activeTrackColor: AppColors.primaryContainer,
inactiveTrackColor: AppColors.onSurface24,
thumbColor: AppColors.primary,
overlayColor: Color(0x29D9541F),
overlayColor: Color(0x290A84FF),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
@@ -362,17 +361,17 @@ class AppTheme {
);
}
// ============ LIGHT THEME (« papier kraft ») ============
// ============ LIGHT THEME (gris système clair) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
const bgColor = Color(0xFFFBF3EC);
const bgColor = Color(0xFFF2F2F7);
const surfaceColor = Color(0xFFFFFFFF);
const primaryColor = Color(0xFFA63B10);
const textPrimaryColor = Color(0xFF201A16);
const textSecondaryColor = Color(0xFF52443B);
const textTertiaryColor = Color(0xFF847369);
const borderColor = Color(0xFFD6C3B7);
const primaryColor = Color(0xFF0066CC);
const textPrimaryColor = Color(0xFF1C1C1E);
const textSecondaryColor = Color(0xFF48484E);
const textTertiaryColor = Color(0xFF8E8E93);
const borderColor = Color(0xFFD8D8DE);
return baseTheme.copyWith(
colorScheme: AppColors.lightColorScheme,
@@ -386,7 +385,7 @@ class AppTheme {
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor,
selectionColor: Color(0x4DA63B10),
selectionColor: Color(0x4D0066CC),
selectionHandleColor: primaryColor,
),
+4 -3
View File
@@ -10,6 +10,7 @@ library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Standard cache manager with 200MB size limit
class AppCacheManager {
@@ -103,7 +104,7 @@ class ImageCacheConfig {
width: width,
height: height,
decoration: BoxDecoration(
color: const Color(0xFF2C2C2E),
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
@@ -111,10 +112,10 @@ class ImageCacheConfig {
width: width,
height: height,
decoration: BoxDecoration(
color: const Color(0xFF3A3A3C),
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.image_not_supported, color: Color(0xFF8E8E93)),
child: const Icon(Icons.image_not_supported, color: AppColors.textTertiary),
),
);
}
@@ -150,7 +150,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
color: AppColors.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -161,7 +161,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
item.subtitle!,
style: GoogleFonts.inter(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
color: AppColors.onSurface.withOpacity(0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -227,8 +227,8 @@ class _HeroButtonState extends State<_HeroButton> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: widget.isPrimary
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
? (_isHovered ? AppColors.onSurface : AppColors.onSurface.withOpacity(0.9))
: (_isHovered ? AppColors.onSurface.withOpacity(0.2) : AppColors.onSurface.withOpacity(0.1)),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Row(
@@ -236,14 +236,14 @@ class _HeroButtonState extends State<_HeroButton> {
children: [
Icon(
widget.icon,
color: widget.isPrimary ? Colors.black : Colors.white,
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
size: 20,
),
const SizedBox(width: 8),
Text(
widget.label,
style: GoogleFonts.inter(
color: widget.isPrimary ? Colors.black : Colors.white,
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
fontWeight: FontWeight.w600,
),
),
@@ -134,7 +134,7 @@ class _GradientButtonState extends State<GradientButton>
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.black,
color: AppColors.onPrimaryFixed,
),
)
: Row(
@@ -143,13 +143,13 @@ class _GradientButtonState extends State<GradientButton>
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.icon != null) ...[
Icon(widget.icon, color: Colors.black, size: 20),
Icon(widget.icon, color: AppColors.onPrimaryFixed, size: 20),
const SizedBox(width: 8),
],
Text(
widget.label,
style: const TextStyle(
color: Colors.black,
color: AppColors.onPrimaryFixed,
fontWeight: FontWeight.w600,
fontSize: 14,
),
@@ -247,12 +247,12 @@ class LiveBadge extends StatelessWidget {
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, size: 6, color: Colors.white),
Icon(Icons.circle, size: 6, color: AppColors.onSurface),
SizedBox(width: 4),
Text(
'LIVE',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.w700,
),
@@ -427,11 +427,11 @@ class _MediaCardState extends State<MediaCard>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
const Icon(Icons.star, size: 10, color: AppColors.ratingGold),
const SizedBox(width: 4),
Text(
widget.rating!,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
style: const TextStyle(color: AppColors.onSurface, fontSize: 10, fontWeight: FontWeight.bold),
),
],
),
@@ -449,7 +449,7 @@ class _MediaCardState extends State<MediaCard>
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Icon(Icons.check, size: 12, color: Colors.white),
child: const Icon(Icons.check, size: 12, color: AppColors.onSurface),
),
),
],
+4 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class DigitalClock extends StatefulWidget {
const DigitalClock({super.key});
@@ -31,16 +32,16 @@ class _DigitalClockState extends State<DigitalClock> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.05)),
border: Border.all(color: AppColors.onSurface.withOpacity(0.05)),
),
child: Text(
timeString,
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
color: AppColors.onSurface,
),
),
);
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key});
@@ -11,7 +12,7 @@ class ThemedLoading extends StatelessWidget {
center: Alignment(0, -0.2),
radius: 1.5,
colors: [
Color(0xFF2C2C2E),
AppColors.surfaceContainerHigh,
Color(0xFF000000),
],
stops: [0.0, 1.0],
@@ -19,7 +20,7 @@ class ThemedLoading extends StatelessWidget {
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
color: AppColors.onSurface,
strokeWidth: 3,
),
),
+322 -14
View File
@@ -80,6 +80,16 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode();
final FocusNode _nextFocusNode = FocusNode();
final FocusNode _channelListButtonFocusNode = FocusNode();
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
/// directement dessus à l'ouverture de la liste.
final FocusNode _channelListItemFocusNode = FocusNode();
// [TiviMate] Channel List Sidebar
bool _showChannelList = false;
Timer? _channelListTimer;
final ScrollController _channelListScrollController = ScrollController();
@override
void initState() {
@@ -132,6 +142,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_controlsTimer = null;
_stabilizationTimer?.cancel();
_stabilizationTimer = null;
_channelListTimer?.cancel();
_channelListTimer = null;
final controller = _controller;
_controller = null;
@@ -339,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
void _switchChannel(int index) {
if (widget.channels == null) return;
setState(() => _currentIndex = index);
setState(() {
_currentIndex = index;
_showChannelList = false; // close sidebar on switch
});
_channelListTimer?.cancel();
_onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
_loadStream();
}
/// Open/close the channel list sidebar
void _toggleChannelList() {
setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) {
_scrollToCurrentChannel();
_restartChannelListTimer();
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
// liste sans étape intermédiaire. Second essai après l'animation de
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showChannelList) {
_channelListItemFocusNode.requestFocus();
}
});
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted &&
_showChannelList &&
!_channelListItemFocusNode.hasFocus) {
_channelListItemFocusNode.requestFocus();
}
});
} else {
_channelListTimer?.cancel();
}
}
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
void _restartChannelListTimer() {
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
});
}
/// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_channelListScrollController.hasClients) return;
const itemHeight = 64.0;
final offset = (_currentIndex * itemHeight) -
(_channelListScrollController.position.viewportDimension / 2) +
(itemHeight / 2);
_channelListScrollController.animateTo(
offset.clamp(0, _channelListScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
bool _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final key = event.logicalKey;
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
if (_showChannelList) {
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
return true;
}
_restartChannelListTimer();
return false;
}
// Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_toggleChannelList();
return true;
}
}
if (key == LogicalKeyboardKey.channelUp) {
if (widget.channels != null) {
_switchChannel((_currentIndex + 1) % widget.channels!.length);
@@ -397,6 +489,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_epgTimer?.cancel();
_controlsTimer?.cancel();
_stabilizationTimer?.cancel();
_channelListTimer?.cancel();
_channelListScrollController.dispose();
_controller?.pause(); // Force stop audio immediately
_controller?.dispose();
_xtreamService?.dispose();
@@ -404,6 +498,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
super.dispose();
}
@@ -416,9 +512,26 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
: widget.title;
return Scaffold(
// True black behind the video surface, never the warm ink.
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _onUserInteraction,
onTap: () {
if (_showChannelList) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
} else {
_onUserInteraction();
}
},
// Swipe from right edge → open channel list
onHorizontalDragEnd: (details) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null &&
details.primaryVelocity != null &&
details.primaryVelocity! < -300) {
_toggleChannelList();
}
},
child: Stack(
children: [
// Video Surface
@@ -453,7 +566,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppColors.onSurface,
size: 28,
),
),
@@ -472,7 +585,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text(
_currentTime,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -495,7 +608,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
),
),
),
@@ -508,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
// OSD (EPG + Controls)
if (_showControls) _buildOSD(title),
// [TiviMate] Channel list sidebar
if (_showChannelList &&
widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
_buildChannelListSidebar(),
],
),
),
@@ -554,7 +674,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
border: Border.all(color: AppColors.onSurface12),
),
child: IntrinsicHeight(
child: Row(
@@ -579,7 +699,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.skip_previous,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
@@ -591,7 +711,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 56,
),
),
@@ -605,11 +725,25 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.skip_next,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Channel list sidebar (liste des chaînes de la catégorie)
if (widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
TVFocusable(
focusNode: _channelListButtonFocusNode,
onPressed: _toggleChannelList,
child: const Icon(
Icons.playlist_play_rounded,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
TVFocusable(
@@ -642,7 +776,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: const Icon(
Icons
.grid_on, // Icon representing interlaced grid/mesh
color: Colors.white,
color: AppColors.onSurface,
size: 32,
),
),
@@ -684,7 +818,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text(
title,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -710,7 +844,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white10,
backgroundColor: AppColors.onSurface12,
color: AppColors.primary,
minHeight: 4,
),
@@ -718,7 +852,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
const SizedBox(height: 6),
Text(
next,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -758,7 +892,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
min: 0,
max: _duration.inSeconds.toDouble(),
activeColor: AppColors.primary,
inactiveColor: Colors.white24,
inactiveColor: AppColors.onSurface24,
onChanged: (v) => setState(() {
_position = Duration(seconds: v.toInt());
_isSeeking = true;
@@ -773,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
);
}
/// [TiviMate] Channel list sidebar — slide-in panel on the right
Widget _buildChannelListSidebar() {
final channels = widget.channels!;
return Positioned(
top: 0,
bottom: 0,
right: 0,
child: Container(
width: 280,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [Color(0xE6000000), Color(0x99000000)],
),
),
child: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list,
color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
GestureDetector(
onTap: () {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
},
child: const Icon(Icons.close,
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: AppColors.primary,
width: 3,
),
)
: null,
),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
// Logo
Container(
width: 40,
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ch.name,
style: TextStyle(
color: isActive
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'CH ${index + 1}',
style: const TextStyle(
color: AppColors.onSurface38,
fontSize: 11,
),
),
],
),
),
// Active indicator
if (isActive)
const Icon(
Icons.play_arrow,
color: AppColors.primary,
size: 18,
),
],
),
),
);
},
),
),
),
],
),
),
),
);
}
}
@@ -236,7 +236,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
? 'Playlist modifiée'
: 'Playlist ajoutée',
),
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
),
);
}
@@ -125,7 +125,7 @@ class _MobilePlaylistSelectionScreenState
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: GoogleFonts.inter(color: Colors.white),
style: GoogleFonts.inter(color: AppColors.onSurface),
),
TextButton(
onPressed: () => ref.invalidate(playlistsProvider),
@@ -143,14 +143,14 @@ class _MobilePlaylistSelectionScreenState
const Icon(
Icons.playlist_add,
size: 64,
color: Colors.white54,
color: AppColors.onSurface54,
),
const SizedBox(height: 24),
Text(
'Aucune playlist configurée',
style: GoogleFonts.inter(
fontSize: 18,
color: Colors.white70,
color: AppColors.onSurface70,
),
),
const SizedBox(height: 32),
@@ -163,18 +163,18 @@ class _MobilePlaylistSelectionScreenState
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
color: AppColors.primaryFill,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white, size: 20),
Icon(Icons.add, color: AppColors.onSurface, size: 20),
SizedBox(width: 10),
Text(
'Ajouter une Playlist',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
fontSize: 15,
),
@@ -268,21 +268,21 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(label, style: const TextStyle(color: Colors.white)),
title: Text(label, style: const TextStyle(color: AppColors.onSurface)),
content: TextField(
controller: controller,
autofocus: true,
obscureText: obscure,
keyboardType: keyboardType,
textInputAction: TextInputAction.done,
style: const TextStyle(color: Colors.white),
style: const TextStyle(color: AppColors.onSurface),
decoration: const InputDecoration(
labelStyle: TextStyle(color: Colors.white70),
labelStyle: TextStyle(color: AppColors.onSurface70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
borderSide: BorderSide(color: AppColors.onSurface24),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
borderSide: BorderSide(color: AppColors.onSurface),
),
),
onSubmitted: (val) => Navigator.pop(ctx, val),
@@ -327,7 +327,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07),
color: AppColors.onSurface.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
),
child: Row(
@@ -341,7 +341,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text(
label,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 11,
fontWeight: FontWeight.w500,
),
@@ -350,7 +350,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text(
displayValue,
style: TextStyle(
color: value.isEmpty ? Colors.white38 : Colors.white,
color: value.isEmpty ? AppColors.onSurface38 : AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -360,7 +360,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
],
),
),
const Icon(Icons.edit, color: Colors.white38, size: 18),
const Icon(Icons.edit, color: AppColors.onSurface38, size: 18),
],
),
),
@@ -374,7 +374,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
backgroundColor: AppColors.surface,
title: Text(
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
style: GoogleFonts.inter(color: AppColors.onSurface),
),
content: SizedBox(
width: 480,
@@ -435,8 +435,8 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
title: const Text('Confirmer', style: TextStyle(color: AppColors.onSurface)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: AppColors.onSurface70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -444,7 +444,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
@@ -458,7 +458,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
child: Text('Supprimer', style: TextStyle(color: AppColors.error)),
),
),
TVFocusable(
@@ -466,7 +466,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
child: Text('Annuler', style: TextStyle(color: AppColors.onSurface70)),
),
),
TVFocusable(
@@ -482,14 +482,14 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _name.isEmpty || _dns.isEmpty
? Colors.white12
? AppColors.onSurface12
: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_isSaving ? '...' : 'Enregistrer',
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
@@ -129,7 +129,7 @@ class _MobileSeriesDetailScreenState
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
onPressed: () => Navigator.pop(context),
tooltip: 'Back',
),
@@ -149,7 +149,7 @@ class _MobileSeriesDetailScreenState
maxHeightDiskCache: 720,
cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) =>
Container(color: Colors.grey.shade900),
Container(color: AppColors.surfaceContainerLowest),
),
// Gradient Overlay
Container(
@@ -190,7 +190,7 @@ class _MobileSeriesDetailScreenState
const Icon(
Icons.star,
size: 16,
color: Colors.amber,
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
@@ -261,15 +261,15 @@ class _MobileSeriesDetailScreenState
}
},
backgroundColor: isDark
? const Color(0xFF1C1C1E)
: Colors.grey.shade300,
selectedColor: isDark ? Colors.white : Colors.blue,
? AppColors.surfaceContainer
: AppColors.surfaceContainerHigh,
selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
labelStyle: TextStyle(
color: isSelected
? (isDark ? Colors.black : Colors.white)
? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
: (isDark
? Colors.grey.shade400
: Colors.grey.shade700),
? AppColors.textSecondary
: AppColors.outlineVariant),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
@@ -323,10 +323,10 @@ class _MobileSeriesDetailScreenState
}
// Force dark mode colors since background is hardcoded dark
const textColor = Colors.white;
final secondaryTextColor = Colors.grey.shade400;
const surfaceColor = Color(0xFF1C1C1E);
const primaryColor = Colors.white; // Or accent color if preferred
const textColor = AppColors.onSurface;
final secondaryTextColor = AppColors.textSecondary;
const surfaceColor = AppColors.surfaceContainer;
const primaryColor = AppColors.onSurface; // Or accent color if preferred
return InkWell(
onTap: () {
@@ -356,7 +356,7 @@ class _MobileSeriesDetailScreenState
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
),
child: Row(
children: [
@@ -374,7 +374,7 @@ class _MobileSeriesDetailScreenState
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched
? Colors.black // Checkmark on white bg
? AppColors.onPrimaryFixed // Checkmark on white bg
: primaryColor, // Play icon
size: 20,
),
@@ -413,7 +413,7 @@ class _MobileSeriesDetailScreenState
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
minHeight: 2,
),
],
@@ -73,6 +73,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
final FocusNode _sliderFocusNode = FocusNode();
final FocusNode _backFocusNode = FocusNode();
final FocusNode _audioFocusNode = FocusNode();
final FocusNode _channelListButtonFocusNode = FocusNode();
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
/// directement dessus à l'ouverture de la liste.
final FocusNode _channelListItemFocusNode = FocusNode();
bool _isLoading = true;
bool _isFirstLoad = true; // true = black overlay; false = silent spinner
@@ -135,6 +140,35 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer();
}
/// Dernier contrôle de l'OSD qui avait le focus télécommande.
///
/// Quand l'OSD se masque (auto-hide 5s), ses FocusNodes sont démontés et le
/// focus part « on ne sait où » : à la réouverture l'utilisateur devait
/// re-naviguer depuis un point aléatoire jusqu'au bouton de zapping. On
/// mémorise donc le dernier bouton utilisé pour le re-focaliser d'office.
FocusNode? _lastFocusedControl;
void _noteFocus(FocusNode node) {
_lastFocusedControl = node;
_resetControlsTimer();
}
/// Réaffiche l'OSD en redonnant le focus au dernier contrôle utilisé
/// (Play/Pause par défaut) — un seul appui suffit alors pour re-zapper.
void _showControlsRestoringFocus() {
setState(() => _showControls = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl ?? _playPauseFocusNode;
if (target.canRequestFocus) {
target.requestFocus();
} else {
_playPauseFocusNode.requestFocus();
}
});
_resetControlsTimer();
}
@override
void initState() {
super.initState();
@@ -189,10 +223,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
// --- Buffering stable (TiviMate Medium ~ 8-10s) ---
(_player.platform as dynamic)?.setProperty('cache-secs', '8');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
// --- Buffering basse latence (TiviMate Low ~ 3-5s) ---
// 8s/10s de readahead ajoutaient autant de retard sur le direct : 3s de
// cache + 5s de readahead suffisent à absorber la gigue réseau tout en
// restant nettement plus près du live edge.
(_player.platform as dynamic)?.setProperty('cache-secs', '3');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '60000000'); // 60MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '5');
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
// --- Plafond du buffer ARRIÈRE (stabilité longue durée) ---
// mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour
@@ -234,15 +271,19 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
}
// [V11.0 Perfect Restoration] Recovering settings from working commit a1628a6b
// Forced software re-sampling handles ANY stubborn 5.1/LATM/24-bit audio track.
// [Audio Fix] L'ancienne chaîne `af=lavrresample,format=channels=stereo:
// sample_fmts=s16` mélangeait la syntaxe FFmpeg et mpv : `lavrresample`
// n'existe plus dans les builds récents et `sample_fmts` n'est pas une
// option du filtre `format` de mpv. Quand la chaîne échoue à s'initialiser
// pour un codec donné (AC3/EAC3/LATM selon la chaîne TV), mpv coupe la
// sortie audio ⇒ « certaines chaînes n'ont pas de son ».
// Le downmix natif `audio-channels=stereo` fait la même chose (5.1 → 2.0)
// sans chaîne de filtres fragile, et l'AO négocie lui-même le format.
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
(_player.platform as dynamic)?.setProperty('af', 'lavrresample,format=channels=stereo:sample_fmts=s16');
(_player.platform as dynamic)?.setProperty('audio-format', 's16');
(_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses)
(_player.platform as dynamic)?.setProperty('audio-samplerate', '48000');
(_player.platform as dynamic)?.setProperty('aid', 'auto');
// --- Seeking & Sync globaux ---
@@ -270,11 +311,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
);
// --- Probing du flux (une seule entrée, consolidée) ---
// Précédent : 2 setProperty('demuxer-lavf-o') en conflit
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
// Ces valeurs sont des PLAFONDS : FFmpeg s'arrête dès que toutes les pistes
// sont identifiées, donc les relever ne ralentit pas les flux sains.
// 1MB/2s ratait la piste audio (AAC-LATM déclaré tard) sur certaines
// chaînes ⇒ pas de son. 2MB/3s les récupère sans pénaliser le zapping.
(_player.platform as dynamic)?.setProperty(
'demuxer-lavf-o',
'analyzeduration=2000000,probesize=1000000',
'analyzeduration=3000000,probesize=2000000',
);
// --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) ---
@@ -566,6 +610,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_sliderFocusNode.dispose();
_backFocusNode.dispose();
_audioFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
// Restore normal orientation
SystemChrome.setPreferredOrientations([
@@ -654,7 +700,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green.shade700,
backgroundColor: AppColors.success,
),
);
} else {
@@ -997,6 +1043,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_showChannelList = false; // close sidebar on switch
_showControls = true; // show regular OSD
});
// Garde le focus sur le bouton de zap qui vient d'être pressé : l'appui
// suivant sur OK rezappe immédiatement, sans re-navigation dans l'OSD.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl;
if (target != null && target.canRequestFocus && !target.hasFocus) {
target.requestFocus();
}
});
_resetControlsTimer();
_loadStream();
}
@@ -1006,15 +1061,36 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) {
_scrollToCurrentChannel();
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
_restartChannelListTimer();
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
// liste sans étape intermédiaire. Second essai après l'animation de
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showChannelList) {
_channelListItemFocusNode.requestFocus();
}
});
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted &&
_showChannelList &&
!_channelListItemFocusNode.hasFocus) {
_channelListItemFocusNode.requestFocus();
}
});
} else {
_channelListTimer?.cancel();
}
}
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
void _restartChannelListTimer() {
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
});
}
/// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1049,12 +1125,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
void _toggleControls() {
// Always show controls on tap, then start auto-hide timer
if (!_showControls) {
setState(() => _showControls = true);
// Request focus on Play/Pause button when opening
WidgetsBinding.instance.addPostFrameCallback((_) {
// If controls opened, default to Play/Pause
_playPauseFocusNode.requestFocus();
});
// Reopen on the last focused control (falls back to Play/Pause)
_showControlsRestoringFocus();
return;
}
_resetControlsTimer();
}
@@ -1066,6 +1139,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
try {
final key = event.logicalKey;
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
if (_showChannelList) {
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
return true;
}
_restartChannelListTimer();
return false;
}
// Explicit Play
if (key == LogicalKeyboardKey.mediaPlay) {
if (!_isPlaying) _player.play();
@@ -1087,8 +1175,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.mediaPlayPause) {
// If controls are hidden, toggle them + play/pause
if (!_showControls) {
// Live : OK réaffiche seulement l'OSD (focus restauré sur le dernier
// bouton utilisé) — surtout ne PAS mettre le direct en pause.
if (widget.streamType == model.StreamType.live &&
key != LogicalKeyboardKey.mediaPlayPause) {
_showControlsRestoringFocus();
return true;
}
// VOD : comportement historique — toggle play/pause + OSD
_togglePlayPause();
_toggleControls();
return true;
@@ -1116,8 +1211,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (widget.streamType != model.StreamType.live) {
final newPos = _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero ? Duration.zero : newPos);
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
_onUserInteraction();
return true;
}
return false; // Let Focus handle navigation
@@ -1131,8 +1228,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (newPos < _duration) {
_player.seek(newPos);
}
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
_onUserInteraction();
return true;
}
return false; // Let Focus handle navigation
@@ -1142,7 +1241,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.arrowDown) {
if (!_showControls) {
_onUserInteraction();
_showControlsRestoringFocus();
return true;
}
return false; // Let Focus handle navigation
@@ -1168,16 +1267,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
}
// OK / Select when channel list is open → switch to focused channel
if (_showChannelList &&
(key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.numpadEnter)) {
// Channel list handles its own tap, this closes it
setState(() => _showChannelList = false);
return true;
}
// Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
@@ -1293,6 +1382,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Loading Overlay — black only on FIRST load
if (_isLoading && _errorMessage == null)
Container(
// True black, not the warm ink: this sits over the video surface.
color: Colors.black,
child: _buildLoadingView(),
),
@@ -1313,7 +1403,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white70,
color: AppColors.onSurface70,
),
),
),
@@ -1349,7 +1439,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppColors.onSurface,
),
onPressed: () => Navigator.pop(context),
),
@@ -1380,7 +1470,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
title,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -1392,7 +1482,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
_currentTime,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -1485,7 +1575,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
border: Border.all(color: AppColors.onSurface12),
),
child: IntrinsicHeight(
child: Row(
@@ -1503,10 +1593,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _prevFocusNode,
onPressed: _playPrevious,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_prevFocusNode),
child: const Icon(
Icons.skip_previous,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
@@ -1516,12 +1606,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_playPauseFocusNode),
child: Icon(
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 56,
),
),
@@ -1531,15 +1621,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _nextFocusNode,
onPressed: _playNext,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_nextFocusNode),
child: const Icon(
Icons.skip_next,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Channel list sidebar (liste des chaînes de la catégorie)
if (widget.channels != null && widget.channels!.isNotEmpty)
TVFocusable(
focusNode: _channelListButtonFocusNode,
onPressed: _toggleChannelList,
onFocus: () => _noteFocus(_channelListButtonFocusNode),
child: const Icon(
Icons.playlist_play_rounded,
color: AppColors.onSurface,
size: 36,
),
),
// Disable Deinterlace Button
if (widget.forceDeinterlace) ...[
const SizedBox(width: 16),
@@ -1570,7 +1673,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
onFocus: _resetControlsTimer,
child: const Icon(
Icons.grid_off,
color: Colors.white,
color: AppColors.onSurface,
size: 32,
),
),
@@ -1618,7 +1721,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: widget.channels![_currentIndex].streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38),
const Icon(Icons.tv, color: AppColors.onSurface38),
),
),
Expanded(
@@ -1628,7 +1731,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
title,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -1639,7 +1742,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
nowPlaying,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -1654,7 +1757,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
startTime,
style: const TextStyle(
color: Colors.white54, fontSize: 11),
color: AppColors.onSurface54, fontSize: 11),
),
const SizedBox(width: 6),
Expanded(
@@ -1663,9 +1766,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 4,
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7),
AppColors.primary,
),
),
),
@@ -1674,7 +1777,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
endTime,
style: const TextStyle(
color: Colors.white54, fontSize: 11),
color: AppColors.onSurface54, fontSize: 11),
),
],
),
@@ -1714,7 +1817,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.82),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white12),
border: Border.all(color: AppColors.onSurface12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -1728,7 +1831,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white10,
color: AppColors.onSurface12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -1736,7 +1839,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 32),
const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
),
)
@@ -1747,9 +1850,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white10,
color: AppColors.onSurface12,
),
child: const Icon(Icons.tv, color: Colors.white38, size: 32),
child: const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
// Channel info
@@ -1765,13 +1868,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0),
color: const AppColors.primaryFill,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'CH $chNum',
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
@@ -1781,7 +1884,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Text(
ch.name,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -1797,9 +1900,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: SizedBox(
height: 3,
child: LinearProgressIndicator(
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)),
AppColors.primary),
),
),
)
@@ -1808,7 +1911,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
nowPlaying,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 13,
),
maxLines: 1,
@@ -1820,9 +1923,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)),
AppColors.primary),
),
),
],
@@ -1861,18 +1964,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white12),
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list, color: Colors.white70, size: 18),
const Icon(Icons.list, color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
@@ -1881,34 +1984,47 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
GestureDetector(
onTap: () => setState(() => _showChannelList = false),
child: const Icon(Icons.close,
color: Colors.white54, size: 18),
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: ListView.builder(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return GestureDetector(
onTap: () {
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? const Color(0xFF1565C0).withOpacity(0.6)
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: Color(0xFF4FC3F7),
color: AppColors.primary,
width: 3,
),
)
@@ -1924,7 +2040,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: Colors.white10,
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
@@ -1935,12 +2051,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: Colors.white38,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: Colors.white38, size: 20),
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
@@ -1952,8 +2068,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
ch.name,
style: TextStyle(
color: isActive
? Colors.white
: Colors.white70,
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
@@ -1965,7 +2081,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
'CH ${index + 1}',
style: const TextStyle(
color: Colors.white38,
color: AppColors.onSurface38,
fontSize: 11,
),
),
@@ -1976,7 +2092,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (isActive)
const Icon(
Icons.play_arrow,
color: Color(0xFF4FC3F7),
color: AppColors.primary,
size: 18,
),
],
@@ -1984,6 +2100,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
),
);
},
),
),
),
],
@@ -2016,12 +2133,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos - const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_prevFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.replay_10,
color: Colors.white,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
@@ -2037,7 +2154,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_playPauseFocusNode),
scale: 1.1,
borderRadius: BorderRadius.circular(50),
child: IconButton(
@@ -2046,7 +2163,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
),
onPressed: _togglePlayPause,
),
@@ -2062,12 +2179,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos + const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_nextFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.forward_10,
color: Colors.white,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
@@ -2103,7 +2220,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
_formatDuration(_position),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
),
),
@@ -2157,14 +2274,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer();
},
activeColor: AppColors.primary,
inactiveColor: Colors.white24,
inactiveColor: AppColors.onSurface24,
),
),
),
Text(
_formatDuration(_duration),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
),
),
@@ -2199,7 +2316,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 72,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white10,
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
@@ -2208,7 +2325,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 40),
const Icon(Icons.tv, color: AppColors.onSurface38, size: 40),
),
),
),
@@ -2217,7 +2334,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
ch != null ? ch.name : 'Chargement...',
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 15,
fontWeight: FontWeight.w500,
),
@@ -2232,18 +2349,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const Icon(Icons.error_outline, size: 64, color: AppColors.error),
const SizedBox(height: 16),
const Text(
'Erreur de lecture',
style: TextStyle(color: Colors.white, fontSize: 18),
style: TextStyle(color: AppColors.onSurface, fontSize: 18),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage ?? 'Une erreur inconnue est survenue',
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center,
),
),
@@ -2256,8 +2373,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
icon: const Icon(Icons.arrow_back),
label: const Text('Retour'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: AppColors.onSurface,
foregroundColor: AppColors.onPrimaryFixed,
),
),
const SizedBox(width: 16),
@@ -2267,7 +2384,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
foregroundColor: AppColors.onPrimaryFixed,
),
),
],
@@ -2328,7 +2445,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withOpacity(0.5)),
border: Border.all(color: AppColors.success.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2337,7 +2454,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const Text(
'STATS FOR NERDS 🤓',
style: TextStyle(
color: Colors.green,
color: AppColors.success,
fontWeight: FontWeight.bold,
fontSize: 12,
),
@@ -2345,11 +2462,11 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const SizedBox(height: 4),
Text(
'Buffer: ${_buffer.inSeconds}s',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
Text(
'Decoder: $_decoder',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
],
),
@@ -16,6 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/services/ip_service.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -130,14 +131,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text(
"Quitter l'application",
style: TextStyle(color: Colors.white),
style: TextStyle(color: AppColors.onSurface),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
actions: [
TextButton(
@@ -145,14 +146,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: Colors.blueAccent),
style: TextStyle(color: AppColors.primary),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: AppColors.error),
),
),
],
@@ -171,7 +172,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center,
),
),
@@ -235,7 +236,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance
@@ -263,7 +264,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
@@ -357,7 +358,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? const Color(0xFFFF453A)
? AppColors.live
: AppDecorations.textSecondary(
context),
),
@@ -535,7 +536,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center(
child: Text(
'Aucune catégorie sélectionnée',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
),
);
@@ -557,7 +558,7 @@ class _ChannelListView extends ConsumerWidget {
showFavoritesOnly
? 'Sélectionnez une catégorie pour voir vos favoris'
: 'Sélectionnez une catégorie pour rechercher',
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center,
),
),
@@ -575,7 +576,7 @@ class _ChannelListView extends ConsumerWidget {
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center,
),
),
@@ -600,7 +601,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center(
child: Text(
'Aucune chaîne trouvée',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
),
);
@@ -754,12 +755,12 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.favorite,
color: Colors.white,
color: AppColors.onSurface,
size: 10,
),
),
@@ -779,7 +780,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text(
widget.channel.name,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
fontSize: 10,
),
@@ -792,7 +793,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text(
_epgTitle!,
style: const TextStyle(
color: Colors.yellow,
color: AppColors.ratingGold,
fontSize: 8,
),
textAlign: TextAlign.center,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -285,14 +286,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text(
"Quitter l'application",
style: TextStyle(color: Colors.white),
style: TextStyle(color: AppColors.onSurface),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
actions: [
TextButton(
@@ -300,14 +301,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: Colors.blueAccent),
style: TextStyle(color: AppColors.primary),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: AppColors.error),
),
),
],
@@ -346,7 +347,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -367,7 +368,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileSeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -269,18 +270,18 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)),
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text("Quitter l'application", style: TextStyle(color: AppColors.onSurface)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: AppColors.onSurface70)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
autofocus: true,
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)),
child: const Text('Annuler', style: TextStyle(color: AppColors.primary)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)),
child: const Text('Quitter', style: TextStyle(color: AppColors.error)),
),
],
),
@@ -316,7 +317,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -337,7 +338,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
@@ -8,6 +8,7 @@ import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_theme.dart';
import 'package:xtremobile/core/providers/theme_provider.dart';
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Simplified mobile settings tab - optimized for TV remote control
class MobileSettingsTab extends ConsumerStatefulWidget {
@@ -105,7 +106,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
),
Text(
'Version 1.5.1',
'Version 1.6.1',
style: GoogleFonts.inter(
fontSize: 13,
color: AppDecorations.textSecondary(context),
@@ -327,7 +328,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
duration: Duration(seconds: 3),
),
);
@@ -335,7 +336,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error),
);
}
} finally {
@@ -415,7 +416,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
icon,
size: 16,
color: selected
? Colors.white
? AppColors.onSurface
: AppDecorations.textSecondary(context),
),
const SizedBox(height: 3),
@@ -427,7 +428,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
? FontWeight.w700
: FontWeight.w500,
color: selected
? Colors.white
? AppColors.onSurface
: AppDecorations.textSecondary(context),
),
),
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileCategoryCard extends StatelessWidget {
final String title;
@@ -13,7 +14,7 @@ class MobileCategoryCard extends StatelessWidget {
required this.title,
required this.onTap,
this.icon = Icons.folder_rounded,
this.iconColor = const Color(0xFF0A84FF),
this.iconColor = AppColors.primary,
});
@override
+5 -4
View File
@@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobilePosterCard extends StatelessWidget {
final String title;
@@ -134,12 +135,12 @@ class MobilePosterCard extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, color: Colors.amber, size: 10),
const Icon(Icons.star, color: AppColors.ratingGold, size: 10),
const SizedBox(width: 2),
Text(
rating!,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.bold,
),
@@ -157,13 +158,13 @@ class MobilePosterCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Color(0xFF30D158),
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
size: 10,
color: Colors.white,
color: AppColors.onSurface,
),
),
);
+5 -4
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileScaffold extends ConsumerStatefulWidget {
final Widget child;
@@ -71,7 +72,7 @@ class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 40,
),
),
@@ -201,12 +202,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
@override
Widget build(BuildContext context) {
const Color selectedColor = Color(0xFF0A84FF);
const Color selectedColor = AppColors.primaryFill;
final Color unselectedColor = AppDecorations.textSecondary(context);
final Color textColor =
widget.isSelected ? Colors.white : unselectedColor;
widget.isSelected ? AppColors.onSurface : unselectedColor;
final Color iconColor =
widget.isSelected ? Colors.white : unselectedColor;
widget.isSelected ? AppColors.onSurface : unselectedColor;
final FontWeight fontWeight =
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
+3 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget {
final Widget child;
@@ -25,7 +26,7 @@ class TVFocusable extends StatefulWidget {
this.autofocus = false,
this.scale = 1.05, // Apple TV subtle lift
this.borderWidth = 2.0,
this.focusColor = Colors.white,
this.focusColor = AppColors.onSurface,
this.borderRadius,
this.longPressDuration = const Duration(seconds: 3),
});
@@ -162,7 +163,7 @@ class _TVFocusableState extends State<TVFocusable> {
offset: const Offset(0, 6),
),
const BoxShadow(
color: Color(0x40FFFFFF), // White glow
color: Color(0x590A84FF), // Apple blue glow
blurRadius: 12,
spreadRadius: 0,
),
+1 -1
View File
@@ -1,7 +1,7 @@
name: xtremobile
description: High-performance IPTV Android Application with Xtream Codes API
publish_to: 'none'
version: 1.6.0+12
version: 1.7.1+15
environment:
sdk: '>=3.0.0 <4.0.0'
+15
View File
@@ -25,6 +25,21 @@
- [x] `mobile_theme.dart` : dérive désormais de `AppTheme` (échelle typo mobile)
au lieu de dupliquer 390 lignes
- [x] `app_decorations.dart` : hexs Apple codés en dur remplacés par la palette
- [x] **Passe 2 (v1.6.1)** — le thème ne suffisait pas : du bleu restait codé en
dur dans 19 fichiers d'écran/widget, hors palette. Balayage complet :
- `0xFF0A84FF` (bleu Apple) dans la **pilule de navigation sélectionnée**
(`mobile_scaffold`), les icônes de catégorie et le bouton « ajouter »
- `0xFF4FC3F7` / `0xFF1565C0` (bleus) dans les overlays du lecteur
- `Colors.blueAccent` / `Colors.blue` dans les boîtes de dialogue
- gris Apple `1C1C1E` / `2C2C2E` / `3A3A3C` / `8E8E93` → charbons chauds
- `Colors.white*` (128 occurrences) → paliers crème `onSurfaceNN`
- `Colors.red/green/amber/yellow/grey*` → `error` / `success` /
`ratingGold` / `outlineVariant`
- noir pur sur remplissage clair → encre chaude `onPrimaryFixed`
- halo de focus blanc → halo ember
- Conservés en noir vrai : fonds de surface vidéo et voiles (`scrims`)
- Vérification : plus **aucune** couleur Material/Apple codée en dur hors
de `lib/core/theme/`
## 3. Lecture — arrêt en arrière-plan et tenue dans la durée