572 lines
18 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
} |