Date: 2025-01-24 Status: ✅ Implemented
Refactored the podcast download system using command_it 9.4.1 with per-episode download commands, progress tracking, and cancellation support. Improved separation of concerns and aligned with flutter_it patterns.
Core Principle: Commands own their execution state, Services are stateless, Managers orchestrate.
What: Each EpisodeMedia has its own downloadCommand with progress/cancellation
class EpisodeMedia {
late final downloadCommand = _createDownloadCommand();
bool get isDownloaded => downloadCommand.progress.value == 1.0;
Command<void, void> _createDownloadCommand() {
final command = Command.createAsyncNoParamNoResultWithProgress(
(handle) async {
// 1. Add to active downloads
di<PodcastManager>().activeDownloads.add(this);
// 2. Download with progress
await di<DownloadService>().download(
episode: this,
cancelToken: cancelToken,
onProgress: (received, total) {
handle.updateProgress(received / total);
},
);
// 3. Remove from active
di<PodcastManager>().activeDownloads.remove(this);
},
errorFilter: const LocalAndGlobalErrorFilter(),
)..errors.listen((error, subscription) {
di<PodcastManager>().activeDownloads.remove(this);
});
// Initialize progress to 1.0 if already downloaded
if (_wasDownloadedOnCreation) {
command.resetProgress(progress: 1.0);
}
return command;
}
}Benefits:
- ✅ Commands own execution state (no separate registry needed)
- ✅ Built-in progress tracking via
command.progress - ✅ Built-in cancellation via
command.cancel() - ✅ UI watches command state directly
What: PodcastManager tracks currently downloading episodes in a ListNotifier
class PodcastManager {
final activeDownloads = ListNotifier<EpisodeMedia>();
}Why ListNotifier (not MapNotifier):
- No need for O(1) lookup - UI just iterates for display
- Simpler API:
add(),remove() - Episodes are reference-equal (same instances)
Usage:
// UI watches for display
watchValue((PodcastManager m) => m.activeDownloads)
// Command manages lifecycle
di<PodcastManager>().activeDownloads.add(this); // Start
di<PodcastManager>().activeDownloads.remove(this); // End/ErrorChange: Removed all state tracking, made it stateless
What was removed:
- ❌
extends ChangeNotifier - ❌
_episodeToProgressmap - ❌
_episodeToCancelTokenmap - ❌
messageStreamfor error notifications - ❌
startOrCancelDownload()method - ❌
isDownloaded()method (moved to EpisodeMedia)
What remains (stateless operations):
- ✅
download()- Downloads episode with progress callback - ✅
getDownload()- Gets local path for downloaded episode - ✅
deleteDownload()- Deletes downloaded episode - ✅
feedsWithDownloads- Lists feeds with downloads
What: Replaced messageStream with command_it's global error stream
// In home.dart
registerStreamHandler<Stream<CommandError>, CommandError>(
target: Command.globalErrors,
handler: (context, snapshot, cancel) {
if (snapshot.hasData) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text('Download error: ${snapshot.data!.error}')),
);
}
},
);Why:
- Uses command_it v9.1.0+ global error stream
LocalAndGlobalErrorFilterroutes errors to both local handler and global stream- Local handler cleans up (removes from activeDownloads)
- Global handler shows user notification
What: Episodes automatically use local path when downloaded
Factory Constructor Pattern:
factory EpisodeMedia(...) {
// Call getDownload only once
final downloadPath = di<DownloadService>().getDownload(episode.contentUrl);
final wasDownloaded = downloadPath != null;
final effectiveResource = downloadPath ?? resource;
return EpisodeMedia._(
effectiveResource,
wasDownloaded: wasDownloaded,
...
);
}Benefits:
- ✅ Only one
getDownload()call during construction - ✅ Resource automatically set to local path if downloaded
- ✅ Progress initialized to 1.0 for downloaded episodes
- ✅ No need for
copyWithX()in UI code
isDownloaded Getter:
bool get isDownloaded => downloadCommand.progress.value == 1.0;What: Moved episode and description caching from PodcastService to PodcastManager
Why: Aligns with architecture pattern - Services are stateless, Managers handle state and caching
Changes in PodcastService:
- ❌ Removed
_episodeCachemap - ❌ Removed
_podcastDescriptionCachemap - ❌ Removed
getPodcastEpisodesFromCache()method - ❌ Removed
getPodcastDescriptionFromCache()method - ❌ Removed
loadFromCacheparameter - ✅ Changed
findEpisodes()to return record:({List<EpisodeMedia> episodes, String? description})
Changes in PodcastManager:
// Episode cache - ensures same instances across app for command state
final _episodeCache = <String, List<EpisodeMedia>>{};
final _podcastDescriptionCache = <String, String?>{};
// Updated fetchEpisodeMediaCommand to cache both episodes and description
fetchEpisodeMediaCommand = Command.createAsync<Item, List<EpisodeMedia>>(
(podcast) async {
final feedUrl = podcast.feedUrl;
if (feedUrl == null) return [];
// Check cache first - returns same instances so downloadCommands work
if (_episodeCache.containsKey(feedUrl)) {
return _episodeCache[feedUrl]!;
}
// Fetch from service - destructure the record
final result = await _podcastService.findEpisodes(item: podcast);
// Cache both episodes and description
_episodeCache[feedUrl] = result.episodes;
_podcastDescriptionCache[feedUrl] = result.description;
return result.episodes;
},
initialValue: [],
);
// New method to get cached description
String? getPodcastDescription(String? feedUrl) =>
_podcastDescriptionCache[feedUrl];Benefits:
- ✅ Service is now truly stateless (only operations, no caching)
- ✅ Manager owns all caching logic in one place
- ✅ Same episode instances returned from cache (ensures downloadCommands work correctly)
- ✅ Description cached alongside episodes for efficiency
Updated UI:
podcast_page.dartnow callsdi<PodcastManager>().getPodcastDescription(feedUrl)podcast_card.dartdestructures the record:final episodes = result.episodes
What: Moved podcast update checking from PodcastService to PodcastManager and converted to Command pattern
Why:
- checkForUpdates() needs to invalidate/refresh the episode cache (owned by PodcastManager)
- Commands provide built-in execution management (no manual lock needed)
- Consistent with other manager patterns
- UI can watch command state directly
Changes in PodcastService:
- ❌ Removed
checkForUpdates()method - ❌ Removed
_updateLockfield - ❌ Removed
NotificationsServicedependency (no longer needed)
Changes in PodcastManager:
- ✅ Added
checkForUpdatesCommand(Command with record parameters) - ✅ Removed
_updateLockfield (Command handles this withisRunning) - ✅ Added
NotificationsServicedependency - ✅ Fixed bug: Episodes are now fetched when updates detected (was accidentally removed)
- ✅ Restored podcast name in single-update notifications
Command Definition:
late Command<
({
Set<String>? feedUrls,
String updateMessage,
String Function(int) multiUpdateMessage
}),
void> checkForUpdatesCommand;
// Initialized in constructor:
checkForUpdatesCommand = Command.createAsync<...>((params) async {
final newUpdateFeedUrls = <String>{};
for (final feedUrl in (params.feedUrls ?? _podcastLibraryService.podcasts)) {
// Check for updates...
if (storedTimeStamp != null &&
!storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) {
// Fetch episodes to refresh cache using runAsync
await fetchEpisodeMediaCommand.runAsync(Item(feedUrl: feedUrl));
await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated);
newUpdateFeedUrls.add(feedUrl);
}
}
if (newUpdateFeedUrls.isNotEmpty) {
// Include podcast name in single-update notification
final podcastName = newUpdateFeedUrls.length == 1
? _podcastLibraryService.getSubscribedPodcastName(newUpdateFeedUrls.first)
: null;
final msg = newUpdateFeedUrls.length == 1
? '${params.updateMessage}${podcastName != null ? ' $podcastName' : ''}'
: params.multiUpdateMessage(newUpdateFeedUrls.length);
await _notificationsService.notify(message: msg);
}
}, initialValue: null);Usage:
// Run the command:
podcastManager.checkForUpdatesCommand.run((
feedUrls: null, // or specific set
updateMessage: 'New episode available',
multiUpdateMessage: (count) => '$count new episodes available'
));
// UI can watch command state:
watch(podcastManager.checkForUpdatesCommand.isRunning).valueBenefits:
- ✅ No manual lock needed (Command prevents concurrent execution automatically)
- ✅ UI can watch
command.isRunningfor loading state - ✅ Consistent with other manager commands
- ✅ Manager owns cache invalidation logic
- ✅ Service remains stateless
- ✅ Bug fixed: Episodes fetched when updates detected
- ✅ More informative notifications (includes podcast name)
Note: checkForUpdatesCommand is fully implemented but not yet called anywhere in the app (planned future feature with UI integration pending).
Before:
final isDownloaded = watchPropertyValue(
(DownloadService m) => m.isDownloaded(episode.url),
);
if (isDownloaded) {
final download = di<DownloadService>().getDownload(episode.url);
episode.copyWithX(resource: download!);
}After:
final progress = watch(episode.downloadCommand.progress).value;
final isDownloaded = progress == 1.0;
// Episode resource is already correct - just use it directly
di<PlayerManager>().setPlaylist([episode]);┌─────────────────┐
│ EpisodeMedia │ ← Owns downloadCommand with progress/cancellation
│ - downloadCommand
│ - isDownloaded
└─────────────────┘
│
├──→ DownloadService (stateless operations)
│
└──→ PodcastManager.activeDownloads (tracks active)
State Flow:
- User clicks download →
episode.downloadCommand.run() - Command adds to
activeDownloads→ UI shows in list - Command calls
DownloadService.download()with progress callback - Progress updates via
handle.updateProgress()→ UI shows indicator - On success: removes from
activeDownloads, progress stays 1.0 - On error: removes from
activeDownloads, routes to global error stream
What: Converted direct async method calls from UI to command pattern
Why: Consistent architecture, eliminates async/await in UI, enables reactive state management
Changes:
late Command<Item, void> togglePodcastCommand;
togglePodcastCommand = Command.createAsync<Item, void>((item) async {
final feedUrl = item.feedUrl;
if (feedUrl == null) return;
final isSubscribed = _podcastLibraryService.podcasts.contains(feedUrl);
if (isSubscribed) {
await removePodcast(feedUrl: feedUrl);
} else {
await addPodcast(PodcastMetadata(
feedUrl: feedUrl,
name: item.collectionName,
imageUrl: item.bestArtworkUrl,
));
}
}, initialValue: null);Benefits:
- UI passes full
Itemobject, not constructedPodcastMetadata - Command handles state checking and metadata extraction
- Single toggle operation instead of separate add/remove buttons
- No async/await in UI button handlers
Updated: lib/podcasts/view/podcast_favorite_button.dart
onPressed: () => di<PodcastManager>()
.togglePodcastCommand
.run(podcastItem),late Command<String, void> toggleFavoriteStationCommand;
toggleFavoriteStationCommand = Command.createAsync<String, void>(
(stationUuid) async {
final isFavorite =
_radioLibraryService.favoriteStations.contains(stationUuid);
if (isFavorite) {
await removeFavoriteStation(stationUuid);
} else {
await addFavoriteStation(stationUuid);
}
},
initialValue: null,
);Updated: lib/radio/view/radio_browser_station_star_button.dart
onPressed: () => di<RadioManager>()
.toggleFavoriteStationCommand
.run(media.id),late final deleteDownloadCommand = _createDeleteDownloadCommand();
Command<void, void> _createDeleteDownloadCommand() {
return Command.createAsyncNoParamNoResult(() async {
await di<DownloadService>().deleteDownload(media: this);
downloadCommand.resetProgress(progress: 0.0);
}, errorFilter: const LocalAndGlobalErrorFilter());
}Updated: lib/podcasts/view/download_button.dart
if (isDownloaded) {
episode.deleteDownloadCommand.run();
}Pattern: Entity-level command (on EpisodeMedia) alongside downloadCommand
- lib/player/data/episode_media.dart - Added downloadCommand, deleteDownloadCommand, factory constructor
- lib/podcasts/podcast_manager.dart - Added activeDownloads ListNotifier, episode/description caching, checkForUpdatesCommand, togglePodcastCommand
- lib/radio/radio_manager.dart - Added toggleFavoriteStationCommand
- lib/podcasts/podcast_service.dart - Now stateless (removed caching, checkForUpdates, returns record with episodes + description)
- lib/podcasts/download_service.dart - Removed state, kept operations only
- lib/app/home.dart - Added Command.globalErrors handler
- lib/register_dependencies.dart - Updated service registrations (moved NotificationsService from PodcastService to PodcastManager)
- lib/podcasts/view/download_button.dart - Uses deleteDownloadCommand.run(), watches command progress/isRunning
- lib/podcasts/view/podcast_favorite_button.dart - Uses togglePodcastCommand.run() instead of direct add/remove calls
- lib/radio/view/radio_browser_station_star_button.dart - Uses toggleFavoriteStationCommand.run() instead of direct add/remove calls
- lib/podcasts/view/recent_downloads_button.dart - Watch activeDownloads
- lib/podcasts/view/podcast_card.dart - Uses fetchEpisodeMediaCommand with registerHandler for reactive loading dialog
- lib/podcasts/view/podcast_page.dart - Uses PodcastManager.getPodcastDescription
- lib/podcasts/view/podcast_page_episode_list.dart - Use episode.isDownloaded
- messageStream and downloadMessageStreamHandler (replaced by Command.globalErrors)
- DownloadService.isDownloaded() method (use episode.isDownloaded)
- All copyWithX() calls for setting local resource (automatic now)
- PodcastService caching (moved to PodcastManager): _episodeCache, _podcastDescriptionCache, getPodcastEpisodesFromCache(), getPodcastDescriptionFromCache()
- PodcastService.checkForUpdates() and _updateLock (converted to checkForUpdatesCommand)
- PodcastManager._updateLock field (replaced by Command.isRunning)
- NotificationsService dependency from PodcastService (moved to PodcastManager)
- command_it: ^9.4.1 (progress tracking, cancellation, global errors)
- listen_it: (ListNotifier for activeDownloads)
- dio: (CancelToken for download cancellation)
- Simpler State Management: Commands own their state, no separate registry
- Better UI Integration: Watch command properties directly
- Automatic Resource Handling: Episodes "just work" with local paths
- Single Lookup: Only call getDownload() once during construction
- Type Safety: All command properties are ValueListenable
- Cancellation: Built-in cooperative cancellation support
- Error Handling: Global error stream for user notifications
- Progress Tracking: Real-time progress updates via handle.updateProgress()
For Future Features:
- To add download functionality to new media types, add downloadCommand to their class
- Use same pattern: command adds/removes from activeDownloads
- Set errorFilter to LocalAndGlobalErrorFilter for user notifications
- Initialize progress to 1.0 if already downloaded on creation