Files
PinePods-nix/PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
2026-03-03 10:57:43 -05:00

572 lines
18 KiB
Dart

// lib/ui/pinepods/playlist_episodes.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
class PlaylistEpisodesPage extends StatefulWidget {
final PlaylistData playlist;
const PlaylistEpisodesPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
State<PlaylistEpisodesPage> createState() => _PlaylistEpisodesPageState();
}
class _PlaylistEpisodesPageState extends State<PlaylistEpisodesPage> {
final PinepodsService _pinepodsService = PinepodsService();
PlaylistEpisodesResponse? _playlistResponse;
bool _isLoading = true;
String? _errorMessage;
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
@override
void initState() {
super.initState();
_loadPlaylistEpisodes();
}
Future<void> _loadPlaylistEpisodes() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final response = await _pinepodsService.getPlaylistEpisodes(
settings.pinepodsUserId!,
widget.playlist.playlistId,
);
setState(() {
_playlistResponse = response;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
String _getEmptyStateMessage() {
switch (widget.playlist.name) {
case 'Fresh Releases':
return 'No new episodes have been released in the last 24 hours. Check back later for fresh content!';
case 'Currently Listening':
return 'Start listening to some episodes and they\'ll appear here for easy access.';
case 'Almost Done':
return 'You don\'t have any episodes that are near completion. Keep listening!';
default:
return 'No episodes match the current playlist criteria. Try adjusting the filters or add more podcasts.';
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
} catch (e) {
if (mounted) {
_showSnackBar('Failed to play episode: $e', Colors.red);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode saved', Colors.green);
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from saved', Colors.orange);
} else if (mounted) {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.queued) {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from queue', Colors.orange);
}
} else {
final success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode added to queue', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.completed) {
final success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as incomplete', Colors.orange);
}
} else {
final success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as complete', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating completion status: $e', Colors.red);
}
}
}
void _showSnackBar(String message, Color backgroundColor) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
final episode = _playlistResponse!.episodes[episodeIndex];
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return Scaffold(
appBar: AppBar(
title: Text(widget.playlist.name),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
PlatformProgressIndicator(),
SizedBox(height: 16),
Text('Loading playlist episodes...'),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 75,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlist',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylistEpisodes,
child: const Text('Retry'),
),
],
),
),
);
}
if (_playlistResponse == null) {
return const Center(
child: Text('No data available'),
);
}
return CustomScrollView(
slivers: [
// Playlist header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(_playlistResponse!.playlistInfo.iconName),
size: 48,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_playlistResponse!.playlistInfo.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (_playlistResponse!.playlistInfo.description != null &&
_playlistResponse!.playlistInfo.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_playlistResponse!.playlistInfo.description!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${_playlistResponse!.playlistInfo.episodeCount ?? _playlistResponse!.episodes.length} episodes',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
],
),
),
],
),
],
),
),
),
// Episodes list
if (_playlistResponse!.episodes.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_remove,
size: 75,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_getEmptyStateMessage(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _playlistResponse!.episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
childCount: _playlistResponse!.episodes.length,
),
),
],
);
}
}