added cargo files
This commit is contained in:
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal file
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
// lib/ui/pinepods/create_playlist.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:provider/provider.dart';
|
||||
|
||||
class CreatePlaylistPage extends StatefulWidget {
|
||||
const CreatePlaylistPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CreatePlaylistPage> createState() => _CreatePlaylistPageState();
|
||||
}
|
||||
|
||||
class _CreatePlaylistPageState extends State<CreatePlaylistPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
|
||||
bool _isLoading = false;
|
||||
String _selectedIcon = 'ph-playlist';
|
||||
bool _includeUnplayed = true;
|
||||
bool _includePartiallyPlayed = true;
|
||||
bool _includePlayed = false;
|
||||
String _minDuration = '';
|
||||
String _maxDuration = '';
|
||||
String _sortOrder = 'newest_first';
|
||||
bool _groupByPodcast = false;
|
||||
String _maxEpisodes = '';
|
||||
|
||||
final List<Map<String, String>> _availableIcons = [
|
||||
{'name': 'ph-playlist', 'icon': '🎵'},
|
||||
{'name': 'ph-music-notes', 'icon': '🎶'},
|
||||
{'name': 'ph-play-circle', 'icon': '▶️'},
|
||||
{'name': 'ph-headphones', 'icon': '🎧'},
|
||||
{'name': 'ph-star', 'icon': '⭐'},
|
||||
{'name': 'ph-heart', 'icon': '❤️'},
|
||||
{'name': 'ph-bookmark', 'icon': '🔖'},
|
||||
{'name': 'ph-clock', 'icon': '⏰'},
|
||||
{'name': 'ph-calendar', 'icon': '📅'},
|
||||
{'name': 'ph-timer', 'icon': '⏲️'},
|
||||
{'name': 'ph-shuffle', 'icon': '🔀'},
|
||||
{'name': 'ph-repeat', 'icon': '🔁'},
|
||||
{'name': 'ph-microphone', 'icon': '🎤'},
|
||||
{'name': 'ph-queue', 'icon': '📋'},
|
||||
{'name': 'ph-fire', 'icon': '🔥'},
|
||||
{'name': 'ph-lightning', 'icon': '⚡'},
|
||||
{'name': 'ph-coffee', 'icon': '☕'},
|
||||
{'name': 'ph-moon', 'icon': '🌙'},
|
||||
{'name': 'ph-sun', 'icon': '☀️'},
|
||||
{'name': 'ph-rocket', 'icon': '🚀'},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _createPlaylist() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer == null ||
|
||||
settings.pinepodsApiKey == null ||
|
||||
settings.pinepodsUserId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Not connected to PinePods server')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final request = CreatePlaylistRequest(
|
||||
userId: settings.pinepodsUserId!,
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim().isNotEmpty
|
||||
? _descriptionController.text.trim()
|
||||
: null,
|
||||
podcastIds: const [], // For now, we'll create without podcast filtering
|
||||
includeUnplayed: _includeUnplayed,
|
||||
includePartiallyPlayed: _includePartiallyPlayed,
|
||||
includePlayed: _includePlayed,
|
||||
minDuration: _minDuration.isNotEmpty ? int.tryParse(_minDuration) : null,
|
||||
maxDuration: _maxDuration.isNotEmpty ? int.tryParse(_maxDuration) : null,
|
||||
sortOrder: _sortOrder,
|
||||
groupByPodcast: _groupByPodcast,
|
||||
maxEpisodes: _maxEpisodes.isNotEmpty ? int.tryParse(_maxEpisodes) : null,
|
||||
iconName: _selectedIcon,
|
||||
playProgressMin: null, // Simplified for now
|
||||
playProgressMax: null,
|
||||
timeFilterHours: null,
|
||||
);
|
||||
|
||||
final success = await _pinepodsService.createPlaylist(request);
|
||||
|
||||
if (success) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true); // Return true to indicate success
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Playlist created successfully!')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to create playlist')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error creating playlist: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Create Playlist'),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: _createPlaylist,
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
children: [
|
||||
// Name field
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Playlist Name',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter playlist name',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter a playlist name';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description field
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (Optional)',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Enter playlist description',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Icon selector
|
||||
Text(
|
||||
'Icon',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 5,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: _availableIcons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final icon = _availableIcons[index];
|
||||
final isSelected = _selectedIcon == icon['name'];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedIcon = icon['name']!;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor.withOpacity(0.2)
|
||||
: null,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
icon['icon']!,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text(
|
||||
'Episode Filters',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Episode filters
|
||||
CheckboxListTile(
|
||||
title: const Text('Include Unplayed'),
|
||||
value: _includeUnplayed,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_includeUnplayed = value ?? true;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Include Partially Played'),
|
||||
value: _includePartiallyPlayed,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_includePartiallyPlayed = value ?? true;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: const Text('Include Played'),
|
||||
value: _includePlayed,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_includePlayed = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Duration range
|
||||
Text(
|
||||
'Duration Range (minutes)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Min',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Any',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
_minDuration = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Max',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Any',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
_maxDuration = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Sort order
|
||||
DropdownButtonFormField<String>(
|
||||
value: _sortOrder,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sort Order',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'newest_first', child: Text('Newest First')),
|
||||
DropdownMenuItem(value: 'oldest_first', child: Text('Oldest First')),
|
||||
DropdownMenuItem(value: 'shortest_first', child: Text('Shortest First')),
|
||||
DropdownMenuItem(value: 'longest_first', child: Text('Longest First')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_sortOrder = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Max episodes
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Max Episodes (Optional)',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Leave blank for no limit',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
_maxEpisodes = value;
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Group by podcast
|
||||
CheckboxListTile(
|
||||
title: const Text('Group by Podcast'),
|
||||
subtitle: const Text('Group episodes by their podcast'),
|
||||
value: _groupByPodcast,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_groupByPodcast = value ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal file
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal file
@@ -0,0 +1,968 @@
|
||||
// lib/ui/pinepods/downloads.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/services/download/download_service.dart';
|
||||
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
|
||||
import 'package:pinepods_mobile/state/bloc_state.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class PinepodsDownloads extends StatefulWidget {
|
||||
const PinepodsDownloads({super.key});
|
||||
|
||||
@override
|
||||
State<PinepodsDownloads> createState() => _PinepodsDownloadsState();
|
||||
}
|
||||
|
||||
class _PinepodsDownloadsState extends State<PinepodsDownloads> {
|
||||
final log = Logger('PinepodsDownloads');
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
|
||||
List<PinepodsEpisode> _serverDownloads = [];
|
||||
List<Episode> _localDownloads = [];
|
||||
Map<String, List<PinepodsEpisode>> _serverDownloadsByPodcast = {};
|
||||
Map<String, List<Episode>> _localDownloadsByPodcast = {};
|
||||
|
||||
bool _isLoadingServerDownloads = false;
|
||||
bool _isLoadingLocalDownloads = false;
|
||||
String? _errorMessage;
|
||||
|
||||
Set<String> _expandedPodcasts = {};
|
||||
int? _contextMenuEpisodeIndex;
|
||||
bool _isServerEpisode = false;
|
||||
|
||||
// Search functionality
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
Map<String, List<PinepodsEpisode>> _filteredServerDownloadsByPodcast = {};
|
||||
Map<String, List<Episode>> _filteredLocalDownloadsByPodcast = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDownloads();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
_filterDownloads();
|
||||
});
|
||||
}
|
||||
|
||||
void _filterDownloads() {
|
||||
// Filter server downloads
|
||||
_filteredServerDownloadsByPodcast = {};
|
||||
for (final entry in _serverDownloadsByPodcast.entries) {
|
||||
final podcastName = entry.key;
|
||||
final episodes = entry.value;
|
||||
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredServerDownloadsByPodcast[podcastName] = List.from(episodes);
|
||||
} else {
|
||||
final filteredEpisodes = episodes.where((episode) {
|
||||
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filteredEpisodes.isNotEmpty) {
|
||||
_filteredServerDownloadsByPodcast[podcastName] = filteredEpisodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter local downloads (will be called when local downloads are loaded)
|
||||
_filterLocalDownloads();
|
||||
}
|
||||
|
||||
void _filterLocalDownloads([Map<String, List<Episode>>? localDownloadsByPodcast]) {
|
||||
final downloadsToFilter = localDownloadsByPodcast ?? _localDownloadsByPodcast;
|
||||
_filteredLocalDownloadsByPodcast = {};
|
||||
|
||||
for (final entry in downloadsToFilter.entries) {
|
||||
final podcastName = entry.key;
|
||||
final episodes = entry.value;
|
||||
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredLocalDownloadsByPodcast[podcastName] = List.from(episodes);
|
||||
} else {
|
||||
final filteredEpisodes = episodes.where((episode) {
|
||||
return (episode.title ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
|
||||
if (filteredEpisodes.isNotEmpty) {
|
||||
_filteredLocalDownloadsByPodcast[podcastName] = filteredEpisodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDownloads() async {
|
||||
await Future.wait([
|
||||
_loadServerDownloads(),
|
||||
_loadLocalDownloads(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _loadServerDownloads() async {
|
||||
setState(() {
|
||||
_isLoadingServerDownloads = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer != null &&
|
||||
settings.pinepodsApiKey != null &&
|
||||
settings.pinepodsUserId != null) {
|
||||
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final downloads = await _pinepodsService.getServerDownloads(settings.pinepodsUserId!);
|
||||
|
||||
setState(() {
|
||||
_serverDownloads = downloads;
|
||||
_serverDownloadsByPodcast = _groupEpisodesByPodcast(downloads);
|
||||
_filterDownloads(); // Initialize filtered data
|
||||
_isLoadingServerDownloads = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoadingServerDownloads = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log.severe('Error loading server downloads: $e');
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load server downloads: $e';
|
||||
_isLoadingServerDownloads = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLocalDownloads() async {
|
||||
setState(() {
|
||||
_isLoadingLocalDownloads = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
|
||||
episodeBloc.fetchDownloads(false);
|
||||
|
||||
// Debug: Let's also directly check what the repository returns
|
||||
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
|
||||
final directDownloads = await podcastBloc.podcastService.loadDownloads();
|
||||
print('DEBUG: Direct downloads from repository: ${directDownloads.length} episodes');
|
||||
for (var episode in directDownloads) {
|
||||
print('DEBUG: Episode: ${episode.title}, GUID: ${episode.guid}, Downloaded: ${episode.downloaded}, Percentage: ${episode.downloadPercentage}');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoadingLocalDownloads = false;
|
||||
});
|
||||
} catch (e) {
|
||||
log.severe('Error loading local downloads: $e');
|
||||
setState(() {
|
||||
_isLoadingLocalDownloads = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<PinepodsEpisode>> _groupEpisodesByPodcast(List<PinepodsEpisode> episodes) {
|
||||
final grouped = <String, List<PinepodsEpisode>>{};
|
||||
|
||||
for (final episode in episodes) {
|
||||
final podcastName = episode.podcastName;
|
||||
if (!grouped.containsKey(podcastName)) {
|
||||
grouped[podcastName] = [];
|
||||
}
|
||||
grouped[podcastName]!.add(episode);
|
||||
}
|
||||
|
||||
// Sort episodes within each podcast by publication date (newest first)
|
||||
for (final episodes in grouped.values) {
|
||||
episodes.sort((a, b) {
|
||||
try {
|
||||
final dateA = DateTime.parse(a.episodePubDate);
|
||||
final dateB = DateTime.parse(b.episodePubDate);
|
||||
return dateB.compareTo(dateA); // newest first
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
Map<String, List<Episode>> _groupLocalEpisodesByPodcast(List<Episode> episodes) {
|
||||
final grouped = <String, List<Episode>>{};
|
||||
|
||||
for (final episode in episodes) {
|
||||
final podcastName = episode.podcast ?? 'Unknown Podcast';
|
||||
if (!grouped.containsKey(podcastName)) {
|
||||
grouped[podcastName] = [];
|
||||
}
|
||||
grouped[podcastName]!.add(episode);
|
||||
}
|
||||
|
||||
// Sort episodes within each podcast by publication date (newest first)
|
||||
for (final episodes in grouped.values) {
|
||||
episodes.sort((a, b) {
|
||||
if (a.publicationDate == null || b.publicationDate == null) {
|
||||
return 0;
|
||||
}
|
||||
return b.publicationDate!.compareTo(a.publicationDate!);
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
void _togglePodcastExpansion(String podcastKey) {
|
||||
setState(() {
|
||||
if (_expandedPodcasts.contains(podcastKey)) {
|
||||
_expandedPodcasts.remove(podcastKey);
|
||||
} else {
|
||||
_expandedPodcasts.add(podcastKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleServerEpisodeDelete(PinepodsEpisode episode) async {
|
||||
try {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsUserId != null) {
|
||||
final success = await _pinepodsService.deleteEpisode(
|
||||
episode.episodeId,
|
||||
settings.pinepodsUserId!,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Remove from local state
|
||||
setState(() {
|
||||
_serverDownloads.removeWhere((e) => e.episodeId == episode.episodeId);
|
||||
_serverDownloadsByPodcast = _groupEpisodesByPodcast(_serverDownloads);
|
||||
_filterDownloads(); // Update filtered lists after removal
|
||||
});
|
||||
} else {
|
||||
_showErrorSnackBar('Failed to delete episode from server');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.severe('Error deleting server episode: $e');
|
||||
_showErrorSnackBar('Error deleting episode: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleLocalEpisodeDelete(Episode episode) {
|
||||
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
|
||||
episodeBloc.deleteDownload(episode);
|
||||
|
||||
// The episode bloc will automatically update the downloads stream
|
||||
// which will trigger a UI refresh
|
||||
}
|
||||
|
||||
void _showContextMenu(int episodeIndex, bool isServerEpisode) {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = episodeIndex;
|
||||
_isServerEpisode = isServerEpisode;
|
||||
});
|
||||
}
|
||||
|
||||
void _hideContextMenu() {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = null;
|
||||
_isServerEpisode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _localDownloadServerEpisode(int episodeIndex) async {
|
||||
final episode = _serverDownloads[episodeIndex];
|
||||
|
||||
try {
|
||||
// Convert PinepodsEpisode to Episode for local download
|
||||
final localEpisode = Episode(
|
||||
guid: 'pinepods_${episode.episodeId}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}',
|
||||
podcast: episode.podcastName,
|
||||
title: episode.episodeTitle,
|
||||
description: episode.episodeDescription,
|
||||
imageUrl: episode.episodeArtwork,
|
||||
contentUrl: episode.episodeUrl,
|
||||
duration: episode.episodeDuration,
|
||||
publicationDate: DateTime.tryParse(episode.episodePubDate),
|
||||
author: episode.podcastName,
|
||||
season: 0,
|
||||
episode: 0,
|
||||
position: episode.listenDuration ?? 0,
|
||||
played: episode.completed,
|
||||
chapters: [],
|
||||
transcriptUrls: [],
|
||||
);
|
||||
|
||||
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
|
||||
|
||||
// First save the episode to the repository so it can be tracked
|
||||
await podcastBloc.podcastService.saveEpisode(localEpisode);
|
||||
|
||||
// Use the download service from podcast bloc
|
||||
final success = await podcastBloc.downloadService.downloadEpisode(localEpisode);
|
||||
|
||||
if (success) {
|
||||
_showSnackBar('Episode download started', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to start download', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error starting local download: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPodcastDropdown(String podcastKey, List<dynamic> episodes, {bool isServerDownload = false, String? displayName}) {
|
||||
final isExpanded = _expandedPodcasts.contains(podcastKey);
|
||||
final title = displayName ?? podcastKey;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isServerDownload ? Icons.cloud_download : Icons.file_download,
|
||||
color: isServerDownload ? Colors.blue : Colors.green,
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${episodes.length} episode${episodes.length != 1 ? 's' : ''}' +
|
||||
(episodes.length > 20 ? ' (showing 20 at a time)' : '')
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (episodes.length > 20)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Large',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.orange[800],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _togglePodcastExpansion(podcastKey),
|
||||
),
|
||||
if (isExpanded)
|
||||
PaginatedEpisodeList(
|
||||
episodes: episodes,
|
||||
isServerEpisodes: isServerDownload,
|
||||
onEpisodeTap: isServerDownload
|
||||
? (episode) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsEpisodeDetails(
|
||||
initialEpisode: episode,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
onEpisodeLongPress: isServerDownload
|
||||
? (episode, globalIndex) {
|
||||
// Find the index in the full _serverDownloads list
|
||||
final serverIndex = _serverDownloads.indexWhere((e) => e.episodeId == episode.episodeId);
|
||||
_showContextMenu(serverIndex >= 0 ? serverIndex : globalIndex, true);
|
||||
}
|
||||
: null,
|
||||
onPlayPressed: isServerDownload
|
||||
? (episode) => _playServerEpisode(episode)
|
||||
: (episode) => _playLocalEpisode(episode),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final episodeBloc = Provider.of<EpisodeBloc>(context);
|
||||
|
||||
// Show context menu as a modal overlay if needed
|
||||
if (_contextMenuEpisodeIndex != null) {
|
||||
final episodeIndex = _contextMenuEpisodeIndex!;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_isServerEpisode) {
|
||||
// Show server episode context menu
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withOpacity(0.3),
|
||||
builder: (context) => EpisodeContextMenu(
|
||||
episode: _serverDownloads[episodeIndex],
|
||||
onDownload: () {
|
||||
Navigator.of(context).pop();
|
||||
_handleServerEpisodeDelete(_serverDownloads[episodeIndex]);
|
||||
_hideContextMenu();
|
||||
},
|
||||
onLocalDownload: () {
|
||||
Navigator.of(context).pop();
|
||||
_localDownloadServerEpisode(episodeIndex);
|
||||
},
|
||||
onDismiss: () {
|
||||
Navigator.of(context).pop();
|
||||
_hideContextMenu();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
// Reset the context menu index after storing it locally
|
||||
_contextMenuEpisodeIndex = null;
|
||||
}
|
||||
|
||||
return StreamBuilder<BlocState>(
|
||||
stream: episodeBloc.downloads,
|
||||
builder: (context, snapshot) {
|
||||
final localDownloadsState = snapshot.data;
|
||||
List<Episode> currentLocalDownloads = [];
|
||||
Map<String, List<Episode>> currentLocalDownloadsByPodcast = {};
|
||||
|
||||
if (localDownloadsState is BlocPopulatedState<List<Episode>>) {
|
||||
currentLocalDownloads = localDownloadsState.results ?? [];
|
||||
currentLocalDownloadsByPodcast = _groupLocalEpisodesByPodcast(currentLocalDownloads);
|
||||
}
|
||||
|
||||
final isLoading = _isLoadingServerDownloads ||
|
||||
_isLoadingLocalDownloads ||
|
||||
(localDownloadsState is BlocLoadingState);
|
||||
|
||||
if (isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: PlatformProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Update filtered local downloads when local downloads change
|
||||
_filterLocalDownloads(currentLocalDownloadsByPodcast);
|
||||
|
||||
if (_errorMessage != null) {
|
||||
// Check if this is a server connection error - show offline mode for downloads
|
||||
if (_errorMessage!.isServerConnectionError) {
|
||||
// Show offline downloads only with special UI
|
||||
return _buildOfflineDownloadsView(_filteredLocalDownloadsByPodcast);
|
||||
} else {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!.userFriendlyMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadDownloads,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_filteredLocalDownloadsByPodcast.isEmpty && _filteredServerDownloadsByPodcast.isEmpty) {
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
// Show no search results message
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No downloads found',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No downloads match "$_searchQuery"',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Show empty downloads message
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.download_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No downloads found',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Downloaded episodes will appear here',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildDownloadsList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter episodes...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadsList() {
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Local Downloads Section
|
||||
if (_filteredLocalDownloadsByPodcast.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.smartphone, color: Colors.green[600]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Local Downloads'
|
||||
: 'Local Downloads (${_countFilteredEpisodes(_filteredLocalDownloadsByPodcast)})',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
..._filteredLocalDownloadsByPodcast.entries.map((entry) {
|
||||
final podcastName = entry.key;
|
||||
final episodes = entry.value;
|
||||
final podcastKey = 'local_$podcastName';
|
||||
|
||||
return _buildPodcastDropdown(
|
||||
podcastKey,
|
||||
episodes,
|
||||
isServerDownload: false,
|
||||
displayName: podcastName,
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
|
||||
// Server Downloads Section
|
||||
if (_filteredServerDownloadsByPodcast.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_download, color: Colors.blue[600]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Server Downloads'
|
||||
: 'Server Downloads (${_countFilteredEpisodes(_filteredServerDownloadsByPodcast)})',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
..._filteredServerDownloadsByPodcast.entries.map((entry) {
|
||||
final podcastName = entry.key;
|
||||
final episodes = entry.value;
|
||||
final podcastKey = 'server_$podcastName';
|
||||
|
||||
return _buildPodcastDropdown(
|
||||
podcastKey,
|
||||
episodes,
|
||||
isServerDownload: true,
|
||||
displayName: podcastName,
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
|
||||
// Bottom padding
|
||||
const SizedBox(height: 100),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
int _countFilteredEpisodes(Map<String, List<dynamic>> downloadsByPodcast) {
|
||||
return downloadsByPodcast.values.fold(0, (sum, episodes) => sum + episodes.length);
|
||||
}
|
||||
|
||||
void _playServerEpisode(PinepodsEpisode episode) {
|
||||
// TODO: Implement server episode playback
|
||||
// This would involve getting the stream URL from the server
|
||||
// and playing it through the audio service
|
||||
log.info('Playing server episode: ${episode.episodeTitle}');
|
||||
|
||||
_showErrorSnackBar('Server episode playback not yet implemented');
|
||||
}
|
||||
|
||||
Future<void> _playLocalEpisode(Episode episode) async {
|
||||
try {
|
||||
log.info('Playing local episode: ${episode.title}');
|
||||
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
|
||||
// Use the regular audio player service for offline playback
|
||||
// This bypasses the PinePods service and server dependencies
|
||||
await audioPlayerService.playEpisode(episode: episode, resume: true);
|
||||
|
||||
log.info('Successfully started local episode playback');
|
||||
} catch (e) {
|
||||
log.severe('Error playing local episode: $e');
|
||||
_showErrorSnackBar('Failed to play episode: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOfflinePodcastDropdown(String podcastKey, List<Episode> episodes, {String? displayName}) {
|
||||
final isExpanded = _expandedPodcasts.contains(podcastKey);
|
||||
final title = displayName ?? podcastKey;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.offline_pin,
|
||||
color: Colors.green[700],
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${episodes.length} episode${episodes.length != 1 ? 's' : ''} available offline'
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Offline',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.green[700],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => _togglePodcastExpansion(podcastKey),
|
||||
),
|
||||
if (isExpanded)
|
||||
PaginatedEpisodeList(
|
||||
episodes: episodes,
|
||||
isServerEpisodes: false,
|
||||
isOfflineMode: true,
|
||||
onPlayPressed: (episode) => _playLocalEpisode(episode),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOfflineDownloadsView(Map<String, List<Episode>> localDownloadsByPodcast) {
|
||||
return MultiSliver(
|
||||
children: [
|
||||
// Offline banner
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
margin: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[100],
|
||||
border: Border.all(color: Colors.orange[300]!),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
color: Colors.orange[800],
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Offline Mode',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange[800],
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Server unavailable. Showing local downloads only.',
|
||||
style: TextStyle(
|
||||
color: Colors.orange[700],
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadDownloads();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
size: 16,
|
||||
color: Colors.orange[800],
|
||||
),
|
||||
label: Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
color: Colors.orange[800],
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange[50],
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search bar for filtering local downloads
|
||||
_buildSearchBar(),
|
||||
|
||||
// Local downloads content
|
||||
if (localDownloadsByPodcast.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No local downloads',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Download episodes while online to access them here',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Local downloads header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.smartphone, color: Colors.green[600]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Local Downloads'
|
||||
: 'Local Downloads (${_countFilteredEpisodes(localDownloadsByPodcast)})',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Local downloads by podcast
|
||||
...localDownloadsByPodcast.entries.map((entry) {
|
||||
final podcastName = entry.key;
|
||||
final episodes = entry.value;
|
||||
final podcastKey = 'offline_local_$podcastName';
|
||||
|
||||
return _buildOfflinePodcastDropdown(
|
||||
podcastKey,
|
||||
episodes,
|
||||
displayName: podcastName,
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// Bottom padding
|
||||
const SizedBox(height: 100),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal file
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal file
@@ -0,0 +1,963 @@
|
||||
// lib/ui/pinepods/episode_details.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/audio/default_audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_search.dart';
|
||||
import 'package:pinepods_mobile/entities/person.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_description.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/mini_player.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
|
||||
import 'package:pinepods_mobile/services/global_services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
|
||||
class PinepodsEpisodeDetails extends StatefulWidget {
|
||||
final PinepodsEpisode initialEpisode;
|
||||
|
||||
const PinepodsEpisodeDetails({
|
||||
Key? key,
|
||||
required this.initialEpisode,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsEpisodeDetails> createState() => _PinepodsEpisodeDetailsState();
|
||||
}
|
||||
|
||||
class _PinepodsEpisodeDetailsState extends State<PinepodsEpisodeDetails> {
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
// Use global audio service instead of creating local instance
|
||||
PinepodsEpisode? _episode;
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
List<Person> _persons = [];
|
||||
bool _isDownloadedLocally = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_episode = widget.initialEpisode;
|
||||
_loadEpisodeDetails();
|
||||
_checkLocalDownloadStatus();
|
||||
}
|
||||
|
||||
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
|
||||
|
||||
Future<void> _checkLocalDownloadStatus() async {
|
||||
if (_episode == null) return;
|
||||
|
||||
final isDownloaded = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, _episode!);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDownloadedLocally = isDownloaded;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _localDownloadEpisode() async {
|
||||
if (_episode == null) return;
|
||||
|
||||
final success = await LocalDownloadUtils.localDownloadEpisode(context, _episode!);
|
||||
|
||||
if (success) {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
|
||||
await _checkLocalDownloadStatus(); // Update button state
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLocalDownload() async {
|
||||
if (_episode == null) return;
|
||||
|
||||
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, _episode!);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
LocalDownloadUtils.showSnackBar(
|
||||
context,
|
||||
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
|
||||
Colors.orange
|
||||
);
|
||||
await _checkLocalDownloadStatus(); // Update button state
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEpisodeDetails() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
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 login first.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
final userId = settings.pinepodsUserId!;
|
||||
|
||||
final episodeDetails = await _pinepodsService.getEpisodeMetadata(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
isYoutube: _episode!.isYoutube,
|
||||
personEpisode: false, // Adjust if needed
|
||||
);
|
||||
|
||||
if (episodeDetails != null) {
|
||||
// Fetch podcast 2.0 data for persons information
|
||||
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(
|
||||
episodeDetails.episodeId,
|
||||
userId,
|
||||
);
|
||||
|
||||
List<Person> persons = [];
|
||||
if (podcast2Data != null) {
|
||||
final personsData = podcast2Data['people'] as List<dynamic>?;
|
||||
if (personsData != null) {
|
||||
try {
|
||||
persons = personsData.map((personData) {
|
||||
return Person(
|
||||
name: personData['name'] ?? '',
|
||||
role: personData['role'] ?? '',
|
||||
group: personData['group'] ?? '',
|
||||
image: personData['img'],
|
||||
link: personData['href'],
|
||||
);
|
||||
}).toList();
|
||||
print('Loaded ${persons.length} persons from episode 2.0 data');
|
||||
} catch (e) {
|
||||
print('Error parsing persons data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_episode = episodeDetails;
|
||||
_persons = persons;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load episode details';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Error loading episode details: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCurrentEpisodePlaying() {
|
||||
try {
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
final currentEpisode = audioPlayerService.nowPlaying;
|
||||
return currentEpisode != null && currentEpisode.guid == _episode!.episodeUrl;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isAudioPlaying() {
|
||||
try {
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
// This method is no longer needed since we're using StreamBuilder
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
|
||||
if (_audioService == null) {
|
||||
_showSnackBar('Audio service not available', Colors.red);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
|
||||
// Check if this episode is currently playing
|
||||
if (_isCurrentEpisodePlaying()) {
|
||||
// This episode is loaded, check current state and toggle
|
||||
final currentState = audioPlayerService.playingState;
|
||||
if (currentState != null) {
|
||||
// Listen to the current state
|
||||
final state = await currentState.first;
|
||||
if (state == AudioState.playing) {
|
||||
await audioPlayerService.pause();
|
||||
} else {
|
||||
await audioPlayerService.play();
|
||||
}
|
||||
} else {
|
||||
await audioPlayerService.play();
|
||||
}
|
||||
} else {
|
||||
// Start playing this episode
|
||||
await playPinepodsEpisodeWithOptionalFullScreen(
|
||||
context,
|
||||
_audioService!,
|
||||
_episode!,
|
||||
resume: _episode!.isStarted,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Failed to control playback: ${e.toString()}', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTimestampTap(Duration timestamp) async {
|
||||
|
||||
if (_audioService == null) {
|
||||
_showSnackBar('Audio service not available', Colors.red);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
|
||||
// Check if this episode is currently playing
|
||||
final currentEpisode = audioPlayerService.nowPlaying;
|
||||
final isCurrentEpisode = currentEpisode != null &&
|
||||
currentEpisode.guid == _episode!.episodeUrl;
|
||||
|
||||
if (!isCurrentEpisode) {
|
||||
// Start playing the episode first
|
||||
await playPinepodsEpisodeWithOptionalFullScreen(
|
||||
context,
|
||||
_audioService!,
|
||||
_episode!,
|
||||
resume: false, // Start from beginning initially
|
||||
);
|
||||
|
||||
// Wait a moment for the episode to start loading
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// Seek to the timestamp (convert Duration to seconds as int)
|
||||
await audioPlayerService.seek(position: timestamp.inSeconds);
|
||||
|
||||
} catch (e) {
|
||||
_showSnackBar('Failed to jump to timestamp: ${e.toString()}', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEpisode() async {
|
||||
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) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, saved: true);
|
||||
});
|
||||
_showSnackBar('Episode saved!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to save episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error saving episode: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeSavedEpisode() async {
|
||||
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) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, saved: false);
|
||||
});
|
||||
_showSnackBar('Removed from saved episodes', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to remove saved episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error removing saved episode: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleQueue() async {
|
||||
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 {
|
||||
bool success;
|
||||
if (_episode!.queued) {
|
||||
success = await _pinepodsService.removeQueuedEpisode(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, queued: false);
|
||||
});
|
||||
_showSnackBar('Removed from queue', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.queueEpisode(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, queued: true);
|
||||
});
|
||||
_showSnackBar('Added to queue!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update queue', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating queue: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleDownload() async {
|
||||
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 {
|
||||
bool success;
|
||||
if (_episode!.downloaded) {
|
||||
success = await _pinepodsService.deleteEpisode(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, downloaded: false);
|
||||
});
|
||||
_showSnackBar('Episode deleted from server', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.downloadEpisode(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, downloaded: true);
|
||||
});
|
||||
_showSnackBar('Episode download queued!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update download', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating download: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleComplete() async {
|
||||
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 {
|
||||
bool success;
|
||||
if (_episode!.completed) {
|
||||
success = await _pinepodsService.markEpisodeUncompleted(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, completed: false);
|
||||
});
|
||||
_showSnackBar('Marked as incomplete', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.markEpisodeCompleted(
|
||||
_episode!.episodeId,
|
||||
userId,
|
||||
_episode!.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episode = _updateEpisodeProperty(_episode!, completed: true);
|
||||
});
|
||||
_showSnackBar('Marked as complete!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update completion status', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating completion: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
PinepodsEpisode _updateEpisodeProperty(
|
||||
PinepodsEpisode episode, {
|
||||
bool? saved,
|
||||
bool? downloaded,
|
||||
bool? queued,
|
||||
bool? completed,
|
||||
}) {
|
||||
return PinepodsEpisode(
|
||||
podcastName: episode.podcastName,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
episodePubDate: episode.episodePubDate,
|
||||
episodeDescription: episode.episodeDescription,
|
||||
episodeArtwork: episode.episodeArtwork,
|
||||
episodeUrl: episode.episodeUrl,
|
||||
episodeDuration: episode.episodeDuration,
|
||||
listenDuration: episode.listenDuration,
|
||||
episodeId: episode.episodeId,
|
||||
completed: completed ?? episode.completed,
|
||||
saved: saved ?? episode.saved,
|
||||
queued: queued ?? episode.queued,
|
||||
downloaded: downloaded ?? episode.downloaded,
|
||||
isYoutube: episode.isYoutube,
|
||||
podcastId: episode.podcastId,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _navigateToPodcast() async {
|
||||
if (_episode!.podcastId == null) {
|
||||
_showSnackBar('Podcast ID not available', Colors.orange);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// Fetch the actual podcast details to get correct episode count
|
||||
final podcastDetails = await _pinepodsService.getPodcastDetailsById(_episode!.podcastId!, userId);
|
||||
|
||||
final podcast = UnifiedPinepodsPodcast(
|
||||
id: _episode!.podcastId!,
|
||||
indexId: 0,
|
||||
title: _episode!.podcastName,
|
||||
url: podcastDetails?['feedurl'] ?? '',
|
||||
originalUrl: podcastDetails?['feedurl'] ?? '',
|
||||
link: podcastDetails?['websiteurl'] ?? '',
|
||||
description: podcastDetails?['description'] ?? '',
|
||||
author: podcastDetails?['author'] ?? '',
|
||||
ownerName: podcastDetails?['author'] ?? '',
|
||||
image: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
|
||||
artwork: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
|
||||
lastUpdateTime: 0,
|
||||
explicit: podcastDetails?['explicit'] ?? false,
|
||||
episodeCount: podcastDetails?['episodecount'] ?? 0,
|
||||
);
|
||||
|
||||
// Navigate to podcast details - same as podcast tile does
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
settings: const RouteSettings(name: 'pinepods_podcast_details'),
|
||||
builder: (context) => PinepodsPodcastDetails(
|
||||
podcast: podcast,
|
||||
isFollowing: true, // Assume following since we have a podcast ID
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_showSnackBar('Error navigating to podcast: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Episode Details'),
|
||||
),
|
||||
body: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading episode details...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Episode Details'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadEpisodeDetails,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_episode!.podcastName),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode artwork and basic info
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Episode artwork
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _episode!.episodeArtwork.isNotEmpty
|
||||
? Image.network(
|
||||
_episode!.episodeArtwork,
|
||||
width: 120,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Episode info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Clickable podcast name
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToPodcast(),
|
||||
child: Text(
|
||||
_episode!.podcastName,
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_episode!.episodeTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_episode!.formattedDuration,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_episode!.formattedPubDate,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (_episode!.isStarted) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Listened: ${_episode!.formattedListenDuration}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
value: _episode!.progressPercentage / 100,
|
||||
backgroundColor: Colors.grey[300],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action buttons
|
||||
Column(
|
||||
children: [
|
||||
// First row: Play, Save, Queue (3 buttons, each 1/3 width)
|
||||
Row(
|
||||
children: [
|
||||
// Play/Pause button
|
||||
Expanded(
|
||||
child: StreamBuilder<AudioState>(
|
||||
stream: Provider.of<AudioPlayerService>(context, listen: false).playingState,
|
||||
builder: (context, snapshot) {
|
||||
final isCurrentEpisode = _isCurrentEpisodePlaying();
|
||||
final isPlaying = snapshot.data == AudioState.playing;
|
||||
final isCurrentlyPlaying = isCurrentEpisode && isPlaying;
|
||||
|
||||
IconData icon;
|
||||
String label;
|
||||
|
||||
if (_episode!.completed) {
|
||||
icon = Icons.replay;
|
||||
label = 'Replay';
|
||||
} else if (isCurrentlyPlaying) {
|
||||
icon = Icons.pause;
|
||||
label = 'Pause';
|
||||
} else {
|
||||
icon = Icons.play_arrow;
|
||||
label = 'Play';
|
||||
}
|
||||
|
||||
return OutlinedButton.icon(
|
||||
onPressed: _togglePlayPause,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Save/Unsave button
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _episode!.saved ? _removeSavedEpisode : _saveEpisode,
|
||||
icon: Icon(
|
||||
_episode!.saved ? Icons.bookmark : Icons.bookmark_outline,
|
||||
color: _episode!.saved ? Colors.orange : null,
|
||||
),
|
||||
label: Text(_episode!.saved ? 'Saved' : 'Save'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Queue button
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _toggleQueue,
|
||||
icon: Icon(
|
||||
_episode!.queued ? Icons.queue_music : Icons.queue_music_outlined,
|
||||
color: _episode!.queued ? Colors.purple : null,
|
||||
),
|
||||
label: Text(_episode!.queued ? 'Queued' : 'Queue'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Second row: Download, Complete (2 buttons, each 1/2 width)
|
||||
Row(
|
||||
children: [
|
||||
// Download button
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _toggleDownload,
|
||||
icon: Icon(
|
||||
_episode!.downloaded ? Icons.download_done : Icons.download_outlined,
|
||||
color: _episode!.downloaded ? Colors.blue : null,
|
||||
),
|
||||
label: Text(_episode!.downloaded ? 'Downloaded' : 'Download'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Complete button
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _toggleComplete,
|
||||
icon: Icon(
|
||||
_episode!.completed ? Icons.check_circle : Icons.check_circle_outline,
|
||||
color: _episode!.completed ? Colors.green : null,
|
||||
),
|
||||
label: Text(_episode!.completed ? 'Complete' : 'Mark Complete'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Third row: Local Download (full width)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _isDownloadedLocally ? _deleteLocalDownload : _localDownloadEpisode,
|
||||
icon: Icon(
|
||||
_isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
|
||||
color: _isDownloadedLocally ? Colors.red : Colors.green,
|
||||
),
|
||||
label: Text(_isDownloadedLocally ? 'Delete Local Download' : 'Download Locally'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(
|
||||
color: _isDownloadedLocally ? Colors.red : Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Hosts/Guests section
|
||||
if (_persons.isNotEmpty) ...[
|
||||
const SizedBox(height: 24),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Hosts & Guests',
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _persons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = _persons[index];
|
||||
return Container(
|
||||
width: 70,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
child: person.image != null && person.image!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
child: PodcastImage(
|
||||
url: person.image!,
|
||||
width: 50,
|
||||
height: 50,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person,
|
||||
size: 30,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
person.name,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Episode description
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
EpisodeDescription(
|
||||
content: _episode!.episodeDescription,
|
||||
onTimestampTap: _handleTimestampTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const MiniPlayer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Don't dispose global audio service - it should persist across pages
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal file
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal file
@@ -0,0 +1,817 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_episode.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/services/search_history_service.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/widgets/paginated_episode_list.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Episode search page for finding episodes in user's subscriptions
|
||||
///
|
||||
/// This page allows users to search through episodes in their subscribed podcasts
|
||||
/// with debounced search input and animated loading states.
|
||||
class EpisodeSearchPage extends StatefulWidget {
|
||||
const EpisodeSearchPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EpisodeSearchPage> createState() => _EpisodeSearchPageState();
|
||||
}
|
||||
|
||||
class _EpisodeSearchPageState extends State<EpisodeSearchPage> with TickerProviderStateMixin {
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
final SearchHistoryService _searchHistoryService = SearchHistoryService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
Timer? _debounceTimer;
|
||||
|
||||
List<SearchEpisodeResult> _searchResults = [];
|
||||
List<String> _searchHistory = [];
|
||||
bool _isLoading = false;
|
||||
bool _hasSearched = false;
|
||||
bool _showHistory = false;
|
||||
String? _errorMessage;
|
||||
String _currentQuery = '';
|
||||
|
||||
// Use global audio service instead of creating local instance
|
||||
int? _contextMenuEpisodeIndex;
|
||||
|
||||
// Animation controllers
|
||||
late AnimationController _fadeAnimationController;
|
||||
late AnimationController _slideAnimationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_setupSearch();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
// Fade animation for results
|
||||
_fadeAnimationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _fadeAnimationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// Slide animation for search bar
|
||||
_slideAnimationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0),
|
||||
end: const Offset(0, -0.2),
|
||||
).animate(CurvedAnimation(
|
||||
parent: _slideAnimationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
void _setupSearch() {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
}
|
||||
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
_loadSearchHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadSearchHistory() async {
|
||||
final history = await _searchHistoryService.getEpisodeSearchHistory();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchHistory = history;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _selectHistoryItem(String searchTerm) {
|
||||
_searchController.text = searchTerm;
|
||||
_performSearch(searchTerm);
|
||||
}
|
||||
|
||||
Future<void> _removeHistoryItem(String searchTerm) async {
|
||||
await _searchHistoryService.removeEpisodeSearchTerm(searchTerm);
|
||||
await _loadSearchHistory();
|
||||
}
|
||||
|
||||
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
|
||||
|
||||
Future<void> _playEpisode(PinepodsEpisode episode) async {
|
||||
if (_audioService == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio service not available'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Playing ${episode.episodeTitle}'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to play episode: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showContextMenu(int episodeIndex) {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = episodeIndex;
|
||||
});
|
||||
}
|
||||
|
||||
void _hideContextMenu() {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEpisode(int episodeIndex) async {
|
||||
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
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);
|
||||
// Update local state
|
||||
setState(() {
|
||||
_searchResults[episodeIndex] = SearchEpisodeResult(
|
||||
podcastId: _searchResults[episodeIndex].podcastId,
|
||||
podcastName: _searchResults[episodeIndex].podcastName,
|
||||
artworkUrl: _searchResults[episodeIndex].artworkUrl,
|
||||
author: _searchResults[episodeIndex].author,
|
||||
categories: _searchResults[episodeIndex].categories,
|
||||
description: _searchResults[episodeIndex].description,
|
||||
episodeCount: _searchResults[episodeIndex].episodeCount,
|
||||
feedUrl: _searchResults[episodeIndex].feedUrl,
|
||||
websiteUrl: _searchResults[episodeIndex].websiteUrl,
|
||||
explicit: _searchResults[episodeIndex].explicit,
|
||||
userId: _searchResults[episodeIndex].userId,
|
||||
episodeId: _searchResults[episodeIndex].episodeId,
|
||||
episodeTitle: _searchResults[episodeIndex].episodeTitle,
|
||||
episodeDescription: _searchResults[episodeIndex].episodeDescription,
|
||||
episodePubDate: _searchResults[episodeIndex].episodePubDate,
|
||||
episodeArtwork: _searchResults[episodeIndex].episodeArtwork,
|
||||
episodeUrl: _searchResults[episodeIndex].episodeUrl,
|
||||
episodeDuration: _searchResults[episodeIndex].episodeDuration,
|
||||
completed: _searchResults[episodeIndex].completed,
|
||||
saved: true, // We just saved it
|
||||
queued: _searchResults[episodeIndex].queued,
|
||||
downloaded: _searchResults[episodeIndex].downloaded,
|
||||
isYoutube: _searchResults[episodeIndex].isYoutube,
|
||||
listenDuration: _searchResults[episodeIndex].listenDuration,
|
||||
);
|
||||
});
|
||||
} 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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
_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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
_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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
_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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
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 = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.trim();
|
||||
|
||||
setState(() {
|
||||
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
|
||||
});
|
||||
|
||||
if (_debounceTimer?.isActive ?? false) {
|
||||
_debounceTimer!.cancel();
|
||||
}
|
||||
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
if (query.isNotEmpty && query != _currentQuery) {
|
||||
_currentQuery = query;
|
||||
_performSearch(query);
|
||||
} else if (query.isEmpty) {
|
||||
_clearResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_showHistory = false;
|
||||
});
|
||||
|
||||
// Save search term to history
|
||||
await _searchHistoryService.addEpisodeSearchTerm(query);
|
||||
await _loadSearchHistory();
|
||||
|
||||
// Animate search bar to top
|
||||
_slideAnimationController.forward();
|
||||
|
||||
try {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
throw Exception('Not logged in');
|
||||
}
|
||||
|
||||
final results = await _pinepodsService.searchEpisodes(userId, query);
|
||||
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_isLoading = false;
|
||||
_hasSearched = true;
|
||||
});
|
||||
|
||||
// Animate results in
|
||||
_fadeAnimationController.forward();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
_hasSearched = true;
|
||||
_searchResults = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _clearResults() {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_hasSearched = false;
|
||||
_errorMessage = null;
|
||||
_currentQuery = '';
|
||||
_showHistory = _searchHistory.isNotEmpty;
|
||||
});
|
||||
_fadeAnimationController.reset();
|
||||
_slideAnimationController.reverse();
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
Theme.of(context).primaryColor.withOpacity(0.05),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search for episodes...',
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_clearResults();
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(64),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Searching...',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
if (!_hasSearched) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search Your Episodes',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Find episodes from your subscribed podcasts',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No Episodes Found',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try adjusting your search terms',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search Error',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage ?? 'Unknown error occurred',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_currentQuery.isNotEmpty) {
|
||||
_performSearch(_currentQuery);
|
||||
}
|
||||
},
|
||||
child: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResults() {
|
||||
// Convert search results to PinepodsEpisode objects
|
||||
final episodes = _searchResults.map((result) => result.toPinepodsEpisode()).toList();
|
||||
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: PaginatedEpisodeList(
|
||||
episodes: episodes,
|
||||
isServerEpisodes: true,
|
||||
pageSize: 20, // Show 20 episodes at a time for good performance
|
||||
onEpisodeTap: (episode) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsEpisodeDetails(
|
||||
initialEpisode: episode,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onEpisodeLongPress: (episode, globalIndex) {
|
||||
// Find the original index in _searchResults for context menu
|
||||
final originalIndex = _searchResults.indexWhere(
|
||||
(result) => result.episodeId == episode.episodeId
|
||||
);
|
||||
if (originalIndex != -1) {
|
||||
_showContextMenu(originalIndex);
|
||||
}
|
||||
},
|
||||
onPlayPressed: (episode) => _playEpisode(episode),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchHistory() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Recent Searches',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_searchHistory.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _searchHistoryService.clearEpisodeSearchHistory();
|
||||
await _loadSearchHistory();
|
||||
},
|
||||
child: Text(
|
||||
'Clear All',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
..._searchHistory.take(10).map((searchTerm) => Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
Icons.history,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
searchTerm,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => _removeHistoryItem(searchTerm),
|
||||
),
|
||||
onTap: () => _selectHistoryItem(searchTerm),
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Show context menu as a modal overlay if needed
|
||||
if (_contextMenuEpisodeIndex != null) {
|
||||
final episodeIndex = _contextMenuEpisodeIndex!; // Store locally to avoid null issues
|
||||
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
|
||||
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 SliverFillRemaining(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
// Dismiss keyboard when tapping outside
|
||||
FocusScope.of(context).unfocus();
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _showHistory
|
||||
? _buildSearchHistory()
|
||||
: _isLoading
|
||||
? _buildLoadingIndicator()
|
||||
: _errorMessage != null
|
||||
? _buildErrorState()
|
||||
: _searchResults.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildResults(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
_focusNode.dispose();
|
||||
_fadeAnimationController.dispose();
|
||||
_slideAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
File diff suppressed because it is too large
Load Diff
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal file
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal file
@@ -0,0 +1,745 @@
|
||||
// lib/ui/pinepods/history.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/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:pinepods_mobile/services/global_services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class PinepodsHistory extends StatefulWidget {
|
||||
const PinepodsHistory({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsHistory> createState() => _PinepodsHistoryState();
|
||||
}
|
||||
|
||||
class _PinepodsHistoryState extends State<PinepodsHistory> {
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
List<PinepodsEpisode> _episodes = [];
|
||||
List<PinepodsEpisode> _filteredEpisodes = [];
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
// Use global audio service instead of creating local instance
|
||||
int? _contextMenuEpisodeIndex;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
// Don't dispose global audio service - it should persist across pages
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
_filterEpisodes();
|
||||
});
|
||||
}
|
||||
|
||||
void _filterEpisodes() {
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredEpisodes = List.from(_episodes);
|
||||
} else {
|
||||
_filteredEpisodes = _episodes.where((episode) {
|
||||
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
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 login first.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
final userId = settings.pinepodsUserId!;
|
||||
|
||||
final episodes = await _pinepodsService.getUserHistory(userId);
|
||||
|
||||
// Enrich episodes with best available positions (local vs server)
|
||||
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
|
||||
context,
|
||||
_pinepodsService,
|
||||
episodes,
|
||||
userId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_episodes = enrichedEpisodes;
|
||||
// Sort episodes by publication date (newest first)
|
||||
_episodes.sort((a, b) {
|
||||
try {
|
||||
final dateA = DateTime.parse(a.episodePubDate);
|
||||
final dateB = DateTime.parse(b.episodePubDate);
|
||||
return dateB.compareTo(dateA); // Newest first
|
||||
} catch (e) {
|
||||
return 0; // Keep original order if parsing fails
|
||||
}
|
||||
});
|
||||
_filterEpisodes(); // Initialize filtered list
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// After loading episodes, check their local download status
|
||||
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load listening history: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
// Clear local download status cache on refresh
|
||||
LocalDownloadUtils.clearCache();
|
||||
await _loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _playEpisode(PinepodsEpisode episode) async {
|
||||
|
||||
if (_audioService == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio service not available'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Starting ${episode.episodeTitle}...'),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
await _audioService!.playPinepodsEpisode(
|
||||
pinepodsEpisode: episode,
|
||||
resume: episode.isStarted,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Now playing: ${episode.episodeTitle}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to play episode: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showContextMenu(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withOpacity(0.3),
|
||||
builder: (context) => EpisodeContextMenu(
|
||||
episode: episode,
|
||||
isDownloadedLocally: isDownloadedLocally,
|
||||
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);
|
||||
},
|
||||
onDeleteLocalDownload: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteLocalDownload(episodeIndex);
|
||||
},
|
||||
onQueue: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleQueueEpisode(episodeIndex);
|
||||
},
|
||||
onMarkComplete: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleMarkComplete(episodeIndex);
|
||||
},
|
||||
onDismiss: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _localDownloadEpisode(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
|
||||
|
||||
if (success) {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLocalDownload(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
LocalDownloadUtils.showSnackBar(
|
||||
context,
|
||||
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
|
||||
Colors.orange
|
||||
);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideContextMenu() {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.saveEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Episode saved!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to save episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error saving episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _removeSavedEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.removeSavedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Removed from saved episodes', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to remove saved episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error removing saved episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _downloadEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.downloadEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Episode download queued!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to queue download', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error downloading episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _deleteEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.deleteEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Episode deleted from server', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to delete episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error deleting episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleQueueEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.queued) {
|
||||
success = await _pinepodsService.removeQueuedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Removed from queue', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.queueEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Added to queue!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update queue', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating queue: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleMarkComplete(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.completed) {
|
||||
success = await _pinepodsService.markEpisodeUncompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Marked as incomplete', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.markEpisodeCompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
|
||||
_filterEpisodes(); // Update filtered list to reflect changes
|
||||
});
|
||||
_showSnackBar('Marked as complete!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update completion status', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating completion: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
PinepodsEpisode _updateEpisodeProperty(
|
||||
PinepodsEpisode episode, {
|
||||
bool? saved,
|
||||
bool? downloaded,
|
||||
bool? queued,
|
||||
bool? completed,
|
||||
}) {
|
||||
return PinepodsEpisode(
|
||||
podcastName: episode.podcastName,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
episodePubDate: episode.episodePubDate,
|
||||
episodeDescription: episode.episodeDescription,
|
||||
episodeArtwork: episode.episodeArtwork,
|
||||
episodeUrl: episode.episodeUrl,
|
||||
episodeDuration: episode.episodeDuration,
|
||||
listenDuration: episode.listenDuration,
|
||||
episodeId: episode.episodeId,
|
||||
completed: completed ?? episode.completed,
|
||||
saved: saved ?? episode.saved,
|
||||
queued: queued ?? episode.queued,
|
||||
downloaded: downloaded ?? episode.downloaded,
|
||||
isYoutube: episode.isYoutube,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading listening history...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return SliverServerErrorPage(
|
||||
errorMessage: _errorMessage.isServerConnectionError
|
||||
? null
|
||||
: _errorMessage,
|
||||
onRetry: _refresh,
|
||||
title: 'History Unavailable',
|
||||
subtitle: _errorMessage.isServerConnectionError
|
||||
? 'Unable to connect to the PinePods server'
|
||||
: 'Failed to load listening history',
|
||||
);
|
||||
}
|
||||
|
||||
if (_episodes.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No listening history',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Episodes you listen to will appear here',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildEpisodesList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter episodes...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodesList() {
|
||||
// Check if search returned no results
|
||||
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No episodes found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No episodes match "$_searchQuery"',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0) {
|
||||
// Header
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Listening History'
|
||||
: 'Search Results (${_filteredEpisodes.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Episodes (index - 1 because of header)
|
||||
final episodeIndex = index - 1;
|
||||
final episode = _filteredEpisodes[episodeIndex];
|
||||
// Find the original index for context menu operations
|
||||
final originalIndex = _episodes.indexOf(episode);
|
||||
return PinepodsEpisodeCard(
|
||||
episode: episode,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsEpisodeDetails(
|
||||
initialEpisode: episode,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: originalIndex >= 0 ? () => _showContextMenu(originalIndex) : null,
|
||||
onPlayPressed: () => _playEpisode(episode),
|
||||
);
|
||||
},
|
||||
childCount: _filteredEpisodes.length + 1, // +1 for header
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
File diff suppressed because it is too large
Load Diff
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal file
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
// lib/ui/pinepods/more_menu.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinepods_mobile/ui/library/downloads.dart';
|
||||
import 'package:pinepods_mobile/ui/settings/settings.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/saved.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/history.dart';
|
||||
|
||||
class PinepodsMoreMenu extends StatelessWidget {
|
||||
// Constructor with optional key parameter
|
||||
const PinepodsMoreMenu({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'More Options',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'Downloads',
|
||||
Icons.download_outlined,
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: false,
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Downloads')),
|
||||
body: const CustomScrollView(
|
||||
slivers: [Downloads()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'Saved Episodes',
|
||||
Icons.bookmark_outline,
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: false,
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('Saved Episodes')),
|
||||
body: const CustomScrollView(
|
||||
slivers: [PinepodsSaved()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'History',
|
||||
Icons.history,
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: false,
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('History')),
|
||||
body: const CustomScrollView(
|
||||
slivers: [PinepodsHistory()],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMenuItem(
|
||||
context,
|
||||
'Settings',
|
||||
Icons.settings_outlined,
|
||||
() => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
settings: const RouteSettings(name: 'settings'),
|
||||
builder: (context) => const Settings(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuItem(
|
||||
BuildContext context,
|
||||
String title,
|
||||
IconData icon,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12.0),
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal file
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal file
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal file
@@ -0,0 +1,546 @@
|
||||
// lib/ui/pinepods/playlists.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/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/create_playlist.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PinepodsPlaylists extends StatefulWidget {
|
||||
const PinepodsPlaylists({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsPlaylists> createState() => _PinepodsPlaylistsState();
|
||||
}
|
||||
|
||||
class _PinepodsPlaylistsState extends State<PinepodsPlaylists> {
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
List<PlaylistData>? _playlists;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
Set<int> _selectedPlaylists = {};
|
||||
bool _isSelectionMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
/// Calculate responsive cross axis count for playlist grid
|
||||
int _getPlaylistCrossAxisCount(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
|
||||
if (screenWidth > 800) return 3; // Wide tablets like iPad
|
||||
if (screenWidth > 500) return 2; // Standard phones and small tablets
|
||||
return 1; // Very small phones (< 500px)
|
||||
}
|
||||
|
||||
/// Calculate responsive aspect ratio for playlist cards
|
||||
double _getPlaylistAspectRatio(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth <= 500) {
|
||||
// Single column on small screens - generous height for multi-line descriptions + padding
|
||||
return 1.8; // Allows space for title + 2-3 lines of description + proper padding
|
||||
}
|
||||
return 1.1; // Standard aspect ratio for multi-column layouts
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() 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!,
|
||||
);
|
||||
|
||||
final playlists = await _pinepodsService.getUserPlaylists(settings.pinepodsUserId!);
|
||||
|
||||
setState(() {
|
||||
_playlists = playlists;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = !_isSelectionMode;
|
||||
if (!_isSelectionMode) {
|
||||
_selectedPlaylists.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _togglePlaylistSelection(int playlistId) {
|
||||
setState(() {
|
||||
if (_selectedPlaylists.contains(playlistId)) {
|
||||
_selectedPlaylists.remove(playlistId);
|
||||
} else {
|
||||
_selectedPlaylists.add(playlistId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedPlaylists() async {
|
||||
if (_selectedPlaylists.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Playlists'),
|
||||
content: Text('Are you sure you want to delete ${_selectedPlaylists.length} playlist${_selectedPlaylists.length == 1 ? '' : 's'}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
try {
|
||||
for (final playlistId in _selectedPlaylists) {
|
||||
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlistId);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_selectedPlaylists.clear();
|
||||
_isSelectionMode = false;
|
||||
});
|
||||
|
||||
_loadPlaylists(); // Refresh the list
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Playlists deleted successfully')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error deleting playlists: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePlaylist(PlaylistData playlist) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Playlist'),
|
||||
content: Text('Are you sure you want to delete "${playlist.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
try {
|
||||
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlist.playlistId);
|
||||
_loadPlaylists(); // Refresh the list
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Playlist deleted successfully')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error deleting playlist: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openPlaylist(PlaylistData playlist) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PlaylistEpisodesPage(playlist: playlist),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createPlaylist() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CreatePlaylistPage(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_loadPlaylists(); // Refresh the list
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
PlatformProgressIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return SliverServerErrorPage(
|
||||
errorMessage: _errorMessage!.isServerConnectionError
|
||||
? null
|
||||
: _errorMessage,
|
||||
onRetry: _loadPlaylists,
|
||||
title: 'Playlists Unavailable',
|
||||
subtitle: _errorMessage!.isServerConnectionError
|
||||
? 'Unable to connect to the PinePods server'
|
||||
: 'Failed to load your playlists',
|
||||
);
|
||||
}
|
||||
|
||||
if (_playlists == null || _playlists!.isEmpty) {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.playlist_play,
|
||||
size: 75,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No playlists found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Create a smart playlist to get started!',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _createPlaylist,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create Playlist'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Smart Playlists',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _toggleSelectionMode,
|
||||
tooltip: 'Cancel',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _selectedPlaylists.isNotEmpty ? _deleteSelectedPlaylists : null,
|
||||
tooltip: 'Delete selected (${_selectedPlaylists.length})',
|
||||
),
|
||||
] else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
onPressed: _toggleSelectionMode,
|
||||
tooltip: 'Select multiple',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _createPlaylist,
|
||||
tooltip: 'Create playlist',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Info banner for selection mode
|
||||
if (_isSelectionMode)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'System playlists cannot be deleted.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Playlists grid
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _getPlaylistCrossAxisCount(context),
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: _getPlaylistAspectRatio(context),
|
||||
),
|
||||
itemCount: _playlists!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists![index];
|
||||
final isSelected = _selectedPlaylists.contains(playlist.playlistId);
|
||||
final canSelect = _isSelectionMode && !playlist.isSystemPlaylist;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (_isSelectionMode && !playlist.isSystemPlaylist) {
|
||||
_togglePlaylistSelection(playlist.playlistId);
|
||||
} else if (!_isSelectionMode) {
|
||||
_openPlaylist(playlist);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
elevation: isSelected ? 8 : 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
color: isSelected
|
||||
? Theme.of(context).primaryColor.withOpacity(0.1)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getPlaylistIcon(playlist.iconName),
|
||||
size: 32,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const Spacer(),
|
||||
if (playlist.isSystemPlaylist)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondary.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'System',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${playlist.episodeCount ?? 0} episodes',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
),
|
||||
if (playlist.description != null && playlist.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
playlist.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Selection checkbox
|
||||
if (canSelect)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) {
|
||||
_togglePlaylistSelection(playlist.playlistId);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Delete button for non-system playlists (when not in selection mode)
|
||||
if (!_isSelectionMode && !playlist.isSystemPlaylist)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
onPressed: () => _deletePlaylist(playlist),
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
File diff suppressed because it is too large
Load Diff
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal file
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/app_settings.dart';
|
||||
import 'package:pinepods_mobile/entities/podcast.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_grid_tile.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_tile.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/layout_selector.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
/// This class displays the list of podcasts the user is subscribed to on the PinePods server.
|
||||
class PinepodsPodcasts extends StatefulWidget {
|
||||
const PinepodsPodcasts({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PinepodsPodcasts> createState() => _PinepodsPodcastsState();
|
||||
}
|
||||
|
||||
class _PinepodsPodcastsState extends State<PinepodsPodcasts> {
|
||||
List<Podcast>? _podcasts;
|
||||
List<Podcast>? _filteredPodcasts;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPodcasts();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
_filterPodcasts();
|
||||
});
|
||||
}
|
||||
|
||||
void _filterPodcasts() {
|
||||
if (_podcasts == null) {
|
||||
_filteredPodcasts = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredPodcasts = List.from(_podcasts!);
|
||||
} else {
|
||||
_filteredPodcasts = _podcasts!.where((podcast) {
|
||||
return podcast.title.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPodcasts() 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 {
|
||||
// Initialize the service with the stored credentials
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final podcasts = await _pinepodsService.getUserPodcasts(settings.pinepodsUserId!);
|
||||
|
||||
setState(() {
|
||||
_podcasts = podcasts;
|
||||
_filterPodcasts(); // Initialize filtered list
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter podcasts...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Material(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Theme.of(context).secondaryHeaderColor,
|
||||
barrierLabel: L.of(context)!.scrim_layout_selector,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(16.0),
|
||||
topRight: Radius.circular(16.0),
|
||||
),
|
||||
),
|
||||
builder: (context) => const LayoutSelectorWidget(),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.dashboard,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPodcastList(AppSettings settings) {
|
||||
final podcasts = _filteredPodcasts ?? [];
|
||||
|
||||
if (podcasts.isEmpty && _searchQuery.isNotEmpty) {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 75,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No podcasts found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No podcasts match "$_searchQuery"',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
var mode = settings.layout;
|
||||
var size = mode == 1 ? 100.0 : 160.0;
|
||||
|
||||
if (mode == 0) {
|
||||
// List view
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return PinepodsPodcastTile(podcast: podcasts[index]);
|
||||
},
|
||||
childCount: podcasts.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Grid view
|
||||
return SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: size,
|
||||
mainAxisSpacing: 10.0,
|
||||
crossAxisSpacing: 10.0,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return PinepodsPodcastGridTile(podcast: podcasts[index]);
|
||||
},
|
||||
childCount: podcasts.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context);
|
||||
|
||||
if (_isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
PlatformProgressIndicator(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return SliverServerErrorPage(
|
||||
errorMessage: _errorMessage!.isServerConnectionError
|
||||
? null
|
||||
: _errorMessage,
|
||||
onRetry: _loadPodcasts,
|
||||
title: 'Podcasts Unavailable',
|
||||
subtitle: _errorMessage!.isServerConnectionError
|
||||
? 'Unable to connect to the PinePods server'
|
||||
: 'Failed to load your podcasts',
|
||||
);
|
||||
}
|
||||
|
||||
if (_podcasts == null || _podcasts!.isEmpty) {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.podcasts,
|
||||
size: 75,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No podcasts found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'You haven\'t subscribed to any podcasts yet. Search for podcasts to get started!',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return StreamBuilder<AppSettings>(
|
||||
stream: settingsBloc.settings,
|
||||
builder: (context, settingsSnapshot) {
|
||||
if (settingsSnapshot.hasData) {
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildPodcastList(settingsSnapshot.data!),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal file
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal file
@@ -0,0 +1,805 @@
|
||||
// lib/ui/pinepods/queue.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/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
|
||||
import 'package:pinepods_mobile/services/global_services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PinepodsQueue extends StatefulWidget {
|
||||
const PinepodsQueue({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsQueue> createState() => _PinepodsQueueState();
|
||||
}
|
||||
|
||||
class _PinepodsQueueState extends State<PinepodsQueue> {
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
List<PinepodsEpisode> _episodes = [];
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
// Use global audio service instead of creating local instance
|
||||
int? _contextMenuEpisodeIndex;
|
||||
|
||||
// Auto-scroll related variables
|
||||
bool _isDragging = false;
|
||||
bool _isAutoScrolling = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadQueuedEpisodes();
|
||||
}
|
||||
|
||||
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
|
||||
|
||||
Future<void> _loadQueuedEpisodes() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
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 login first.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
final userId = settings.pinepodsUserId!;
|
||||
|
||||
final episodes = await _pinepodsService.getQueuedEpisodes(userId);
|
||||
|
||||
// Enrich episodes with best available positions (local vs server)
|
||||
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
|
||||
context,
|
||||
_pinepodsService,
|
||||
episodes,
|
||||
userId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_episodes = enrichedEpisodes;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// After loading episodes, check their local download status
|
||||
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load queued episodes: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
// Clear local download status cache on refresh
|
||||
LocalDownloadUtils.clearCache();
|
||||
await _loadQueuedEpisodes();
|
||||
}
|
||||
|
||||
Future<void> _reorderEpisodes(int oldIndex, int newIndex) async {
|
||||
// Adjust indices if moving down the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
// Update local state immediately for smooth UI
|
||||
setState(() {
|
||||
final episode = _episodes.removeAt(oldIndex);
|
||||
_episodes.insert(newIndex, episode);
|
||||
});
|
||||
|
||||
// Get episode IDs in new order
|
||||
final episodeIds = _episodes.map((e) => e.episodeId).toList();
|
||||
|
||||
// Call API to update order on server
|
||||
try {
|
||||
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);
|
||||
// Reload to restore original order if API call fails
|
||||
await _loadQueuedEpisodes();
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update queue order', Colors.red);
|
||||
// Reload to restore original order if API call fails
|
||||
await _loadQueuedEpisodes();
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating queue order: $e', Colors.red);
|
||||
// Reload to restore original order if API call fails
|
||||
await _loadQueuedEpisodes();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playEpisode(PinepodsEpisode episode) async {
|
||||
|
||||
if (_audioService == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio service not available'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Starting ${episode.episodeTitle}...'),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
await _audioService!.playPinepodsEpisode(
|
||||
pinepodsEpisode: episode,
|
||||
resume: episode.isStarted,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Now playing: ${episode.episodeTitle}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to play episode: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showContextMenu(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.3),
|
||||
builder: (context) => EpisodeContextMenu(
|
||||
episode: episode,
|
||||
isDownloadedLocally: isDownloadedLocally,
|
||||
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);
|
||||
},
|
||||
onDeleteLocalDownload: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteLocalDownload(episodeIndex);
|
||||
},
|
||||
onQueue: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleQueueEpisode(episodeIndex);
|
||||
},
|
||||
onMarkComplete: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleMarkComplete(episodeIndex);
|
||||
},
|
||||
onDismiss: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _localDownloadEpisode(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
|
||||
|
||||
if (success) {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLocalDownload(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
LocalDownloadUtils.showSnackBar(
|
||||
context,
|
||||
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
|
||||
Colors.orange
|
||||
);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideContextMenu() {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.saveEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
|
||||
});
|
||||
_showSnackBar('Episode saved!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to save episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error saving episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _removeSavedEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.removeSavedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
|
||||
});
|
||||
_showSnackBar('Removed from saved episodes', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to remove saved episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error removing saved episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _downloadEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.downloadEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
|
||||
});
|
||||
_showSnackBar('Episode download queued!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to queue download', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error downloading episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _deleteEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.deleteEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
|
||||
});
|
||||
_showSnackBar('Episode deleted from server', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to delete episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error deleting episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleQueueEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.queued) {
|
||||
success = await _pinepodsService.removeQueuedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
// REMOVE the episode from the list since it's no longer queued
|
||||
setState(() {
|
||||
_episodes.removeAt(episodeIndex);
|
||||
});
|
||||
_showSnackBar('Removed from queue', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
// This shouldn't happen since all episodes here are already queued
|
||||
// But just in case, we'll handle it
|
||||
success = await _pinepodsService.queueEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
|
||||
});
|
||||
_showSnackBar('Added to queue!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update queue', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating queue: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleMarkComplete(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.completed) {
|
||||
success = await _pinepodsService.markEpisodeUncompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
|
||||
});
|
||||
_showSnackBar('Marked as incomplete', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.markEpisodeCompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
|
||||
});
|
||||
_showSnackBar('Marked as complete!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update completion status', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating completion: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
PinepodsEpisode _updateEpisodeProperty(
|
||||
PinepodsEpisode episode, {
|
||||
bool? saved,
|
||||
bool? downloaded,
|
||||
bool? queued,
|
||||
bool? completed,
|
||||
}) {
|
||||
return PinepodsEpisode(
|
||||
podcastName: episode.podcastName,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
episodePubDate: episode.episodePubDate,
|
||||
episodeDescription: episode.episodeDescription,
|
||||
episodeArtwork: episode.episodeArtwork,
|
||||
episodeUrl: episode.episodeUrl,
|
||||
episodeDuration: episode.episodeDuration,
|
||||
listenDuration: episode.listenDuration,
|
||||
episodeId: episode.episodeId,
|
||||
completed: completed ?? episode.completed,
|
||||
saved: saved ?? episode.saved,
|
||||
queued: queued ?? episode.queued,
|
||||
downloaded: downloaded ?? episode.downloaded,
|
||||
isYoutube: episode.isYoutube,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startAutoScroll(bool scrollUp) async {
|
||||
if (_isAutoScrolling) return;
|
||||
_isAutoScrolling = true;
|
||||
|
||||
while (_isDragging && _isAutoScrolling) {
|
||||
// Find the nearest ScrollView controller
|
||||
final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller;
|
||||
|
||||
if (scrollController != null && scrollController.hasClients) {
|
||||
final currentOffset = scrollController.offset;
|
||||
final maxScrollExtent = scrollController.position.maxScrollExtent;
|
||||
|
||||
if (scrollUp && currentOffset > 0) {
|
||||
// Scroll up
|
||||
final newOffset = (currentOffset - 8.0).clamp(0.0, maxScrollExtent);
|
||||
scrollController.jumpTo(newOffset);
|
||||
} else if (!scrollUp && currentOffset < maxScrollExtent) {
|
||||
// Scroll down
|
||||
final newOffset = (currentOffset + 8.0).clamp(0.0, maxScrollExtent);
|
||||
scrollController.jumpTo(newOffset);
|
||||
} else {
|
||||
break; // Reached the edge
|
||||
}
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 16));
|
||||
}
|
||||
|
||||
_isAutoScrolling = false;
|
||||
}
|
||||
|
||||
void _stopAutoScroll() {
|
||||
_isAutoScrolling = false;
|
||||
}
|
||||
|
||||
void _checkAutoScroll(double globalY) {
|
||||
if (!_isDragging) return;
|
||||
|
||||
final MediaQueryData mediaQuery = MediaQuery.of(context);
|
||||
final double screenHeight = mediaQuery.size.height;
|
||||
final double topPadding = mediaQuery.padding.top;
|
||||
final double bottomPadding = mediaQuery.padding.bottom;
|
||||
|
||||
const double autoScrollThreshold = 80.0;
|
||||
|
||||
if (globalY < topPadding + autoScrollThreshold) {
|
||||
// Near top, scroll up
|
||||
if (!_isAutoScrolling) {
|
||||
_startAutoScroll(true);
|
||||
}
|
||||
} else if (globalY > screenHeight - bottomPadding - autoScrollThreshold) {
|
||||
// Near bottom, scroll down
|
||||
if (!_isAutoScrolling) {
|
||||
_startAutoScroll(false);
|
||||
}
|
||||
} else {
|
||||
// In the middle, stop auto-scrolling
|
||||
_stopAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopAutoScroll();
|
||||
// Don't dispose global audio service - it should persist across pages
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading queue...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _refresh,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_episodes.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.queue_music_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No queued episodes',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Episodes you queue will appear here',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildEpisodesList();
|
||||
}
|
||||
|
||||
Widget _buildEpisodesList() {
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
// Header
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Drag to reorder',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Auto-scrolling reorderable episodes list wrapped with pointer detection
|
||||
SliverToBoxAdapter(
|
||||
child: Listener(
|
||||
onPointerMove: (details) {
|
||||
if (_isDragging) {
|
||||
_checkAutoScroll(details.position.dy);
|
||||
}
|
||||
},
|
||||
child: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderStart: (index) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
});
|
||||
},
|
||||
onReorderEnd: (index) {
|
||||
setState(() {
|
||||
_isDragging = false;
|
||||
});
|
||||
_stopAutoScroll();
|
||||
},
|
||||
onReorder: _reorderEpisodes,
|
||||
itemCount: _episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = _episodes[index];
|
||||
return Container(
|
||||
key: ValueKey(episode.episodeId),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
child: DraggableQueueEpisodeCard(
|
||||
episode: episode,
|
||||
index: index,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsEpisodeDetails(
|
||||
initialEpisode: episode,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _showContextMenu(index),
|
||||
onPlayPressed: () => _playEpisode(episode),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal file
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal file
@@ -0,0 +1,730 @@
|
||||
// lib/ui/pinepods/saved.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/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:pinepods_mobile/services/global_services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class PinepodsSaved extends StatefulWidget {
|
||||
const PinepodsSaved({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsSaved> createState() => _PinepodsSavedState();
|
||||
}
|
||||
|
||||
class _PinepodsSavedState extends State<PinepodsSaved> {
|
||||
bool _isLoading = false;
|
||||
String _errorMessage = '';
|
||||
List<PinepodsEpisode> _episodes = [];
|
||||
List<PinepodsEpisode> _filteredEpisodes = [];
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
// Use global audio service instead of creating local instance
|
||||
int? _contextMenuEpisodeIndex;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedEpisodes();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
// Don't dispose global audio service - it should persist across pages
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
setState(() {
|
||||
_searchQuery = _searchController.text;
|
||||
_filterEpisodes();
|
||||
});
|
||||
}
|
||||
|
||||
void _filterEpisodes() {
|
||||
if (_searchQuery.isEmpty) {
|
||||
_filteredEpisodes = List.from(_episodes);
|
||||
} else {
|
||||
_filteredEpisodes = _episodes.where((episode) {
|
||||
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
|
||||
|
||||
Future<void> _loadSavedEpisodes() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = '';
|
||||
});
|
||||
|
||||
try {
|
||||
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 login first.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
final userId = settings.pinepodsUserId!;
|
||||
|
||||
final episodes = await _pinepodsService.getSavedEpisodes(userId);
|
||||
|
||||
// Enrich episodes with best available positions (local vs server)
|
||||
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
|
||||
context,
|
||||
_pinepodsService,
|
||||
episodes,
|
||||
userId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_episodes = enrichedEpisodes;
|
||||
_filterEpisodes(); // Initialize filtered list
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// After loading episodes, check their local download status
|
||||
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load saved episodes: ${e.toString()}';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
// Clear local download status cache on refresh
|
||||
LocalDownloadUtils.clearCache();
|
||||
await _loadSavedEpisodes();
|
||||
}
|
||||
|
||||
Future<void> _playEpisode(PinepodsEpisode episode) async {
|
||||
|
||||
if (_audioService == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Audio service not available'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Starting ${episode.episodeTitle}...'),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
await _audioService!.playPinepodsEpisode(
|
||||
pinepodsEpisode: episode,
|
||||
resume: episode.isStarted,
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Now playing: ${episode.episodeTitle}'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to play episode: ${e.toString()}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showContextMenu(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierColor: Colors.black.withOpacity(0.3),
|
||||
builder: (context) => EpisodeContextMenu(
|
||||
episode: episode,
|
||||
isDownloadedLocally: isDownloadedLocally,
|
||||
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);
|
||||
},
|
||||
onDeleteLocalDownload: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteLocalDownload(episodeIndex);
|
||||
},
|
||||
onQueue: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleQueueEpisode(episodeIndex);
|
||||
},
|
||||
onMarkComplete: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleMarkComplete(episodeIndex);
|
||||
},
|
||||
onDismiss: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _localDownloadEpisode(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
|
||||
|
||||
if (success) {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteLocalDownload(int episodeIndex) async {
|
||||
final episode = _episodes[episodeIndex];
|
||||
|
||||
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
|
||||
|
||||
if (deletedCount > 0) {
|
||||
LocalDownloadUtils.showSnackBar(
|
||||
context,
|
||||
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
|
||||
Colors.orange
|
||||
);
|
||||
} else {
|
||||
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
void _hideContextMenu() {
|
||||
setState(() {
|
||||
_contextMenuEpisodeIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEpisode(int episodeIndex) async {
|
||||
// This shouldn't be called since all episodes here are already saved
|
||||
// But just in case, we'll handle it
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.saveEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
|
||||
});
|
||||
_showSnackBar('Episode saved!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to save episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error saving episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _removeSavedEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.removeSavedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// REMOVE the episode from the list since it's no longer saved
|
||||
setState(() {
|
||||
_episodes.removeAt(episodeIndex);
|
||||
_filterEpisodes(); // Update filtered list after removal
|
||||
});
|
||||
_showSnackBar('Removed from saved episodes', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to remove saved episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error removing saved episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _downloadEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.downloadEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
|
||||
});
|
||||
_showSnackBar('Episode download queued!', Colors.green);
|
||||
} else {
|
||||
_showSnackBar('Failed to queue download', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error downloading episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _deleteEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.deleteEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
|
||||
});
|
||||
_showSnackBar('Episode deleted from server', Colors.orange);
|
||||
} else {
|
||||
_showSnackBar('Failed to delete episode', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error deleting episode: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleQueueEpisode(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.queued) {
|
||||
success = await _pinepodsService.removeQueuedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
|
||||
});
|
||||
_showSnackBar('Removed from queue', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.queueEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
|
||||
});
|
||||
_showSnackBar('Added to queue!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update queue', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating queue: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _toggleMarkComplete(int episodeIndex) async {
|
||||
final episode = _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;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (episode.completed) {
|
||||
success = await _pinepodsService.markEpisodeUncompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
|
||||
});
|
||||
_showSnackBar('Marked as incomplete', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.markEpisodeCompleted(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
|
||||
});
|
||||
_showSnackBar('Marked as complete!', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to update completion status', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error updating completion: $e', Colors.red);
|
||||
}
|
||||
|
||||
_hideContextMenu();
|
||||
}
|
||||
|
||||
PinepodsEpisode _updateEpisodeProperty(
|
||||
PinepodsEpisode episode, {
|
||||
bool? saved,
|
||||
bool? downloaded,
|
||||
bool? queued,
|
||||
bool? completed,
|
||||
}) {
|
||||
return PinepodsEpisode(
|
||||
podcastName: episode.podcastName,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
episodePubDate: episode.episodePubDate,
|
||||
episodeDescription: episode.episodeDescription,
|
||||
episodeArtwork: episode.episodeArtwork,
|
||||
episodeUrl: episode.episodeUrl,
|
||||
episodeDuration: episode.episodeDuration,
|
||||
listenDuration: episode.listenDuration,
|
||||
episodeId: episode.episodeId,
|
||||
completed: completed ?? episode.completed,
|
||||
saved: saved ?? episode.saved,
|
||||
queued: queued ?? episode.queued,
|
||||
downloaded: downloaded ?? episode.downloaded,
|
||||
isYoutube: episode.isYoutube,
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Loading saved episodes...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return SliverServerErrorPage(
|
||||
errorMessage: _errorMessage.isServerConnectionError
|
||||
? null
|
||||
: _errorMessage,
|
||||
onRetry: _refresh,
|
||||
title: 'Saved Episodes Unavailable',
|
||||
subtitle: _errorMessage.isServerConnectionError
|
||||
? 'Unable to connect to the PinePods server'
|
||||
: 'Failed to load saved episodes',
|
||||
);
|
||||
}
|
||||
|
||||
if (_episodes.isEmpty) {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_outline,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'No saved episodes',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Episodes you save will appear here',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
_buildEpisodesList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Filter episodes...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).cardColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodesList() {
|
||||
// Check if search returned no results
|
||||
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No episodes found',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'No episodes match "$_searchQuery"',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == 0) {
|
||||
// Header
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Saved Episodes'
|
||||
: 'Search Results (${_filteredEpisodes.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Episodes (index - 1 because of header)
|
||||
final episodeIndex = index - 1;
|
||||
final episode = _filteredEpisodes[episodeIndex];
|
||||
// Find the original index for context menu operations
|
||||
final originalIndex = _episodes.indexOf(episode);
|
||||
return PinepodsEpisodeCard(
|
||||
episode: episode,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsEpisodeDetails(
|
||||
initialEpisode: episode,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _showContextMenu(originalIndex),
|
||||
onPlayPressed: () => _playEpisode(episode),
|
||||
);
|
||||
},
|
||||
childCount: _filteredEpisodes.length + 1, // +1 for header
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal file
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal file
@@ -0,0 +1,674 @@
|
||||
// lib/ui/pinepods/search.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_search.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/services/search_history_service.dart';
|
||||
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
|
||||
import 'package:pinepods_mobile/services/error_handling_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PinepodsSearch extends StatefulWidget {
|
||||
final String? searchTerm;
|
||||
|
||||
const PinepodsSearch({
|
||||
super.key,
|
||||
this.searchTerm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PinepodsSearch> createState() => _PinepodsSearchState();
|
||||
}
|
||||
|
||||
class _PinepodsSearchState extends State<PinepodsSearch> {
|
||||
late TextEditingController _searchController;
|
||||
late FocusNode _searchFocusNode;
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
final SearchHistoryService _searchHistoryService = SearchHistoryService();
|
||||
|
||||
SearchProvider _selectedProvider = SearchProvider.podcastIndex;
|
||||
bool _isLoading = false;
|
||||
bool _showHistory = false;
|
||||
String? _errorMessage;
|
||||
List<UnifiedPinepodsPodcast> _searchResults = [];
|
||||
List<String> _searchHistory = [];
|
||||
Set<String> _addedPodcastUrls = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_searchFocusNode = FocusNode();
|
||||
_searchController = TextEditingController();
|
||||
|
||||
if (widget.searchTerm != null) {
|
||||
_searchController.text = widget.searchTerm!;
|
||||
_performSearch(widget.searchTerm!);
|
||||
} else {
|
||||
_loadSearchHistory();
|
||||
}
|
||||
|
||||
_initializeCredentials();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
void _initializeCredentials() {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchFocusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSearchHistory() async {
|
||||
final history = await _searchHistoryService.getPodcastSearchHistory();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_searchHistory = history;
|
||||
_showHistory = _searchController.text.isEmpty && history.isNotEmpty;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.trim();
|
||||
setState(() {
|
||||
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
void _selectHistoryItem(String searchTerm) {
|
||||
_searchController.text = searchTerm;
|
||||
_performSearch(searchTerm);
|
||||
}
|
||||
|
||||
Future<void> _removeHistoryItem(String searchTerm) async {
|
||||
await _searchHistoryService.removePodcastSearchTerm(searchTerm);
|
||||
await _loadSearchHistory();
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_errorMessage = null;
|
||||
_showHistory = _searchHistory.isNotEmpty;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_showHistory = false;
|
||||
});
|
||||
|
||||
// Save search term to history
|
||||
await _searchHistoryService.addPodcastSearchTerm(query);
|
||||
await _loadSearchHistory();
|
||||
|
||||
try {
|
||||
final result = await _pinepodsService.searchPodcasts(query, _selectedProvider);
|
||||
final podcasts = result.getUnifiedPodcasts();
|
||||
|
||||
setState(() {
|
||||
_searchResults = podcasts;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Check which podcasts are already added
|
||||
await _checkAddedPodcasts();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Search failed: $e';
|
||||
_isLoading = false;
|
||||
_searchResults = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAddedPodcasts() async {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) return;
|
||||
|
||||
for (final podcast in _searchResults) {
|
||||
try {
|
||||
final exists = await _pinepodsService.checkPodcastExists(
|
||||
podcast.title,
|
||||
podcast.url,
|
||||
userId,
|
||||
);
|
||||
if (exists) {
|
||||
setState(() {
|
||||
_addedPodcastUrls.add(podcast.url);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore individual check failures
|
||||
print('Failed to check podcast ${podcast.title}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePodcast(UnifiedPinepodsPodcast podcast) async {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
_showSnackBar('Not logged in to PinePods server', Colors.red);
|
||||
return;
|
||||
}
|
||||
|
||||
final isAdded = _addedPodcastUrls.contains(podcast.url);
|
||||
|
||||
try {
|
||||
bool success;
|
||||
if (isAdded) {
|
||||
success = await _pinepodsService.removePodcast(
|
||||
podcast.title,
|
||||
podcast.url,
|
||||
userId,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_addedPodcastUrls.remove(podcast.url);
|
||||
});
|
||||
_showSnackBar('Podcast removed', Colors.orange);
|
||||
}
|
||||
} else {
|
||||
success = await _pinepodsService.addPodcast(podcast, userId);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_addedPodcastUrls.add(podcast.url);
|
||||
});
|
||||
_showSnackBar('Podcast added', Colors.green);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
_showSnackBar('Failed to ${isAdded ? 'remove' : 'add'} podcast', Colors.red);
|
||||
}
|
||||
} catch (e) {
|
||||
_showSnackBar('Error: $e', Colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String message, Color backgroundColor) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchHistorySliver() {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Recent Podcast Searches',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_searchHistory.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _searchHistoryService.clearPodcastSearchHistory();
|
||||
await _loadSearchHistory();
|
||||
},
|
||||
child: Text(
|
||||
'Clear All',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_searchHistory.isEmpty)
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 50),
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search for Podcasts',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter a search term above to find new podcasts to subscribe to',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
..._searchHistory.take(10).map((searchTerm) => Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
Icons.history,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: 20,
|
||||
),
|
||||
title: Text(
|
||||
searchTerm,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
size: 18,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: () => _removeHistoryItem(searchTerm),
|
||||
),
|
||||
onTap: () => _selectHistoryItem(searchTerm),
|
||||
),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPodcastCard(UnifiedPinepodsPodcast podcast) {
|
||||
final isAdded = _addedPodcastUrls.contains(podcast.url);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PinepodsPodcastDetails(
|
||||
podcast: podcast,
|
||||
isFollowing: isAdded,
|
||||
onFollowChanged: (following) {
|
||||
setState(() {
|
||||
if (following) {
|
||||
_addedPodcastUrls.add(podcast.url);
|
||||
} else {
|
||||
_addedPodcastUrls.remove(podcast.url);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// Podcast image and info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Podcast artwork
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: podcast.artwork.isNotEmpty
|
||||
? Image.network(
|
||||
podcast.artwork,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.music_note,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Podcast info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
podcast.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (podcast.author.isNotEmpty)
|
||||
Text(
|
||||
'By ${podcast.author}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
podcast.description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.mic,
|
||||
size: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${podcast.episodeCount} episode${podcast.episodeCount != 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (podcast.explicit)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'E',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Follow/Unfollow button
|
||||
IconButton(
|
||||
onPressed: () => _togglePodcast(podcast),
|
||||
icon: Icon(
|
||||
isAdded ? Icons.remove_circle : Icons.add_circle,
|
||||
color: isAdded ? Colors.red : Colors.green,
|
||||
),
|
||||
tooltip: isAdded ? 'Remove podcast' : 'Add podcast',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
leading: IconButton(
|
||||
tooltip: 'Back',
|
||||
icon: Platform.isAndroid
|
||||
? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor)
|
||||
: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
autofocus: widget.searchTerm != null ? false : true,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.search,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
|
||||
});
|
||||
},
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search for podcasts',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryIconTheme.color,
|
||||
fontSize: 18.0,
|
||||
decorationColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
),
|
||||
onSubmitted: _performSearch,
|
||||
),
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
tooltip: 'Clear search',
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_errorMessage = null;
|
||||
_showHistory = _searchHistory.isNotEmpty;
|
||||
});
|
||||
FocusScope.of(context).requestFocus(_searchFocusNode);
|
||||
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
|
||||
},
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Search Provider: ',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: DropdownButton<SearchProvider>(
|
||||
value: _selectedProvider,
|
||||
isExpanded: true,
|
||||
items: SearchProvider.values.map((provider) {
|
||||
return DropdownMenuItem(
|
||||
value: provider,
|
||||
child: Text(provider.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (provider) {
|
||||
if (provider != null) {
|
||||
setState(() {
|
||||
_selectedProvider = provider;
|
||||
});
|
||||
// Re-search with new provider if there's a current search
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_performSearch(_searchController.text);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search results or history
|
||||
if (_showHistory)
|
||||
_buildSearchHistorySliver()
|
||||
else if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: PlatformProgressIndicator()),
|
||||
)
|
||||
else if (_errorMessage != null)
|
||||
SliverServerErrorPage(
|
||||
errorMessage: _errorMessage!.isServerConnectionError
|
||||
? null
|
||||
: _errorMessage,
|
||||
onRetry: () => _performSearch(_searchController.text),
|
||||
title: 'Search Unavailable',
|
||||
subtitle: _errorMessage!.isServerConnectionError
|
||||
? 'Unable to connect to the PinePods server'
|
||||
: 'Failed to search for podcasts',
|
||||
)
|
||||
else if (_searchResults.isEmpty && _searchController.text.isNotEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No podcasts found',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try searching with different keywords or switch search provider',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_searchResults.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Search for podcasts',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter a search term to find podcasts',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return _buildPodcastCard(_searchResults[index]);
|
||||
},
|
||||
childCount: _searchResults.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal file
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal file
@@ -0,0 +1,503 @@
|
||||
// lib/ui/pinepods/user_stats.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/user_stats.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/services/logging/app_logger.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/core/environment.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class PinepodsUserStats extends StatefulWidget {
|
||||
const PinepodsUserStats({super.key});
|
||||
|
||||
@override
|
||||
State<PinepodsUserStats> createState() => _PinepodsUserStatsState();
|
||||
}
|
||||
|
||||
class _PinepodsUserStatsState extends State<PinepodsUserStats> {
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
UserStats? _userStats;
|
||||
String? _pinepodsVersion;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeCredentials();
|
||||
_loadUserStats();
|
||||
}
|
||||
|
||||
void _initializeCredentials() {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate responsive cross axis count for stats grid
|
||||
int _getStatsCrossAxisCount(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
|
||||
if (screenWidth > 800) return 3; // Wide tablets like iPad
|
||||
if (screenWidth > 500) return 2; // Standard phones and small tablets
|
||||
return 1; // Very small phones (< 500px)
|
||||
}
|
||||
|
||||
/// Calculate responsive aspect ratio for stats cards
|
||||
double _getStatsAspectRatio(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth <= 500) {
|
||||
// Single column on small screens - generous height for content + proper padding
|
||||
return 2.2; // Allows space for icon + title + value + padding, handles text wrapping
|
||||
}
|
||||
return 1.0; // Square aspect ratio for multi-column layouts
|
||||
}
|
||||
|
||||
Future<void> _loadUserStats() async {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Not logged in';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final futures = await Future.wait([
|
||||
_pinepodsService.getUserStats(userId),
|
||||
_pinepodsService.getPinepodsVersion(),
|
||||
]);
|
||||
|
||||
setState(() {
|
||||
_userStats = futures[0] as UserStats;
|
||||
_pinepodsVersion = futures[1] as String;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load stats: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
final logger = AppLogger();
|
||||
logger.info('UserStats', 'Attempting to launch URL: $url');
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
// Try to launch directly first (works better on Android)
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
|
||||
if (!launched) {
|
||||
logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched');
|
||||
// If direct launch fails, check if URL can be launched
|
||||
final canLaunch = await canLaunchUrl(uri);
|
||||
if (!canLaunch) {
|
||||
throw Exception('No app available to handle this URL');
|
||||
}
|
||||
} else {
|
||||
logger.info('UserStats', 'Successfully launched URL: $url');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('UserStats', 'Failed to launch URL: $url', e.toString());
|
||||
// Show error if URL can't be launched
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Could not open link: $url'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: iconColor ?? Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build sync status card that fits in the grid with consistent styling
|
||||
Widget _buildSyncStatCard() {
|
||||
if (_userStats == null) return const SizedBox.shrink();
|
||||
|
||||
final stats = _userStats!;
|
||||
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
|
||||
|
||||
return _buildStatCard(
|
||||
'Sync Status',
|
||||
stats.syncStatusDescription,
|
||||
icon: isNotSyncing ? Icons.sync_disabled : Icons.sync,
|
||||
iconColor: isNotSyncing ? Colors.grey : null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSyncStatusCard() {
|
||||
if (_userStats == null) return const SizedBox.shrink();
|
||||
|
||||
final stats = _userStats!;
|
||||
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
isNotSyncing ? Icons.sync_disabled : Icons.sync,
|
||||
size: 32,
|
||||
color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Podcast Sync Status',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
stats.syncStatusDescription,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
stats.gpodderUrl,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// PinePods Logo
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/pinepods-logo.png'),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
'App Version: v${Environment.projectVersion}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Server Version: ${_pinepodsVersion ?? "Unknown"}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text(
|
||||
'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text(
|
||||
'Copyright © 2025 Gooseberry Development',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
height: 1.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
GestureDetector(
|
||||
onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'),
|
||||
child: Text(
|
||||
'View original project on GitHub',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.underline,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Buttons
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _launchUrl('https://pinepods.online'),
|
||||
icon: const Icon(Icons.description),
|
||||
label: const Text('PinePods Documentation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'),
|
||||
icon: const Icon(Icons.code),
|
||||
label: const Text('PinePods GitHub Repo'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'),
|
||||
icon: const Icon(Icons.coffee),
|
||||
label: const Text('Buy me a Coffee'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
showLicensePage(context: context);
|
||||
},
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: const Text('Open Source Licenses'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('User Statistics'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: PlatformProgressIndicator())
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.red[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadUserStats();
|
||||
},
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Statistics Grid
|
||||
GridView.count(
|
||||
crossAxisCount: _getStatsCrossAxisCount(context),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
childAspectRatio: _getStatsAspectRatio(context),
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
children: [
|
||||
_buildStatCard(
|
||||
'User Created',
|
||||
_userStats?.formattedUserCreated ?? '',
|
||||
icon: Icons.calendar_today,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Podcasts Played',
|
||||
_userStats?.podcastsPlayed.toString() ?? '',
|
||||
icon: Icons.play_circle,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Time Listened',
|
||||
_userStats?.formattedTimeListened ?? '',
|
||||
icon: Icons.access_time,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Podcasts Added',
|
||||
_userStats?.podcastsAdded.toString() ?? '',
|
||||
icon: Icons.library_add,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Episodes Saved',
|
||||
_userStats?.episodesSaved.toString() ?? '',
|
||||
icon: Icons.bookmark,
|
||||
),
|
||||
_buildStatCard(
|
||||
'Episodes Downloaded',
|
||||
_userStats?.episodesDownloaded.toString() ?? '',
|
||||
icon: Icons.download,
|
||||
),
|
||||
// Add sync status as a stat card to maintain consistent layout
|
||||
_buildSyncStatCard(),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Info Card
|
||||
_buildInfoCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user