Construindo um App de Dramas e Reels com Flutter 3.41: Player Customizado, Downloads Offline e UX (Parte 3)
Neste artigo, vamos resolver os desafios mais difíceis de um app de vídeos: a Lógica de Reprodução (Play, Pause, Seek), a complexidade de baixar arquivos gigantes para Assistir Offline (Downloads), e fecharemos o ciclo do usuário com o Login e a Edição de Perfil.
🔐 1. Tela de Login (Autenticação Local com Isar)
Na Parte 2, criamos o Registro. Agora precisamos da Tela de Login. Como estamos focando em uma arquitetura local (offline-first) neste momento, o login consistirá em consultar o banco Isar para validar se o usuário existe.
Arquivo: lib/features/auth/login_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/database_providers.dart';
class LoginScreen extends ConsumerWidget {
LoginScreen({super.key});
final _emailController = TextEditingController();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.movie_filter, size: 80, color: Colors.redAccent),
const SizedBox(height: 24),
Text('Bem-vindo ao DramaReels', style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 40),
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Seu E-mail', border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () async {
final isar = await ref.read(isarServiceProvider).db;
// Busca o usuário pelo email
final user = await isar.userProfiles.filter().emailEqualTo(_emailController.text).findFirst();
if (user != null) {
// Sucesso: Navega para a Home (GoRouter)
// context.go('/home');
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Usuário não encontrado.')));
}
},
child: const Text('ENTRAR'),
),
],
),
),
);
}
}
✏️ 2. Edição de Cadastro no Perfil
O usuário precisa poder alterar seu nome de tela. Vamos criar um modal para atualizar o documento no Isar de forma síncrona.
Trecho de Código (Ação de Edição):
Future<void> _updateUserProfile(WidgetRef ref, int userId, String newName) async {
final isar = await ref.read(isarServiceProvider).db;
await isar.writeTxn(() async {
final user = await isar.userProfiles.get(userId);
if (user != null) {
user.name = newName;
await isar.userProfiles.put(user); // O 'put' atualiza o registro existente
}
});
}
Para fazer isso funcionar perfeitamente com o Riverpod e atualizar a interface em tempo real assim que o nome for alterado no Isar, precisamos de duas coisas:
-
Transformar o nosso
StreamingDrawerem umConsumerWidget. -
Criar um provedor (
Provider) para ler os dados do usuário.
1. Criando o Provider do Usuário
Primeiro, adicione este provedor no seu arquivo de provedores (lib/core/providers/database_providers.dart). Ele será responsável por buscar o usuário logado (usaremos o ID 1 como padrão local).
// Adicione junto aos seus outros providers
final userProfileProvider = FutureProvider<UserProfile?>((ref) async {
final isar = await ref.read(isarServiceProvider).db;
return isar.userProfiles.get(1); // Busca o usuário de ID 1
});
2. Atualizando o StreamingDrawer com o AlertDialog
Agora, vamos atualizar o arquivo lib/features/home/widgets/streaming_drawer.dart.
Nós vamos:
-
Envolver o componente com
ConsumerWidgetpara escutar o Riverpod. -
Criar a função
_showEditProfileDialogque invoca oAlertDialog. -
Usar
ref.invalidate()após salvar, para que a UI do Drawer se atualize automaticamente com o novo nome.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/database_providers.dart';
import '../../profile/models/user_profile.dart'; // Ajuste o caminho conforme seu projeto
class StreamingDrawer extends ConsumerWidget {
const StreamingDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Assiste ao estado do usuário no banco de dados
final userAsyncValue = ref.watch(userProfileProvider);
return NavigationDrawer(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 16, 20),
child: Row(
children: [
const CircleAvatar(
radius: 30,
backgroundImage: NetworkImage('https://via.placeholder.com/150'),
),
const SizedBox(width: 16),
Expanded(
child: userAsyncValue.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => const Text('Erro ao carregar'),
data: (user) {
final userName = user?.name ?? 'Visitante';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Botão Clicável que abre o Modal
InkWell(
onTap: () => _showEditProfileDialog(context, ref, userName),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit, size: 14, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 4),
Text(
'Editar Perfil',
style: TextStyle(color: Theme.of(context).colorScheme.primary, fontSize: 12),
),
],
),
),
),
],
);
},
),
),
],
),
),
const Divider(),
const NavigationDrawerDestination(icon: Icon(Icons.download_done), label: Text('Meus Downloads')),
const NavigationDrawerDestination(icon: Icon(Icons.settings), label: Text('Configurações do App')),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: FilledButton.tonalIcon(
onPressed: () {
// IsarService().exportDatabase();
},
icon: const Icon(Icons.backup),
label: const Text('Exportar Banco de Dados'),
),
),
],
);
}
// Lógica do Modal de Edição (AlertDialog)
void _showEditProfileDialog(BuildContext context, WidgetRef ref, String currentName) {
// Inicia o campo de texto já com o nome atual do usuário
final nameController = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Editar Nome de Tela'),
content: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Novo Nome',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
autofocus: true, // Abre o teclado automaticamente
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext), // Fecha sem salvar
child: const Text('CANCELAR'),
),
FilledButton(
onPressed: () async {
final newName = nameController.text.trim();
if (newName.isNotEmpty && newName != currentName) {
// Executa a atualização no Isar
await _updateUserProfile(ref, 1, newName);
// Força o Riverpod a buscar o dado atualizado no banco
ref.invalidate(userProfileProvider);
}
// Fecha o modal se o contexto ainda for válido
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: const Text('SALVAR'),
),
],
);
},
);
}
// A função de atualização no banco que rascunhamos no artigo
Future<void> _updateUserProfile(WidgetRef ref, int userId, String newName) async {
final isar = await ref.read(isarServiceProvider).db;
await isar.writeTxn(() async {
final user = await isar.userProfiles.get(userId);
if (user != null) {
user.name = newName;
await isar.userProfiles.put(user); // Atualiza o registro
}
});
}
}
💡 O que acontece nos bastidores?
-
O usuário abre o Drawer lateral. O Riverpod (
ref.watch) busca o usuário no banco Isar e exibe o nome dele. -
Ao clicar em “Editar Perfil”, o
AlertDialogdo Material 3 salta na tela. O campo de texto já vem preenchido com o nome atual graças aoTextEditingController(text: currentName). -
O usuário digita o novo nome e clica em “SALVAR”.
-
A função
_updateUserProfileabre uma transação síncrona de escrita (writeTxn) no Isar e sobrescreve o nome. -
O pulo do gato: Usamos
ref.invalidate(userProfileProvider). Isso avisa ao Riverpod que o dado do usuário ficou obsoleto. O Riverpod vai imediatamente consultar o Isar de novo, e o nome no Drawer atualizará na frente dos olhos do usuário num piscar de olhos, enquanto o modal se fecha.
🎬 3. O Motor de Reprodução: Configurando o VideoPlayer
O pacote oficial video_player fornece a base para decodificar MP4/HLS. Criaremos um StatefulWidget que gerencia o ciclo de vida do vídeo (fundamental para evitar vazamento de memória).
Dependência: video_player: ^2.8.2
Arquivo: lib/features/player/custom_video_player.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class CustomVideoPlayer extends StatefulWidget {
final String url;
final String? localPath; // Para vídeos offline
const CustomVideoPlayer({super.key, required this.url, this.localPath});
@override
State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}
class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
late VideoPlayerController _controller;
bool _isInitialized = false;
@override
void initState() {
super.initState();
_initPlayer();
}
Future<void> _initPlayer() async {
// Lógica inteligente: Toca o arquivo local se existir, senão faz streaming
if (widget.localPath != null && widget.localPath!.isNotEmpty) {
_controller = VideoPlayerController.file(File(widget.localPath!));
} else {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
}
await _controller.initialize();
setState(() => _isInitialized = true);
_controller.play(); // Auto-play
}
@override
void dispose() {
_controller.dispose(); // CRÍTICO: Libera o decodificador de hardware
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) return const Center(child: CircularProgressIndicator());
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
}
}
⏯️ 4. Lógica de Controle Real (Play, Pause, Seek)
Um player sem controles é apenas um GIF gigante. Vamos construir a camada de interação (Overlay) que fica por cima do vídeo, contendo o botão de Play/Pause e a barra de progresso (Seek).
Componente de Overlay:
Widget _buildControls() {
return GestureDetector(
onTap: () {
// Toggle Play/Pause
setState(() {
_controller.value.isPlaying ? _controller.pause() : _controller.play();
});
},
child: Container(
color: Colors.black26, // Máscara escura sutil
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Ícone central de Play/Pause animado
AnimatedOpacity(
opacity: _controller.value.isPlaying ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: const Icon(Icons.play_circle_fill, size: 80, color: Colors.white),
),
const Spacer(),
// Seek Bar (Barra de Progresso interativa)
VideoProgressIndicator(
_controller,
allowScrubbing: true, // Permite arrastar para avançar (Seek)
colors: VideoProgressColors(
playedColor: Theme.of(context).colorScheme.primary,
bufferedColor: Colors.white24,
backgroundColor: Colors.black45,
),
),
],
),
),
);
}
O Código Completo do Player com Controles
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
class CustomVideoPlayer extends StatefulWidget {
final String url;
final String? localPath; // Para vídeos baixados offline
const CustomVideoPlayer({super.key, required this.url, this.localPath});
@override
State<CustomVideoPlayer> createState() => _CustomVideoPlayerState();
}
class _CustomVideoPlayerState extends State<CustomVideoPlayer> {
late VideoPlayerController _controller;
bool _isInitialized = false;
@override
void initState() {
super.initState();
_initPlayer();
}
Future<void> _initPlayer() async {
// 1. Lógica inteligente: Toca o arquivo local se existir, senão faz streaming da web
if (widget.localPath != null && widget.localPath!.isNotEmpty) {
_controller = VideoPlayerController.file(File(widget.localPath!));
} else {
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
}
// 2. Inicializa o decodificador
await _controller.initialize();
// Atualiza a tela para remover o loading e dá o Play automático
setState(() => _isInitialized = true);
_controller.play();
// Adiciona um listener para atualizar a tela caso o vídeo termine
_controller.addListener(() {
if (_controller.value.position == _controller.value.duration) {
setState(() {}); // Força rebuild para mostrar o botão de Play quando acabar
}
});
}
@override
void dispose() {
_controller.dispose(); // CRÍTICO: Libera a memória e o decodificador de hardware
super.dispose();
}
@override
Widget build(BuildContext context) {
// Mostra um loading enquanto o vídeo não está pronto
if (!_isInitialized) {
return const Center(
child: CircularProgressIndicator(color: Colors.redAccent),
);
}
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
// A MÁGICA ACONTECE AQUI: O Stack empilha o vídeo e os controles
child: Stack(
fit: StackFit.expand, // Força os filhos a preencherem o espaço
children: [
// Camada 1 (Fundo): O Vídeo rolando
VideoPlayer(_controller),
// Camada 2 (Frente): Os Controles Interativos
_buildControls(),
],
),
);
}
// O Widget de Overlay (Controles)
Widget _buildControls() {
return GestureDetector(
// Qualquer toque na tela pausa ou despausa o vídeo
onTap: () {
setState(() {
_controller.value.isPlaying ? _controller.pause() : _controller.play();
});
},
child: Container(
// Máscara escura sutil para destacar os ícones e a barra (Material 3)
color: _controller.value.isPlaying ? Colors.transparent : Colors.black45,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Espaço vazio no topo para empurrar o botão de play para o centro
const Spacer(),
// Ícone central de Play/Pause animado
AnimatedOpacity(
opacity: _controller.value.isPlaying ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: const Icon(
Icons.play_circle_fill,
size: 80,
color: Colors.white,
),
),
const Spacer(),
// Seek Bar (Barra de Progresso) no rodapé do player
VideoProgressIndicator(
_controller,
allowScrubbing: true, // Permite que o usuário arraste para avançar/voltar o vídeo
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
colors: VideoProgressColors(
playedColor: Theme.of(context).colorScheme.primary, // Cor do Material 3
bufferedColor: Colors.white24, // O que já carregou da internet
backgroundColor: Colors.black45, // O fundo da barra
),
),
],
),
),
);
}
}
💡 Por que esta estrutura é profissional?
-
O
GestureDetectorcobre toda a tela: Como ele envolve oContainerdo_buildControls, o usuário não precisa mirar exatamente no ícone minúsculo de Play/Pause. Clicar em qualquer lugar da tela (estilo TikTok ou Reels) fará a ação. -
AnimatedOpacity: Em vez de fazer o ícone de Play desaparecer do nada, usamos uma animação de 300ms. Fica muito mais suave e agradável aos olhos. -
Fundo escurecido dinâmico: Se o vídeo está rolando (
isPlaying), o fundo de controle fica transparente para não atrapalhar a visão. Se o usuário pausa, aplicamos umColors.black45para dar contraste ao ícone do Play.
Para implementar isso de forma limpa, não precisamos criar vários botões invisíveis. Vamos envolver nossos controles em um LayoutBuilder (para sabermos a largura exata do player na tela) e usar o evento onDoubleTapDown do nosso GestureDetector. Ao pegar a coordenada exata de onde o usuário tocou, descobrimos se foi na metade esquerda ou direita da tela.
Aqui está o trecho do _buildControls() atualizado com essa lógica temporal:
O Código Atualizado (_buildControls)
Substitua apenas o método _buildControls dentro do seu CustomVideoPlayer:
// O Widget de Overlay (Controles com Double Tap)
Widget _buildControls() {
return LayoutBuilder(
builder: (context, constraints) {
return GestureDetector(
// Um toque simples: Pausa ou Despausa
onTap: () {
setState(() {
_controller.value.isPlaying ? _controller.pause() : _controller.play();
});
},
// Dois toques rápidos: Avança ou Retrocede 10 segundos
onDoubleTapDown: (details) {
final double tapPosition = details.localPosition.dx;
final double playerWidth = constraints.maxWidth;
final currentPosition = _controller.value.position;
if (tapPosition < playerWidth / 2) {
// Tocou na ESQUERDA: Retroceder 10 segundos
final newPosition = currentPosition - const Duration(seconds: 10);
// Garante que não tente retroceder para antes do zero
_controller.seekTo(newPosition < Duration.zero ? Duration.zero : newPosition);
// Opcional: Você pode disparar um SnackBar rápido aqui dizendo "-10s"
} else {
// Tocou na DIREITA: Avançar 10 segundos
final newPosition = currentPosition + const Duration(seconds: 10);
final maxDuration = _controller.value.duration;
// Garante que não tente avançar para depois do fim do vídeo
_controller.seekTo(newPosition > maxDuration ? maxDuration : newPosition);
// Opcional: SnackBar rápido dizendo "+10s"
}
},
child: Container(
color: _controller.value.isPlaying ? Colors.transparent : Colors.black45,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Spacer(),
// Ícone central de Play/Pause animado
AnimatedOpacity(
opacity: _controller.value.isPlaying ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: const Icon(
Icons.play_circle_fill,
size: 80,
color: Colors.white,
),
),
const Spacer(),
// Barra de Progresso
VideoProgressIndicator(
_controller,
allowScrubbing: true,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
colors: VideoProgressColors(
playedColor: Theme.of(context).colorScheme.primary,
bufferedColor: Colors.white24,
backgroundColor: Colors.black45,
),
),
],
),
),
);
}
);
}
📱 5. A Tela de Reels (Feed Vertical Imersivo)
Agora sim, daremos vida à tela de Reels com os controladores reais. A grande sacada do TikTok/Reels é a rolagem magnética do PageView combinada com o auto-play.
Arquivo: lib/features/reels/reels_screen.dart
import 'package:flutter/material.dart';
class ReelsScreen extends StatelessWidget {
const ReelsScreen({super.key});
@override
Widget build(BuildContext context) {
// Mock de vídeos (Na vida real, viria do Riverpod/Isar)
final urls = [
'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'
];
return Scaffold(
backgroundColor: Colors.black,
body: PageView.builder(
scrollDirection: Axis.vertical, // ROLAGEM VERTICAL
itemCount: urls.length,
itemBuilder: (context, index) {
return Stack(
fit: StackFit.expand,
children: [
// O Player que criamos no Passo 3
CustomVideoPlayer(url: urls[index]),
// Interface de Botões Laterais (Like, Comentar, Compartilhar)
Positioned(
right: 16,
bottom: 100,
child: Column(
children: [
IconButton(icon: const Icon(Icons.favorite_border, color: Colors.white, size: 36), onPressed: () {}),
const SizedBox(height: 16),
IconButton(icon: const Icon(Icons.share, color: Colors.white, size: 36), onPressed: () {}),
],
),
),
// Título e Descrição
const Positioned(
left: 16,
bottom: 40,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@usuariocriador', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('Uma cena épica deste K-Drama!', style: TextStyle(color: Colors.white)),
],
),
),
],
);
},
),
);
}
}
💾 6. Atualização do Schema para Downloads
Para baixar vídeos e saber onde estão salvos, precisamos atualizar nosso VideoContent no Isar.
Atualize video_content.dart:
@collection
class VideoContent {
Id id = Isar.autoIncrement;
// ... propriedades existentes ...
bool isDownloaded = false; // Flag para a UI
String? localFilePath; // Onde o vídeo foi salvo no celular
}
📥 7. Arquitetura de Download Offline (Dio + FileSystem)
Para baixar vídeos pesados, o Dio é a biblioteca mais robusta do Flutter. Salvaremos o arquivo no diretório de documentos do aplicativo.
Dependências: dio: ^5.4.0, path_provider: ^2.1.2
Serviço de Download (download_service.dart):
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DownloadService {
final Dio _dio = Dio();
// Função que baixa e retorna o caminho local
Future<String?> downloadVideo(String url, String fileName, Function(int, int) onProgress) async {
try {
final dir = await getApplicationDocumentsDirectory();
final savePath = '${dir.path}/$fileName.mp4';
await _dio.download(
url,
savePath,
onReceiveProgress: onProgress, // Para atualizar a UI com a % do download
);
return savePath;
} catch (e) {
return null;
}
}
}
🔄 8. Integrando o Download com a Interface (UX)
O usuário clica no botão “Baixar” no DramaDetailScreen. A UI precisa mostrar o progresso e depois atualizar o Isar.
Lógica no Botão de Download:
double _progress = 0.0;
bool _isDownloading = false;
// UI do Botão
_isDownloading
? CircularProgressIndicator(value: _progress)
: IconButton.filledTonal(
icon: const Icon(Icons.download),
onPressed: () async {
setState(() => _isDownloading = true);
final localPath = await DownloadService().downloadVideo(
widget.video.videoUrl,
'video_${widget.video.id}',
(received, total) {
setState(() => _progress = received / total);
}
);
if (localPath != null) {
// Salva o caminho no Isar
final isar = await ref.read(isarServiceProvider).db;
await isar.writeTxn(() async {
widget.video.isDownloaded = true;
widget.video.localFilePath = localPath;
await isar.videoContents.put(widget.video);
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Download concluído!')));
}
setState(() => _isDownloading = false);
},
);
🎬 9. Reprodução Offline Transparente
A beleza desta arquitetura é que, lá no nosso CustomVideoPlayer (Passo 3), nós já preparamos a lógica: if (localPath != null) { VideoPlayerController.file(File(localPath)) }.
Ou seja, se o usuário estiver em modo avião, ele acessa a tela de “Minha Lista” (ou a aba “Meus Downloads” do Drawer), clica no vídeo, e o player abrirá o arquivo do disco rígido local usando decodificação nativa instantânea. Zero buffering!
Parte 1: O “Cérebro” do Player (custom_video_player.dart)
Dentro do método de inicialização do player que construímos no Passo 3, nós colocamos esta validação. O VideoPlayerController nativo do Flutter tem métodos diferentes para ler da internet (networkUrl) ou do disco rígido do celular (file).
Future<void> _initPlayer() async {
// 1. Verificamos se o banco Isar nos enviou um caminho local válido
if (widget.localPath != null && widget.localPath!.isNotEmpty) {
// MODO OFFLINE: O Flutter lê o arquivo diretamente do armazenamento do celular.
// Zero uso de dados móveis, zero travamentos (buffering).
_controller = VideoPlayerController.file(File(widget.localPath!));
print('Reproduzindo em modo OFFLINE do disco local.');
} else {
// MODO ONLINE: Se não tem arquivo local, fazemos o streaming da URL.
_controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
print('Reproduzindo em modo ONLINE via Streaming.');
}
await _controller.initialize();
setState(() => _isInitialized = true);
_controller.play();
}
Parte 2: Como a Interface chama o Player (Na Tela de Detalhes)
Quando o usuário clica em um filme na aba “Minha Lista” ou “Downloads”, o Riverpod te entrega o objeto VideoContent (que modelamos na Parte 1).
Tudo o que a sua UI precisa fazer é passar as duas variáveis (videoUrl e localFilePath) para o nosso componente. A interface não faz nenhum if/else.
// Exemplo de uso dentro de uma tela como a DramaDetailScreen
@override
Widget build(BuildContext context) {
// Supondo que você pegou o 'video' (VideoContent) via Riverpod
return Scaffold(
body: Column(
children: [
// O Player Transparente
CustomVideoPlayer(
url: video.videoUrl, // A URL original da internet
localPath: video.localFilePath, // O caminho local (pode ser nulo)
),
// ... restante da tela (Título, Botão de Download, Episódios)
],
),
);
}
💡 Por que isso é incrível?
Imagine o fluxo do usuário:
-
Ele está no Wi-Fi de casa, abre o app e clica em “Assistir”. O banco Isar diz que
localFilePathé nulo. O player roda via Streaming (networkUrl). -
Ele clica no botão de Download (que construímos no Passo 8). O Dio baixa o arquivo e salva no Isar o caminho (ex:
/data/user/0/com.app/app_flutter/video_1.mp4). -
Ele entra no avião, liga o Modo Avião e abre o app. Ele clica no mesmo filme.
-
O Isar agora entrega o
localFilePathpreenchido. Nosso player detecta isso e roda via Arquivo Local (file). O usuário assiste ao filme em Full HD sem internet, e a UI do app não precisou mudar uma única linha de código.