import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mirror_core/mirror_core.dart'; import 'package:mirror_protocol/mirror_protocol.dart'; import 'package:mirror_ui/mirror_ui.dart'; import '../providers/connection_provider.dart'; import '../providers/youtube_search_provider.dart'; import '../providers/plex_provider.dart'; class RemoteMedia extends ConsumerStatefulWidget { const RemoteMedia({super.key}); @override ConsumerState createState() => _RemoteMediaState(); } class _RemoteMediaState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); } @override void dispose() { _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final playback = ref.watch(mirrorStateProvider.select((s) => s?.playback)); // If media is playing (non-workout), show now-playing overlay on top if (playback != null && playback.source != MediaSource.workout) { return Column( children: [ _NowPlayingBar(playback: playback), TabBar( controller: _tabController, tabs: const [ Tab(text: 'YouTube'), Tab(text: 'Plex'), Tab(text: 'Spotify'), ], ), Expanded( child: TabBarView( controller: _tabController, children: const [ _YouTubeTab(), _PlexTab(), _SpotifyTab(), ], ), ), ], ); } return Column( children: [ TabBar( controller: _tabController, tabs: const [ Tab(text: 'YouTube'), Tab(text: 'Plex'), Tab(text: 'Spotify'), ], ), Expanded( child: TabBarView( controller: _tabController, children: const [ _YouTubeTab(), _PlexTab(), _SpotifyTab(), ], ), ), ], ); } } // --- Now Playing Bar --- class _NowPlayingBar extends ConsumerWidget { final PlaybackState playback; const _NowPlayingBar({required this.playback}); @override Widget build(BuildContext context, WidgetRef ref) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: MirrorTheme.surface, border: Border(bottom: BorderSide(color: MirrorTheme.surfaceLight)), ), child: Column( children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( playback.title, style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( playback.source.toJson().toUpperCase(), style: TextStyle(color: MirrorTheme.accent, fontSize: 11, letterSpacing: 1), ), ], ), ), IconButton( icon: Icon(playback.isPlaying ? Icons.pause : Icons.play_arrow), iconSize: 28, onPressed: () => ref.read(connectionProvider.notifier).send( PlaybackCommand( action: playback.isPlaying ? PlaybackAction.pause : PlaybackAction.resume, ), ), ), IconButton( icon: const Icon(Icons.stop), iconSize: 24, onPressed: () => ref.read(connectionProvider.notifier).send( PlaybackCommand(action: PlaybackAction.stop), ), ), ], ), const SizedBox(height: 4), LinearProgressIndicator( value: playback.duration.inMilliseconds > 0 ? playback.position.inMilliseconds / playback.duration.inMilliseconds : 0, backgroundColor: MirrorTheme.surfaceLight, color: MirrorTheme.accent, minHeight: 2, ), ], ), ); } } // --- YouTube Tab --- class _YouTubeTab extends ConsumerStatefulWidget { const _YouTubeTab(); @override ConsumerState<_YouTubeTab> createState() => _YouTubeTabState(); } class _YouTubeTabState extends ConsumerState<_YouTubeTab> { final _searchController = TextEditingController(); @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final searchState = ref.watch(youtubeSearchProvider); return Column( children: [ Padding( padding: const EdgeInsets.all(16), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search YouTube...', prefixIcon: const Icon(Icons.search), suffixIcon: IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); ref.read(youtubeSearchProvider.notifier).clear(); }, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), filled: true, fillColor: MirrorTheme.surface, ), textInputAction: TextInputAction.search, onSubmitted: (query) { ref.read(youtubeSearchProvider.notifier).search(query); }, ), ), Expanded( child: searchState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center( child: Text('Search failed: $err', style: const TextStyle(color: MirrorTheme.danger)), ), data: (data) { if (data.results.isEmpty && data.query.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.smart_display, size: 64, color: MirrorTheme.textMuted), const SizedBox(height: 16), Text( 'Search for videos to play', style: TextStyle(color: MirrorTheme.textMuted), ), ], ), ); } if (data.results.isEmpty) { return const Center(child: Text('No results found')); } return ListView.builder( itemCount: data.results.length, itemBuilder: (context, index) { final video = data.results[index]; return _YouTubeResultTile(video: video); }, ); }, ), ), ], ); } } class _YouTubeResultTile extends ConsumerWidget { final YouTubeVideo video; const _YouTubeResultTile({required this.video}); @override Widget build(BuildContext context, WidgetRef ref) { final duration = _formatDuration(video.duration); return ListTile( leading: SizedBox( width: 64, height: 48, child: video.thumbnailUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( video.thumbnailUrl!, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( color: MirrorTheme.surface, child: const Icon(Icons.smart_display, size: 24), ), ), ) : Container( decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(6), ), child: const Icon(Icons.smart_display, size: 24), ), ), title: Text( video.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14), ), subtitle: Text( '${video.author} · $duration', style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted), ), onTap: () { final url = 'https://www.youtube.com/watch?v=${video.id}'; ref.read(connectionProvider.notifier).send( MediaCommand( action: MediaAction.playYouTube, payload: {'url': url, 'title': video.title}, ), ); }, ); } String _formatDuration(Duration d) { final h = d.inHours; final m = d.inMinutes % 60; final s = (d.inSeconds % 60).toString().padLeft(2, '0'); if (h > 0) return '$h:${m.toString().padLeft(2, '0')}:$s'; return '$m:$s'; } } // --- Plex Tab --- class _PlexTab extends ConsumerWidget { const _PlexTab(); @override Widget build(BuildContext context, WidgetRef ref) { final plexState = ref.watch(plexBrowseProvider); return plexState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger), const SizedBox(height: 16), Text('Plex connection failed', style: Theme.of(context).textTheme.bodyLarge), const SizedBox(height: 8), Text( 'Configure Plex in settings', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13), ), const SizedBox(height: 16), FilledButton( onPressed: () => ref.invalidate(plexBrowseProvider), child: const Text('Retry'), ), ], ), ), data: (data) { if (data.error != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.movie, size: 48, color: MirrorTheme.textMuted), const SizedBox(height: 16), Text(data.error!, style: TextStyle(color: MirrorTheme.textMuted)), const SizedBox(height: 16), FilledButton( onPressed: () => ref.invalidate(plexBrowseProvider), child: const Text('Retry'), ), ], ), ); } // Show library list if (data.currentLibrary == null) { return _PlexLibraryList(libraries: data.libraries); } // Show items in library return _PlexItemList(data: data); }, ); } } class _PlexLibraryList extends ConsumerWidget { final List libraries; const _PlexLibraryList({required this.libraries}); @override Widget build(BuildContext context, WidgetRef ref) { if (libraries.isEmpty) { return const Center( child: Text('No Plex libraries found', style: TextStyle(color: MirrorTheme.textMuted)), ); } return ListView.builder( padding: const EdgeInsets.all(16), itemCount: libraries.length, itemBuilder: (context, index) { final lib = libraries[index]; final icon = switch (lib.type) { 'movie' => Icons.movie, 'show' => Icons.tv, 'artist' => Icons.music_note, _ => Icons.folder, }; return ListTile( leading: Icon(icon, color: MirrorTheme.accent), title: Text(lib.title), subtitle: Text(lib.type, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 12)), trailing: const Icon(Icons.chevron_right), onTap: () => ref.read(plexBrowseProvider.notifier).openLibrary(lib), ); }, ); } } class _PlexItemList extends ConsumerWidget { final PlexBrowseState data; const _PlexItemList({required this.data}); @override Widget build(BuildContext context, WidgetRef ref) { return Column( children: [ // Breadcrumb / back Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => ref.read(plexBrowseProvider.notifier).goBack(), ), Expanded( child: Text( data.breadcrumb.join(' / '), style: const TextStyle(fontSize: 13, color: MirrorTheme.textMuted), overflow: TextOverflow.ellipsis, ), ), ], ), ), Expanded( child: ListView.builder( itemCount: data.items.length, itemBuilder: (context, index) { final item = data.items[index]; final isPlayable = item.partKey != null; final hasChildren = item.type == 'show' || item.type == 'season' || item.type == 'artist' || item.type == 'album'; return ListTile( leading: SizedBox( width: 48, height: 48, child: Container( decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(6), ), child: Icon( isPlayable ? Icons.play_circle_outline : Icons.folder, color: MirrorTheme.accent, ), ), ), title: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( [ item.type, if (item.year != null) '${item.year}', if (item.duration != null) _formatMs(item.duration!), ].join(' · '), style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted), ), trailing: hasChildren ? const Icon(Icons.chevron_right) : isPlayable ? const Icon(Icons.play_arrow, color: MirrorTheme.accent) : null, onTap: () { if (hasChildren) { ref.read(plexBrowseProvider.notifier).openItem(item); } else if (isPlayable) { final plex = ref.read(plexServiceProvider); if (plex == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Plex not configured')), ); return; } final streamUrl = plex.getStreamUrl(item.partKey!); ref.read(connectionProvider.notifier).send( MediaCommand( action: MediaAction.playPlex, payload: {'streamUrl': streamUrl, 'title': item.title}, ), ); } }, ); }, ), ), ], ); } String _formatMs(int ms) { final d = Duration(milliseconds: ms); final h = d.inHours; final m = d.inMinutes % 60; if (h > 0) return '${h}h ${m}m'; return '${m}m'; } } // --- Spotify Tab --- class _SpotifyTab extends ConsumerWidget { const _SpotifyTab(); @override Widget build(BuildContext context, WidgetRef ref) { final spotify = ref.watch(mirrorStateProvider.select((s) => s?.spotify)); return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Now playing section if (spotify != null && spotify.trackName != null) ...[ Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(16), ), child: Column( children: [ if (spotify.albumImageUrl != null) ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( spotify.albumImageUrl!, width: 120, height: 120, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( width: 120, height: 120, color: MirrorTheme.surfaceLight, child: const Icon(Icons.music_note, size: 48), ), ), ), const SizedBox(height: 16), Text( spotify.trackName ?? '', style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), if (spotify.artistName != null) Text( spotify.artistName!, style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13), ), const SizedBox(height: 16), // Progress LinearProgressIndicator( value: spotify.durationMs > 0 ? spotify.progressMs / spotify.durationMs : 0, backgroundColor: MirrorTheme.surfaceLight, color: const Color(0xFF1DB954), // Spotify green minHeight: 3, ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _fmtMs(spotify.progressMs), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11), ), Text( _fmtMs(spotify.durationMs), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11), ), ], ), const SizedBox(height: 16), // Transport Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( icon: const Icon(Icons.skip_previous), iconSize: 36, onPressed: () => ref.read(connectionProvider.notifier).send( MediaCommand(action: MediaAction.spotifyPrevious), ), ), const SizedBox(width: 16), IconButton.filled( icon: Icon(spotify.isPlaying ? Icons.pause : Icons.play_arrow), iconSize: 48, style: IconButton.styleFrom( backgroundColor: const Color(0xFF1DB954), ), onPressed: () => ref.read(connectionProvider.notifier).send( MediaCommand( action: spotify.isPlaying ? MediaAction.spotifyPause : MediaAction.spotifyPlay, ), ), ), const SizedBox(width: 16), IconButton( icon: const Icon(Icons.skip_next), iconSize: 36, onPressed: () => ref.read(connectionProvider.notifier).send( MediaCommand(action: MediaAction.spotifyNext), ), ), ], ), ], ), ), ] else ...[ // Not connected / nothing playing Container( width: double.infinity, padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(16), ), child: Column( children: [ Icon(Icons.music_note, size: 48, color: MirrorTheme.textMuted), const SizedBox(height: 16), Text( 'Spotify', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( spotify == null ? 'Not connected' : 'Nothing playing', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13), ), const SizedBox(height: 16), OutlinedButton.icon( icon: const Icon(Icons.link), label: const Text('Connect Spotify'), onPressed: () { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Spotify OAuth not yet configured')), ); }, ), ], ), ), ], ], ), ); } String _fmtMs(int ms) { final d = Duration(milliseconds: ms); final m = d.inMinutes; final s = (d.inSeconds % 60).toString().padLeft(2, '0'); return '$m:$s'; } }