diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md new file mode 100644 index 0000000..5c54eb8 --- /dev/null +++ b/ARCHITECTURE_CHANGES.md @@ -0,0 +1,521 @@ +# Architecture Changes Overview + +**Date**: 2025-01-24 +**Status**: ✅ Implemented + +--- + +## Summary + +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. + +--- + +## Implementation Details + +### 1. Download Commands (Per-Episode) + +**What**: Each `EpisodeMedia` has its own `downloadCommand` with progress/cancellation + +```dart +class EpisodeMedia { + late final downloadCommand = _createDownloadCommand(); + + bool get isDownloaded => downloadCommand.progress.value == 1.0; + + Command _createDownloadCommand() { + final command = Command.createAsyncNoParamNoResultWithProgress( + (handle) async { + // 1. Add to active downloads + di().activeDownloads.add(this); + + // 2. Download with progress + await di().download( + episode: this, + cancelToken: cancelToken, + onProgress: (received, total) { + handle.updateProgress(received / total); + }, + ); + + // 3. Remove from active + di().activeDownloads.remove(this); + }, + errorFilter: const LocalAndGlobalErrorFilter(), + )..errors.listen((error, subscription) { + di().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 + +--- + +### 2. Active Downloads Tracking + +**What**: `PodcastManager` tracks currently downloading episodes in a `ListNotifier` + +```dart +class PodcastManager { + final activeDownloads = ListNotifier(); +} +``` + +**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**: +```dart +// UI watches for display +watchValue((PodcastManager m) => m.activeDownloads) + +// Command manages lifecycle +di().activeDownloads.add(this); // Start +di().activeDownloads.remove(this); // End/Error +``` + +--- + +### 3. Renamed DownloadManager → DownloadService + +**Change**: Removed all state tracking, made it stateless + +**What was removed**: +- ❌ `extends ChangeNotifier` +- ❌ `_episodeToProgress` map +- ❌ `_episodeToCancelToken` map +- ❌ `messageStream` for 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 + +--- + +### 4. Global Error Handling + +**What**: Replaced messageStream with command_it's global error stream + +```dart +// In home.dart +registerStreamHandler, 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 +- `LocalAndGlobalErrorFilter` routes errors to both local handler and global stream +- Local handler cleans up (removes from activeDownloads) +- Global handler shows user notification + +--- + +### 5. Automatic Downloaded Episode Handling + +**What**: Episodes automatically use local path when downloaded + +**Factory Constructor Pattern**: +```dart +factory EpisodeMedia(...) { + // Call getDownload only once + final downloadPath = di().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**: +```dart +bool get isDownloaded => downloadCommand.progress.value == 1.0; +``` + +--- + +### 6. Episode Cache Migration + +**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 `_episodeCache` map +- ❌ Removed `_podcastDescriptionCache` map +- ❌ Removed `getPodcastEpisodesFromCache()` method +- ❌ Removed `getPodcastDescriptionFromCache()` method +- ❌ Removed `loadFromCache` parameter +- ✅ Changed `findEpisodes()` to return record: `({List episodes, String? description})` + +**Changes in PodcastManager**: +```dart +// Episode cache - ensures same instances across app for command state +final _episodeCache = >{}; +final _podcastDescriptionCache = {}; + +// Updated fetchEpisodeMediaCommand to cache both episodes and description +fetchEpisodeMediaCommand = Command.createAsync>( + (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.dart` now calls `di().getPodcastDescription(feedUrl)` +- `podcast_card.dart` destructures the record: `final episodes = result.episodes` + +--- + +### 7. Moved checkForUpdates() to PodcastManager & Converted to Command + +**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 `_updateLock` field +- ❌ Removed `NotificationsService` dependency (no longer needed) + +**Changes in PodcastManager**: +- ✅ Added `checkForUpdatesCommand` (Command with record parameters) +- ✅ Removed `_updateLock` field (Command handles this with `isRunning`) +- ✅ Added `NotificationsService` dependency +- ✅ Fixed bug: Episodes are now fetched when updates detected (was accidentally removed) +- ✅ Restored podcast name in single-update notifications + +**Command Definition**: +```dart +late Command< + ({ + Set? feedUrls, + String updateMessage, + String Function(int) multiUpdateMessage + }), + void> checkForUpdatesCommand; + +// Initialized in constructor: +checkForUpdatesCommand = Command.createAsync<...>((params) async { + final newUpdateFeedUrls = {}; + + 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**: +```dart +// 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).value +``` + +**Benefits**: +- ✅ No manual lock needed (Command prevents concurrent execution automatically) +- ✅ UI can watch `command.isRunning` for 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). + +--- + +### 8. UI Simplification + +**Before**: +```dart +final isDownloaded = watchPropertyValue( + (DownloadService m) => m.isDownloaded(episode.url), +); +if (isDownloaded) { + final download = di().getDownload(episode.url); + episode.copyWithX(resource: download!); +} +``` + +**After**: +```dart +final progress = watch(episode.downloadCommand.progress).value; +final isDownloaded = progress == 1.0; + +// Episode resource is already correct - just use it directly +di().setPlaylist([episode]); +``` + +--- + +## Architecture Pattern + +``` +┌─────────────────┐ +│ EpisodeMedia │ ← Owns downloadCommand with progress/cancellation +│ - downloadCommand +│ - isDownloaded +└─────────────────┘ + │ + ├──→ DownloadService (stateless operations) + │ + └──→ PodcastManager.activeDownloads (tracks active) +``` + +**State Flow**: +1. User clicks download → `episode.downloadCommand.run()` +2. Command adds to `activeDownloads` → UI shows in list +3. Command calls `DownloadService.download()` with progress callback +4. Progress updates via `handle.updateProgress()` → UI shows indicator +5. On success: removes from `activeDownloads`, progress stays 1.0 +6. On error: removes from `activeDownloads`, routes to global error stream + +--- + +### 8. Toggle Commands for UI Actions + +**What**: Converted direct async method calls from UI to command pattern + +**Why**: Consistent architecture, eliminates async/await in UI, enables reactive state management + +**Changes**: + +#### PodcastManager.togglePodcastCommand +```dart +late Command togglePodcastCommand; + +togglePodcastCommand = Command.createAsync((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 `Item` object, not constructed `PodcastMetadata` +- 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` +```dart +onPressed: () => di() + .togglePodcastCommand + .run(podcastItem), +``` + +#### RadioManager.toggleFavoriteStationCommand +```dart +late Command toggleFavoriteStationCommand; + +toggleFavoriteStationCommand = Command.createAsync( + (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` +```dart +onPressed: () => di() + .toggleFavoriteStationCommand + .run(media.id), +``` + +#### EpisodeMedia.deleteDownloadCommand +```dart +late final deleteDownloadCommand = _createDeleteDownloadCommand(); + +Command _createDeleteDownloadCommand() { + return Command.createAsyncNoParamNoResult(() async { + await di().deleteDownload(media: this); + downloadCommand.resetProgress(progress: 0.0); + }, errorFilter: const LocalAndGlobalErrorFilter()); +} +``` + +**Updated**: `lib/podcasts/view/download_button.dart` +```dart +if (isDownloaded) { + episode.deleteDownloadCommand.run(); +} +``` + +**Pattern**: Entity-level command (on `EpisodeMedia`) alongside `downloadCommand` + +--- + +## Key Files Changed + +### Core Changes +- **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) + +### UI Updates +- **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 + +### Removed +- 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) + +--- + +## Dependencies + +- **command_it**: ^9.4.1 (progress tracking, cancellation, global errors) +- **listen_it**: (ListNotifier for activeDownloads) +- **dio**: (CancelToken for download cancellation) + +--- + +## Benefits + +1. **Simpler State Management**: Commands own their state, no separate registry +2. **Better UI Integration**: Watch command properties directly +3. **Automatic Resource Handling**: Episodes "just work" with local paths +4. **Single Lookup**: Only call getDownload() once during construction +5. **Type Safety**: All command properties are ValueListenable +6. **Cancellation**: Built-in cooperative cancellation support +7. **Error Handling**: Global error stream for user notifications +8. **Progress Tracking**: Real-time progress updates via handle.updateProgress() + +--- + +## Migration Notes + +**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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5be17a0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,354 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MediaDojo is a desktop podcast and radio player built with Flutter. It serves as an **example application** to showcase the flutter_it ecosystem (`get_it`, `watch_it`, `command_it`, `listen_it`) for building well-structured Flutter applications without code generation. + +The app targets Linux and macOS desktop platforms and demonstrates clean architecture with separation between UI, business logic (Managers), and data access (Services). + +## Common Development Commands + +### Building and Running + +```bash +# Run the app (Linux/macOS) +flutter run + +# Build for Linux +flutter build linux -v + +# Build for macOS +flutter build macos -v +``` + +### Code Quality + +```bash +# Analyze code (strict mode with fatal infos) +flutter analyze --fatal-infos + +# Format code (REQUIRED before commits per user preferences) +dart format . + +# Check formatting without changes +dart format --set-exit-if-changed . + +# Get dependencies +flutter pub get +``` + +### Testing + +Note: Tests are currently commented out in CI. When adding tests: + +```bash +# Run all tests +flutter test + +# Run specific test file +flutter test test/path/to/test_file.dart +``` + +### Flutter Version Management + +This project uses FVM (Flutter Version Manager): + +```bash +# Current Flutter version: 3.35.5 (see .fvmrc) +fvm use 3.35.5 +fvm flutter run +fvm flutter build linux +``` + +## Architecture + +MediaDojo follows a **three-layer architecture**: + +``` +UI (Flutter Widgets) ← watch/call → Managers (Business Logic) → Services (Data Access) → Data Sources +``` + +### Layer Responsibilities + +**1. UI Layer** (`lib/*/view/`) +- Flutter widgets that display data and handle user interactions +- Uses `watch_it` to reactively watch Manager state +- Calls Commands on Managers (never directly calls Services) +- Must extend `WatchingWidget` or `WatchingStatefulWidget` when using watch_it functions + +**2. Manager Layer** (`lib/*/*_manager.dart`) +- Contains business logic encapsulated in `Command` objects from `command_it` +- Registered as singletons in `get_it` (lives for entire app lifetime) +- Exposes Commands that UI can call and watch +- Orchestrates calls to Services +- No direct database/network access + +**3. Service Layer** (`lib/*/*_service.dart`) +- Pure data access and business operations +- No UI dependencies +- Handles network requests, local storage, file I/O +- Can be pure Dart classes or use Flutter dependencies (e.g., `ChangeNotifier`) + +### Key Managers + +All registered in `lib/register_dependencies.dart`: + +- **PodcastManager**: Podcast search and episode fetching via Commands +- **PlayerManager**: Audio playback control (extends `BaseAudioHandler`) +- **DownloadManager**: Episode download orchestration (extends `ChangeNotifier`) +- **RadioManager**: Radio station management +- **SettingsManager**: User preferences +- **SearchManager**: Global search coordination +- **CollectionManager**: User's collection management + +### Key Services + +- **PodcastService**: Podcast search API integration (`podcast_search` package) +- **PodcastLibraryService**: Podcast subscriptions storage (`SharedPreferences`) +- **RadioService**: Radio station data fetching (`radio_browser_api`) +- **RadioLibraryService**: Radio favorites storage +- **SettingsService**: Persistent user settings +- **NotificationsService**: Local notifications +- **OnlineArtService**: Album art fetching + +### Dependency Registration Pattern + +See `lib/register_dependencies.dart` for the complete setup. Key patterns: + +```dart +// Singleton with async initialization +di.registerSingletonAsync( + () async => ServiceName(), + dependsOn: [OtherService], +); + +// Lazy singleton (created on first access) +di.registerLazySingleton( + () => ServiceName(), + dispose: (s) => s.dispose(), +); + +// Singleton with dependencies +di.registerSingletonWithDependencies( + () => Manager(service: di()), + dependsOn: [ServiceName], +); +``` + +**IMPORTANT**: Use `di` (alias for `GetIt.instance`) throughout the codebase, not `GetIt.instance` directly. + +## flutter_it Patterns + +### Command Pattern + +Commands wrap functions and provide reactive state. UI can call them and watch for results/errors separately: + +```dart +// In Manager +late Command updateSearchCommand; + +updateSearchCommand = Command.createAsync( + (query) async => _podcastService.search(searchQuery: query), + initialValue: SearchResult(items: []), +); + +// In UI +di().updateSearchCommand.run('flutter'); + +// Watch results elsewhere +final results = watchValue((context) => + di().updateSearchCommand.value +); +``` + +### watch_it Requirements + +**CRITICAL**: When using any watch_it functions (`watch`, `watchValue`, `callOnce`, `createOnce`, `registerHandler`): + +- Widget MUST extend `WatchingWidget` or `WatchingStatefulWidget` +- All watch calls MUST be in the same order on every build +- Never conditionally call watch functions + +### Manager Lifecycle + +Managers are singletons that live for the entire app lifetime: +- No need to dispose Commands or subscriptions manually +- They're cleaned up when the app process terminates +- Document this clearly in Manager classes (see `PodcastManager` for example) + +### ValueListenable Operations + +Use `listen_it` operators for reactive transformations: + +```dart +// Debounce search input +searchManager.textChangedCommand + .debounce(const Duration(milliseconds: 500)) + .listen((filterText, sub) => updateSearchCommand.run(filterText)); +``` + +## Code Style and Linting + +The project uses strict linting rules (see `analysis_options.yaml`): + +- **Single quotes** for strings +- **Const constructors** where possible +- **Trailing commas** required +- **Relative imports** within the project +- **No print statements** - use logging utilities +- **Cancel subscriptions** properly +- **Close sinks** when done + +Key rules to follow: +- `prefer_single_quotes: true` +- `prefer_const_constructors: true` +- `require_trailing_commas: true` +- `prefer_relative_imports: true` +- `avoid_print: true` +- `cancel_subscriptions: true` + +## Platform Considerations + +### Desktop-Specific Setup + +- **Window Management**: Uses `window_manager` package for window control +- **System Theme**: Uses `system_theme` for accent colors +- **Audio Backend**: Uses `media_kit` with MPV for audio/video playback +- **Linux Dependencies**: Requires libmpv-dev, libnotify-dev, libgtk-3-dev +- **Audio Service**: Integrates with desktop media controls via `audio_service` + +### Platform Detection + +Use `lib/common/platforms.dart` for platform checks: + +```dart +if (Platforms.isLinux) { ... } +if (Platforms.isMacOS) { ... } +if (Platforms.isDesktop) { ... } +``` + +## Project Structure + +``` +lib/ +├── app/ # App initialization and main widget +│ ├── app.dart # Root widget +│ ├── home.dart # Main navigation scaffold +│ └── app_config.dart # App constants +├── common/ # Shared utilities and widgets +│ ├── view/ # Reusable UI components +│ ├── platforms.dart # Platform detection +│ └── logging.dart # Debug logging utilities +├── podcasts/ # Podcast feature +│ ├── podcast_manager.dart +│ ├── podcast_service.dart +│ ├── podcast_library_service.dart +│ ├── download_manager.dart +│ ├── data/ # Podcast data models +│ └── view/ # Podcast UI widgets +├── radio/ # Radio feature +│ ├── radio_manager.dart +│ ├── radio_service.dart +│ └── view/ +├── player/ # Audio player feature +│ ├── player_manager.dart +│ ├── data/ # Player state models +│ └── view/ # Player UI +├── settings/ # Settings feature +│ ├── settings_manager.dart +│ ├── settings_service.dart +│ └── view/ +├── search/ # Global search +│ └── search_manager.dart +├── collection/ # User collections +│ └── collection_manager.dart +├── extensions/ # Dart extension methods +├── l10n/ # Localization (generated) +└── register_dependencies.dart # Dependency injection setup +``` + +## Key Dependencies + +- **flutter_it**: Umbrella package containing get_it, watch_it, command_it, listen_it +- **media_kit**: Audio/video playback engine +- **podcast_search**: Podcast search API client +- **radio_browser_api**: Radio station database API +- **audio_service**: OS media controls integration +- **shared_preferences**: Local key-value storage +- **dio**: HTTP client for downloads +- **window_manager**: Desktop window control +- **yaru**: Ubuntu-style widgets and theming + +Several dependencies are pinned to specific git commits for stability. + +## Data Models + +### Media Types + +The app uses a hierarchy of media types for playback: + +- **UniqueMedia** (base class): Represents any playable media +- **EpisodeMedia**: Podcast episodes +- **StationMedia**: Radio stations +- **LocalMedia**: Local audio files + +All found in `lib/player/data/`. + +### Podcast Data + +- **PodcastMetadata**: Extended podcast info with subscription state +- **Item**: From `podcast_search` package (podcast or episode) +- **Episode**: From `podcast_search` package + +### Download Management + +- **DownloadCapsule**: Encapsulates episode + download directory for download operations + +## File Naming and Organization + +- **Managers**: `{feature}_manager.dart` (e.g., `podcast_manager.dart`) +- **Services**: `{feature}_service.dart` (e.g., `podcast_service.dart`) +- **Views**: `lib/{feature}/view/{widget_name}.dart` +- **Data Models**: `lib/{feature}/data/{model_name}.dart` +- **Extensions**: `lib/extensions/{type}_x.dart` (e.g., `string_x.dart`) + +## Development Workflow + +1. **Before making changes**: Read existing code to understand patterns +2. **Run analyzer**: `flutter analyze --fatal-infos` to catch issues +3. **Format code**: `dart format .` before committing (REQUIRED per user rules) +4. **Test locally**: Run the app to verify changes +5. **Never commit without asking** (per user's global rules) + +## CI/CD + +GitHub Actions workflows (`.github/workflows/`): + +- **ci.yaml**: Runs on PRs + - Analyzes with `flutter analyze --fatal-infos` + - Checks formatting with `dart format --set-exit-if-changed` + - Builds Linux binary (requires Rust toolchain and system dependencies) + - Tests are currently disabled + +- **release.yml**: Handles release builds + +## Important Notes + +- **No code generation**: This project intentionally avoids build_runner and code gen +- **No tests currently**: Test infrastructure exists but tests are commented out +- **Desktop only**: No mobile support (Android/iOS specific code paths exist but are unused) +- **Global exception handling**: `Command.globalExceptionHandler` is set in `PodcastManager` +- **FVM required**: Use FVM to ensure correct Flutter version (3.35.5) + +## Anti-Patterns to Avoid + +- ❌ Don't call Services directly from UI - always go through Managers +- ❌ Don't use `GetIt.instance` directly - use `di` alias +- ❌ Don't forget to extend `WatchingWidget`/`WatchingStatefulWidget` when using watch_it +- ❌ Don't conditionally call watch_it functions +- ❌ Don't add watch_it functions in different orders across rebuilds +- ❌ Don't use `print()` - use `printMessageInDebugMode()` from `common/logging.dart` +- ❌ Don't commit without formatting code first +- ❌ Don't create new features without following the Manager → Service pattern diff --git a/DOWNLOAD_ARCHITECTURE_ANALYSIS.md b/DOWNLOAD_ARCHITECTURE_ANALYSIS.md new file mode 100644 index 0000000..d64aba1 --- /dev/null +++ b/DOWNLOAD_ARCHITECTURE_ANALYSIS.md @@ -0,0 +1,842 @@ +# MediaDojo Download Architecture Analysis + +**Date**: 2025-01-23 +**Status**: In Discussion - Not Yet Implemented +**Purpose**: Document architectural exploration for podcast episode downloads + +--- + +## Table of Contents + +1. [Current Architecture](#current-architecture) +2. [Proposed Architectures Explored](#proposed-architectures-explored) +3. [Key Technical Findings](#key-technical-findings) +4. [Recommended Architecture](#recommended-architecture) +5. [Implementation Considerations](#implementation-considerations) +6. [Open Questions](#open-questions) + +--- + +## Current Architecture + +### Overview + +Downloads are managed centrally by `DownloadManager` (extends `ChangeNotifier`): + +```dart +class DownloadManager extends ChangeNotifier { + final _episodeToProgress = {}; + final _episodeToCancelToken = {}; + + Future startOrCancelDownload(DownloadCapsule capsule); + double? getProgress(EpisodeMedia? episode); + Future cancelAllDownloads(); +} +``` + +### Data Flow + +1. User clicks download button in UI +2. UI creates `DownloadCapsule` with episode + download directory +3. Calls `DownloadManager.startOrCancelDownload(capsule)` +4. DownloadManager uses Dio to download file +5. Progress tracked in `_episodeToProgress` map +6. On completion, persists to SharedPreferences via `PodcastLibraryService` +7. UI watches DownloadManager via watch_it + +### Current Strengths + +- ✅ Simple, proven pattern +- ✅ Single source of truth +- ✅ Works with episode immutability +- ✅ Compatible with compute() for episode parsing +- ✅ Easy to find all active downloads (single map) +- ✅ Easy to cancel all downloads + +### Current Limitations + +- Uses ChangeNotifier instead of command_it pattern +- Progress tracked in Maps (not Commands) +- Episodes are value objects, download state is external + +--- + +## Proposed Architectures Explored + +### Architecture 1: Commands on Episode Objects (REJECTED) + +**Concept**: Each episode owns its download command + +```dart +class EpisodeMedia { + late final Command downloadCommand; + + EpisodeMedia(...) { + downloadCommand = Command.createAsyncNoParam(() async { + // Download logic + }); + } +} +``` + +**Fatal Problems Identified**: + +1. **Episode Recreation**: Episodes recreated on cache invalidation → commands lost +2. **copyWithX**: Creates new episodes → duplicate commands +3. **Isolate Incompatibility**: Episodes created in compute() → can't access di<> +4. **Shared Resources**: Every command needs Dio, PodcastLibraryService +5. **Global Operations**: Finding all downloads requires iterating all episodes +6. **Memory Overhead**: N commands for N episodes (most never downloaded) + +**Verdict**: Architecturally unsound for immutable value objects. + +--- + +### Architecture 2: Hybrid - Commands Call Central Service (REJECTED) + +**Concept**: Episodes have commands that delegate to DownloadManager + +```dart +class EpisodeMedia { + late final Command downloadCommand; + + EpisodeMedia(...) { + downloadCommand = Command.createAsyncNoParam(() async { + final manager = di(); + return await manager.downloadEpisode(this); + }); + } +} +``` + +**Problems Identified**: + +1. **Command Progress**: Commands don't support incremental progress updates + - Dio's `onReceiveProgress` fires repeatedly during download + - Command.value only set at completion + - Still need DownloadManager map for progress tracking + +2. **Isolate Issue Remains**: Episodes created in compute() isolate + - Can't access di() in isolate + - Must remove compute() → blocks UI during feed parsing + +3. **copyWithX Still Breaks**: Creates new command instances + +4. **Still Need All Maps**: Progress map, cancel token map, registry + - DownloadManager remains same complexity + - Episodes now also complex + +**Verdict**: Adds complexity without solving actual problems. + +--- + +### Architecture 3: Episode-Owned Progress with Self-Registration (EXPLORING) + +**Concept**: Episodes have ValueNotifier for progress, register with central registry + +```dart +class EpisodeMedia { + late final ValueNotifier downloadProgress; + late final Command downloadCommand; + + EpisodeMedia(...) { + downloadProgress = ValueNotifier(null); + + downloadCommand = Command.createAsyncNoParam(() async { + final service = di(); + di().register(this); + + try { + return await service.downloadFile( + url: this.url, + onProgress: (progress) { + downloadProgress.value = progress; + }, + ); + } finally { + di().unregister(this); + } + }); + } +} +``` + +**User Preferences**: +- Self-registration with central registry +- MapNotifier or ListNotifier for registry +- DownloadService provides basic download functions +- Check persistence at episode creation time + +**Issues Remain**: +- Still can't initialize in compute() isolate +- copyWithX creates new instances with new state +- ValueNotifier is mutable state in value object + +--- + +### Architecture 4: Immutable Episodes + MapNotifier Registry (CURRENT RECOMMENDATION) + +**Concept**: Keep episodes immutable, add reactive registry + +```dart +// Central registry with MapNotifier +class DownloadRegistry { + final activeDownloads = MapNotifier({}); + + void register(EpisodeMedia episode, CancelToken token) { + activeDownloads[episode.id] = DownloadProgress(episode, 0.0, token); + } + + void updateProgress(String episodeId, double progress) { + final existing = activeDownloads[episodeId]; + activeDownloads[episodeId] = existing.copyWith(progress: progress); + } + + void unregister(String episodeId) { + activeDownloads.remove(episodeId); + } +} + +// DownloadManager orchestrates +class DownloadManager { + final DownloadRegistry _registry; + + Future startDownload(EpisodeMedia episode) async { + final token = CancelToken(); + _registry.register(episode, token); + + try { + await _dio.download( + episode.url, + path, + onReceiveProgress: (received, total) { + _registry.updateProgress(episode.id, received / total); + }, + cancelToken: token, + ); + + _registry.unregister(episode.id); + return path; + } catch (e) { + _registry.unregister(episode.id); + rethrow; + } + } +} + +// Episodes created with persistence check +extension PodcastX on Podcast { + List toEpisodeMediaListWithPersistence(...) { + return episodes.map((e) { + var episode = EpisodeMedia(...); + + // Check if already downloaded + final localPath = downloadService.getSavedPath(episode.url); + if (localPath != null) { + episode = episode.copyWithX(resource: localPath); + } + + return episode; + }).toList(); + } +} + +// UI watches MapNotifier +final activeDownloads = watchValue((DownloadRegistry r) => r.activeDownloads); +``` + +**Benefits**: +- ✅ Episodes remain immutable +- ✅ Persistence checked at creation (after compute() returns) +- ✅ MapNotifier provides reactive updates +- ✅ Self-registration pattern (in manager, not episode) +- ✅ Compatible with compute() isolation +- ✅ copyWithX works correctly +- ✅ No media_kit contract violations +- ✅ O(1) lookup by episode ID + +--- + +## Key Technical Findings + +### Episode Creation Lifecycle + +**Location**: `lib/podcasts/podcast_service.dart` + +```dart +// Line 172: Runs in compute() isolate +final Podcast? podcast = await compute(loadPodcast, url); + +// Line 180: Back on main thread - CAN access di<> +final episodes = podcast?.toEpisodeMediaList(url, item); +``` + +**Critical Finding**: Persistence CAN be checked at line 180 after compute() returns. + +--- + +### Episode Caching Behavior + +**Location**: `lib/podcasts/podcast_service.dart` + +```dart +final Map> _episodeCache = {}; + +Future> findEpisodes({bool loadFromCache = true}) async { + if (_episodeCache.containsKey(url) && loadFromCache) { + return _episodeCache[url]!; // Returns SAME instances + } + + // Create new instances + final episodes = podcast?.toEpisodeMediaList(url, item); + _episodeCache[url] = episodes; + return episodes; +} +``` + +**Critical Finding**: Episodes ARE cached and reused. Only recreated when: +- First fetch +- Cache invalidation (`loadFromCache: false`) +- Update checks find new content + +--- + +### copyWithX Usage + +**Only Used For**: Swapping streaming URL → local file path at playback time + +**Locations**: +- `lib/podcasts/view/podcast_card.dart:111` +- `lib/podcasts/view/recent_downloads_button.dart:111` + +```dart +final download = di().getDownload(e.id); +if (download != null) { + return e.copyWithX(resource: download); // Swap URL for playback +} +``` + +**Critical Finding**: copyWithX is NOT a bug - it's the correct pattern for media_kit. + +--- + +### Media Kit Integration + +**EpisodeMedia Hierarchy**: +``` +Media (media_kit - immutable) + ↑ +UniqueMedia (app - adds ID-based equality) + ↑ +EpisodeMedia (app - podcast-specific data) +``` + +**Media.resource**: Final field that is the playable URL/path +- Immutable by design in media_kit +- Player.open() expects immutable Media instances +- Mutating would violate media_kit contract + +**Critical Finding**: Making episodes mutable violates media_kit's assumptions. + +--- + +### Command Pattern Limitations + +**Command Execution Model**: +```dart +1. isRunning = true +2. Execute async function +3. Get result +4. value = result ← Only set ONCE at end +5. isRunning = false +``` + +**No incremental updates during step 2!** + +**Dio Download Model**: +```dart +await dio.download( + url, + path, + onReceiveProgress: (count, total) { + // Called MANY times during download + // How to update Command.value? Can't! + }, +); +``` + +**Critical Finding**: Commands don't support progress tracking for long operations. + +--- + +### Episode Equality & Hashing + +**Implementation**: `lib/player/data/unique_media.dart` + +```dart +@override +bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UniqueMedia && other.id == id; +} + +@override +int get hashCode => id.hashCode; +``` + +**ID Source**: `episode.guid` (globally unique identifier from podcast feed) + +**Critical Finding**: Equality based on ID, NOT resource. Changing resource doesn't affect map lookups. + +--- + +### MapNotifier vs ListNotifier + +**From listen_it package** (already available): + +**MapNotifier**: +- O(1) lookup by key +- Implements ValueListenable> +- Automatic notifications on add/remove/update +- Can watch entire map or individual keys + +**ListNotifier**: +- O(n) search to find item +- Implements ValueListenable> +- Automatic notifications on add/remove/update +- Can watch entire list or individual indices + +**For Downloads**: MapNotifier is superior (keyed by episode ID) + +--- + +## Recommended Architecture + +### Core Principles + +1. **Episodes remain immutable value objects** + - No mutable state (commands, ValueNotifiers) + - Can be used as Map keys safely + - Compatible with media_kit's assumptions + +2. **DownloadRegistry tracks active downloads** + - MapNotifier + - Episodes self-register via DownloadManager + - Unregister on completion or error + +3. **Persistence checked at creation** + - After compute() returns (can access di<>) + - Episodes created with correct resource immediately + +4. **DownloadManager orchestrates operations** + - Handles Dio, cancellation, progress + - Updates DownloadRegistry + - Persists to PodcastLibraryService + +### Component Responsibilities + +**EpisodeMedia**: Immutable data +- Podcast episode metadata +- Playback resource (URL or local path) +- No download logic or state + +**DownloadRegistry**: Reactive state tracking +- MapNotifier of active downloads +- Progress updates +- CancelToken management + +**DownloadManager**: Download orchestration +- Executes downloads via Dio +- Updates DownloadRegistry +- Handles persistence +- Provides public API + +**PodcastLibraryService**: Persistence layer +- SharedPreferences storage +- Download path queries +- Feed-to-downloads mapping + +### Data Flow + +``` +1. User clicks download + ↓ +2. DownloadManager.startDownload(episode) + ↓ +3. Create CancelToken + ↓ +4. DownloadRegistry.register(episode, token) + ↓ +5. Dio.download() with onReceiveProgress + ↓ +6. DownloadRegistry.updateProgress() on each chunk + ↓ +7. On success: PodcastLibraryService.addDownload() + ↓ +8. DownloadRegistry.unregister(episode.id) + ↓ +9. UI watches MapNotifier, rebuilds automatically +``` + +--- + +## Implementation Considerations + +### Files to Modify + +1. **Create**: `lib/podcasts/download_registry.dart` + - New class with MapNotifier + - Registration/unregistration methods + - Progress updates + +2. **Modify**: `lib/podcasts/download_manager.dart` + - Inject DownloadRegistry + - Update methods to use registry + - Remove ChangeNotifier (maybe - TBD) + +3. **Modify**: `lib/extensions/podcast_x.dart` + - Add persistence check to toEpisodeMediaList + - Access DownloadService for saved paths + +4. **Modify**: `lib/register_dependencies.dart` + - Register DownloadRegistry singleton + +5. **Modify**: UI files + - Watch MapNotifier instead of ChangeNotifier + - Simplified reactive updates + +### Backward Compatibility + +**Breaking Changes**: +- DownloadManager API changes +- UI watching different notifier + +**Non-Breaking**: +- EpisodeMedia remains unchanged +- Persistence layer unchanged +- Dio integration unchanged + +### Testing Strategy + +**Unit Tests**: +- DownloadRegistry registration/unregistration +- Progress update logic +- Episode creation with persistence check + +**Integration Tests**: +- Complete download flow +- Cancel during download +- Recent downloads dialog +- App restart persistence + +--- + +## Open Questions + +### 1. DownloadManager vs DownloadService Naming + +**Current**: `DownloadManager` (extends ChangeNotifier) + +**Proposed**: Rename to `DownloadService`? +- Provides download capability +- Doesn't "manage" UI state directly (DownloadRegistry does) +- Follows naming convention (PodcastService, RadioService) + +**Decision**: TBD + +### 2. Keep ChangeNotifier or Remove? + +**Current**: DownloadManager extends ChangeNotifier for UI updates + +**With Registry**: DownloadRegistry has MapNotifier +- Do we still need ChangeNotifier in manager? +- Or just have manager be a plain service class? + +**Decision**: TBD + +### 3. Command Integration + +Should DownloadManager expose a download command? + +```dart +class DownloadManager { + late final Command downloadCommand; +} +``` + +**Pros**: Aligns with command_it pattern +**Cons**: Command doesn't help with progress tracking + +**Decision**: TBD + +### 4. Progress Granularity + +**Current**: double? (0.0 to 1.0) representing percentage + +**Alternative**: More structured data? + +```dart +class DownloadProgress { + final int bytesReceived; + final int bytesTotal; + final double percentage; + final EpisodeMedia episode; + final CancelToken cancelToken; +} +``` + +**Decision**: Structured data (implemented in recommendation) + +### 5. Error Handling + +How should errors be surfaced? + +**Current**: Message stream broadcasts error strings + +**Options**: +- Keep message stream +- Add errors to DownloadProgress +- Separate error registry +- Command.error ValueListenable + +**Decision**: TBD + +--- + +## Finalized Architectural Decisions + +### **Decision 1: DownloadManager → DownloadService** +**Rationale**: After moving state to DownloadRegistry and orchestration to PodcastManager, this class becomes a pure service providing download operations. UI will call PodcastManager, not DownloadService directly. + +**Status**: ✅ DECIDED + +### **Decision 2: Remove ChangeNotifier from DownloadService** +**Rationale**: MapNotifier in DownloadRegistry handles all reactive updates. DownloadService doesn't need its own notification mechanism. + +**Status**: ✅ DECIDED + +### **Decision 3: Add Download Command to PodcastManager** +**Rationale**: Aligns with command_it pattern. PodcastManager orchestrates download operations via command. + +**Implementation**: +```dart +class PodcastManager { + late final Command downloadCommand; + + downloadCommand = Command.createAsync( + (episode) => _downloadService.download(episode), + ); +} +``` + +**Status**: ✅ DECIDED + +### **Decision 4: Error Handling via DownloadProgress** +**Approach**: Store error message in DownloadProgress object with auto-cleanup after delay. + +**Implementation**: +```dart +class DownloadProgress { + final EpisodeMedia episode; + final double progress; + final CancelToken cancelToken; + final String? errorMessage; // null if no error + + bool get hasError => errorMessage != null; +} +``` + +**Status**: ✅ DECIDED + +### **Decision 5: Move Episode Cache to PodcastManager** +**Problem Identified**: Episode cache is currently in PodcastService but commands that use it are in PodcastManager. This violates separation of concerns. + +**Solution**: Move `_episodeCache` from PodcastService to PodcastManager. + +**Before**: +```dart +// PodcastService - has cache (wrong!) +class PodcastService { + final Map> _episodeCache = {}; + Future> findEpisodes({...}) { + if (_episodeCache.containsKey(url)) return _episodeCache[url]!; + // ... + } +} +``` + +**After**: +```dart +// PodcastService - stateless, pure operations +class PodcastService { + Future search({...}); + Future loadPodcastFeed(String url); +} + +// PodcastManager - stateful, manages cache + registry +class PodcastManager { + final PodcastService _service; + final Map> _episodeCache = {}; + final DownloadRegistry downloads = DownloadRegistry(); + + late Command> fetchEpisodeMediaCommand; + late Command downloadCommand; +} +``` + +**Rationale**: +- Services should be stateless (operations only) +- Managers should manage state (cache, registry) +- Logical grouping: cache and commands that use it live together +- Consistent pattern across the app + +**Status**: ✅ DECIDED + +### **Decision 6: DownloadRegistry Lives in PodcastManager** +**Rationale**: Downloads are part of the podcast domain. PodcastManager already manages episode cache, adding download registry is natural extension. + +**Implementation**: +```dart +class PodcastManager { + // Episode state + final _episodeCache = >{}; + + // Download state + final downloads = DownloadRegistry(); + + // Commands for both + late Command> fetchEpisodeMediaCommand; + late Command downloadCommand; +} +``` + +**Status**: ✅ DECIDED + +### **Decision 7: Simplify Episode Creation - Remove Extension Method** +**Problem Identified**: Episode creation uses extension method with chained where/map, spread across files. Overly complicated. + +**Current (Complicated)**: +```dart +// lib/extensions/podcast_x.dart +extension PodcastX on Podcast { + List toEpisodeMediaList(String url, Item? item) => episodes + .where((e) => e.contentUrl != null) + .map((e) => EpisodeMedia(...)) + .toList(); +} + +// Called from PodcastService +final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; +``` + +**After (Clean)**: +```dart +// Inline in PodcastManager._fetchEpisodes() +final episodes = podcastData.episodes + .where((e) => e.contentUrl != null) + .map((e) { + // Check for download + final localPath = di().getDownload(e.guid); + + return EpisodeMedia( + localPath ?? e.contentUrl!, // Use local path if available + episode: e, + feedUrl: url, + albumArtUrl: podcast.artworkUrl600 ?? podcast.artworkUrl ?? podcastData.image, + collectionName: podcastData.title, + artist: podcastData.copyright, + genres: [if (podcast.primaryGenreName != null) podcast.primaryGenreName!], + ); + }) + .toList(); +``` + +**Benefits**: +- Functional style (idiomatic Dart) without over-engineering +- No extension method - all logic in one place +- Download persistence check integrated naturally +- More readable and maintainable + +**Actions**: +- Delete `lib/extensions/podcast_x.dart` +- Move episode creation into PodcastManager +- Integrate download check inline + +**Status**: ✅ DECIDED + +--- + +## Next Steps + +1. ✅ Document current knowledge (this file) +2. ✅ Iterate on architecture decisions (COMPLETE) +3. ✅ Finalize naming conventions (COMPLETE) +4. ✅ Decide on ChangeNotifier vs pure service (COMPLETE - removed) +5. ✅ Decide on Command integration (COMPLETE - added) +6. ✅ Finalize error handling approach (COMPLETE) +7. ✅ Decide cache location (COMPLETE - moved to Manager) +8. ⏳ Create detailed implementation plan +9. ⏳ Implement changes +10. ⏳ Test thoroughly +11. ⏳ Update documentation + +--- + +## References + +### Related Files + +- `lib/podcasts/download_manager.dart` - Current implementation +- `lib/podcasts/podcast_library_service.dart` - Persistence layer +- `lib/podcasts/podcast_service.dart` - Episode creation +- `lib/extensions/podcast_x.dart` - toEpisodeMediaList extension +- `lib/player/data/episode_media.dart` - EpisodeMedia class +- `lib/player/data/unique_media.dart` - Equality implementation +- `lib/podcasts/view/download_button.dart` - UI integration +- `lib/podcasts/view/recent_downloads_button.dart` - Active downloads dialog + +### Key Dependencies + +- `dio` - HTTP downloads with progress +- `listen_it` - MapNotifier, ValueListenable operators +- `command_it` - Command pattern (if used) +- `watch_it` - Reactive UI watching +- `get_it` - Dependency injection +- `media_kit` - Media playback (requires immutable Media) + +--- + +## Architectural Principles + +### Separation of Concerns + +- **Data**: EpisodeMedia (immutable) +- **State**: DownloadRegistry (reactive) +- **Behavior**: DownloadManager (orchestration) +- **Persistence**: PodcastLibraryService (storage) +- **UI**: Widgets (reactive watching) + +### Immutability + +- Value objects should be immutable +- State changes via object replacement, not mutation +- Aligns with Flutter's rebuild model +- Safe for use as Map keys + +### Reactivity + +- Use ValueListenables for state +- UI watches with watch_it +- Automatic updates, no manual subscriptions +- MapNotifier for O(1) keyed access + +### Dependency Injection + +- get_it for service locator +- Constructor injection where possible +- di<> only in main isolate +- Testability via mocking + +--- + +**Document Status**: Living document - will be updated as architecture evolves. diff --git a/lib/app/home.dart b/lib/app/home.dart index 388fa8a..32ae7f5 100644 --- a/lib/app/home.dart +++ b/lib/app/home.dart @@ -8,7 +8,6 @@ import '../extensions/build_context_x.dart'; import '../player/player_manager.dart'; import '../player/view/player_full_view.dart'; import '../player/view/player_view.dart'; -import '../podcasts/download_manager.dart'; import '../podcasts/view/recent_downloads_button.dart'; import '../search/view/search_view.dart'; import '../settings/view/settings_dialog.dart'; @@ -18,9 +17,16 @@ class Home extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - registerStreamHandler( - select: (DownloadManager m) => m.messageStream, - handler: downloadMessageStreamHandler, + registerStreamHandler, CommandError>( + target: Command.globalErrors, + handler: (context, snapshot, cancel) { + if (snapshot.hasData) { + final error = snapshot.data!; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text('Download error: ${error.error}')), + ); + } + }, ); final playerFullWindowMode = watchValue( diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 100c895..7249fb2 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -1,13 +1,54 @@ import 'dart:typed_data'; +import 'package:dio/dio.dart'; +import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; import '../../extensions/date_time_x.dart'; +import '../../podcasts/download_service.dart'; +import '../../podcasts/podcast_manager.dart'; import 'unique_media.dart'; class EpisodeMedia extends UniqueMedia { - EpisodeMedia( + // Factory constructor that checks for persisted downloads + factory EpisodeMedia( + String resource, { + Map? extras, + Map? httpHeaders, + Duration? start, + Duration? end, + required Episode episode, + required String feedUrl, + int? bitRate, + String? albumArtUrl, + List genres = const [], + String? collectionName, + String? artist, + }) { + // Check if episode was previously downloaded (persisted in SharedPreferences) + final downloadPath = di().getDownload(episode.contentUrl); + + return EpisodeMedia._( + resource, // Always use original URL as resource + downloadPath: downloadPath, + extras: extras, + httpHeaders: httpHeaders, + start: start, + end: end, + episode: episode, + feedUrl: feedUrl, + bitRate: bitRate, + albumArtUrl: albumArtUrl, + genres: genres, + collectionName: collectionName, + artist: artist, + ); + } + + // Private constructor that receives pre-computed values + EpisodeMedia._( super.resource, { + String? downloadPath, super.extras, super.httpHeaders, super.start, @@ -24,8 +65,13 @@ class EpisodeMedia extends UniqueMedia { _albumArtUrl = albumArtUrl, _genres = genres, _collectionName = collectionName, - _artist = artist; + _artist = artist, + _downloadPath = downloadPath; + /// Path to downloaded file, or null if not downloaded. + /// Updated by downloadCommand (on success) and deleteDownloadCommand (clears it). + String? _downloadPath; + String? get downloadPath => _downloadPath; final Episode episode; final String _feedUrl; final int? _bitRate; @@ -119,4 +165,72 @@ class EpisodeMedia extends UniqueMedia { return '${artist ?? ''}${title ?? ''}${duration?.inMilliseconds ?? ''}${creationDateTime?.podcastTimeStamp ?? ''})$now' .replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); } + + /// Returns true if this episode has been downloaded + bool get isDownloaded => _downloadPath != null; + + // Download command with progress and cancellation support + late final downloadCommand = (() { + final command = + Command.createAsyncNoParamNoResultWithProgress((handle) async { + // 1. Add to active downloads + di().registerActiveDownload(this); + + // 2. Create CancelToken + final cancelToken = CancelToken(); + + // 3. Listen to cancellation and forward to Dio + handle.isCanceled.listen((canceled, subscription) { + if (canceled) { + cancelToken.cancel(); + subscription.cancel(); + } + }); + + // 4. Download with progress updates + final path = await di().download( + episode: this, + cancelToken: cancelToken, + onProgress: (received, total) { + handle.updateProgress(received / total); + }, + ); + + // 5. Set download path on success + _downloadPath = path; + + // 6. Keep in active downloads so user can see completed downloads + // (will be removed when user deletes or starts new session) + }, errorFilter: const LocalAndGlobalErrorFilter()) + ..errors.listen((error, subscription) { + // Error handler: remove from active downloads + di().unregisterActiveDownload(this); + }); + + // Initialize progress to 1.0 if already downloaded + if (_downloadPath != null) { + command.resetProgress(progress: 1.0); + } + + return command; + })(); + + // Delete download command with optimistic update for progress UI + late final deleteDownloadCommand = + Command.createAsyncNoParamNoResult(() async { + // Optimistic: reset progress immediately for instant UI feedback + downloadCommand.resetProgress(progress: 0.0); + + // Delete async + await di().deleteDownload(media: this); + + // Clear downloadPath only after successful delete + _downloadPath = null; + }, errorFilter: const LocalAndGlobalErrorFilter()) + ..errors.listen((error, _) { + // Rollback progress on error + if (error != null) { + downloadCommand.resetProgress(progress: 1.0); + } + }); } diff --git a/lib/player/player_manager.dart b/lib/player/player_manager.dart index eef8ea5..d0633cc 100644 --- a/lib/player/player_manager.dart +++ b/lib/player/player_manager.dart @@ -7,6 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import '../common/logging.dart'; import '../extensions/color_x.dart'; +import 'data/episode_media.dart'; import 'data/unique_media.dart'; import 'view/player_view_state.dart'; @@ -234,7 +235,16 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { }) async { if (mediaList.isEmpty) return; updateState(resetRemoteSource: true); - await _player.open(Playlist(mediaList, index: index), play: play); + + // Use download path if available for episodes + final resolvedList = mediaList.map((media) { + if (media is EpisodeMedia && media.downloadPath != null) { + return media.copyWithX(resource: media.downloadPath!); + } + return media; + }).toList(); + + await _player.open(Playlist(resolvedList, index: index), play: play); } Future addToPlaylist(UniqueMedia media) async => _player.add(media); diff --git a/lib/podcasts/data/podcast_metadata.dart b/lib/podcasts/data/podcast_metadata.dart index 259dae2..8e6fab2 100644 --- a/lib/podcasts/data/podcast_metadata.dart +++ b/lib/podcasts/data/podcast_metadata.dart @@ -1,3 +1,5 @@ +import 'package:podcast_search/podcast_search.dart'; + class PodcastMetadata { const PodcastMetadata({ required this.feedUrl, @@ -12,4 +14,17 @@ class PodcastMetadata { final String? name; final String? artist; final List? genreList; + + factory PodcastMetadata.fromItem(Item item) { + if (item.feedUrl == null) { + throw ArgumentError('Item must have a valid, non null feedUrl!'); + } + return PodcastMetadata( + feedUrl: item.feedUrl!, + name: item.collectionName, + artist: item.artistName, + imageUrl: item.bestArtworkUrl, + genreList: item.genre?.map((e) => e.name).toList() ?? [], + ); + } } diff --git a/lib/podcasts/data/podcast_proxy.dart b/lib/podcasts/data/podcast_proxy.dart new file mode 100644 index 0000000..a4316ba --- /dev/null +++ b/lib/podcasts/data/podcast_proxy.dart @@ -0,0 +1,59 @@ +import 'package:flutter_it/flutter_it.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../../player/data/episode_media.dart'; +import '../../player/player_manager.dart'; +import '../podcast_service.dart'; + +/// Wraps Item (search result) and lazily loads Podcast + episodes. +/// Each podcast owns its own fetchEpisodesCommand. +class PodcastProxy { + PodcastProxy({required this.item, required PodcastService podcastService}) + : _podcastService = podcastService { + fetchEpisodesCommand = Command.createAsyncNoParam>( + () async { + if (_episodes != null) return _episodes!; + + final result = await _podcastService.findEpisodes(item: item); + _podcast = result.podcast; + _episodes = result.episodes; + return _episodes!; + }, + initialValue: [], + ); + } + + final Item item; + final PodcastService _podcastService; + + Podcast? _podcast; + List? _episodes; + + late final Command> fetchEpisodesCommand; + + /// Fetches episodes if not cached, then starts playback. + late final playEpisodesCommand = Command.createAsyncNoResult(( + startIndex, + ) async { + // Fetch if not cached + if (_episodes == null) { + await fetchEpisodesCommand.runAsync(); + } + + if (_episodes != null && _episodes!.isNotEmpty) { + await di().setPlaylist(_episodes!, index: startIndex); + } + }); + + /// Clears cached episodes to force re-fetch on next command run. + void clearEpisodeCache() { + _episodes = null; + } + + // Getters - use Podcast if loaded, fall back to Item + String get feedUrl => item.feedUrl!; + String? get description => _podcast?.description; + String? get title => _podcast?.title ?? item.collectionName; + String? get image => _podcast?.image ?? item.bestArtworkUrl; + List get episodes => _episodes ?? []; +} diff --git a/lib/podcasts/download_manager.dart b/lib/podcasts/download_manager.dart deleted file mode 100644 index bcd89a3..0000000 --- a/lib/podcasts/download_manager.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; - -import '../player/data/episode_media.dart'; -import 'data/download_capsule.dart'; -import 'podcast_library_service.dart'; - -class DownloadManager extends ChangeNotifier { - DownloadManager({ - required PodcastLibraryService libraryService, - required Dio dio, - }) : _libraryService = libraryService, - _dio = dio { - _propertiesChangedSubscription = _libraryService.propertiesChanged.listen( - (_) => notifyListeners(), - ); - } - - final PodcastLibraryService _libraryService; - final Dio _dio; - StreamSubscription? _propertiesChangedSubscription; - - final _messageStreamController = StreamController.broadcast(); - String _lastMessage = ''; - void _addMessage(String message) { - if (message == _lastMessage) return; - _lastMessage = message; - _messageStreamController.add(message); - } - - Stream get messageStream => _messageStreamController.stream; - - List get feedsWithDownloads => _libraryService.feedsWithDownloads; - String? getDownload(String? url) => _libraryService.getDownload(url); - bool isDownloaded(String? url) => getDownload(url) != null; - final _episodeToProgress = {}; - Map get episodeToProgress => _episodeToProgress; - bool getDownloadsInProgress() => _episodeToProgress.values.any( - (progress) => progress != null && progress < 1.0, - ); - - double? getProgress(EpisodeMedia? episode) => _episodeToProgress[episode]; - void setProgress({ - required int received, - required int total, - required EpisodeMedia episode, - }) { - if (total <= 0) return; - _episodeToProgress[episode] = received / total; - notifyListeners(); - } - - final _episodeToCancelToken = {}; - bool _canCancelDownload(EpisodeMedia episode) => - _episodeToCancelToken[episode] != null; - Future startOrCancelDownload(DownloadCapsule capsule) async { - final url = capsule.media.url; - - if (url == null) { - throw Exception('Invalid media, missing URL to download'); - } - - if (_canCancelDownload(capsule.media)) { - await _cancelDownload(capsule.media); - await deleteDownload(media: capsule.media); - return null; - } - - if (!Directory(capsule.downloadsDir).existsSync()) { - Directory(capsule.downloadsDir).createSync(); - } - - final toDownloadPath = p.join( - capsule.downloadsDir, - capsule.media.audioDownloadId, - ); - final response = await _processDownload( - canceledMessage: capsule.canceledMessage, - episode: capsule.media, - path: toDownloadPath, - ); - - if (response?.statusCode == 200) { - await _libraryService.addDownload( - episodeUrl: url, - path: toDownloadPath, - feedUrl: capsule.media.feedUrl, - ); - _episodeToCancelToken.remove(capsule.media); - _addMessage(capsule.finishedMessage); - notifyListeners(); - } - return _libraryService.getDownload(url); - } - - Future _cancelDownload(EpisodeMedia? episode) async { - if (episode == null) return; - _episodeToCancelToken[episode]?.cancel(); - _episodeToProgress.remove(episode); - _episodeToCancelToken.remove(episode); - notifyListeners(); - } - - Future cancelAllDownloads() async { - final episodes = _episodeToCancelToken.keys.toList(); - for (final episode in episodes) { - _episodeToCancelToken[episode]?.cancel(); - _episodeToProgress.remove(episode); - _episodeToCancelToken.remove(episode); - } - notifyListeners(); - } - - Future?> _processDownload({ - required EpisodeMedia episode, - required String path, - required String canceledMessage, - }) async { - _episodeToCancelToken[episode] = CancelToken(); - try { - return await _dio.download( - episode.url!, - path, - onReceiveProgress: (count, total) => - setProgress(received: count, total: total, episode: episode), - cancelToken: _episodeToCancelToken[episode], - ); - } catch (e) { - _episodeToCancelToken[episode]?.cancel(); - - String? message; - if (e.toString().contains('[request cancelled]')) { - message = canceledMessage; - } - - _addMessage(message ?? e.toString()); - return null; - } - } - - Future deleteDownload({required EpisodeMedia? media}) async { - if (media?.url != null && media?.feedUrl != null) { - await _libraryService.removeDownload( - episodeUrl: media!.url!, - feedUrl: media.feedUrl, - ); - _episodeToProgress.remove(media); - notifyListeners(); - } - } - - Future deleteAllDownloads() async { - if (_episodeToProgress.isNotEmpty) { - throw Exception( - 'Cannot delete all downloads while downloads are in progress', - ); - } - await _libraryService.removeAllDownloads(); - _episodeToProgress.clear(); - notifyListeners(); - } - - @override - Future dispose() async { - await cancelAllDownloads(); - await _messageStreamController.close(); - await _propertiesChangedSubscription?.cancel(); - super.dispose(); - } -} - -void downloadMessageStreamHandler( - BuildContext context, - AsyncSnapshot snapshot, - void Function() cancel, -) { - if (snapshot.hasData) { - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text(snapshot.data!))); - } -} diff --git a/lib/podcasts/download_service.dart b/lib/podcasts/download_service.dart new file mode 100644 index 0000000..2d1148f --- /dev/null +++ b/lib/podcasts/download_service.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:path/path.dart' as p; + +import '../player/data/episode_media.dart'; +import '../settings/settings_manager.dart'; +import 'podcast_library_service.dart'; + +/// Service for downloading podcast episodes. +/// +/// This is a stateless service - download progress and state are managed +/// by episode download commands. +class DownloadService { + DownloadService({ + required PodcastLibraryService libraryService, + required Dio dio, + }) : _libraryService = libraryService, + _dio = dio { + _propertiesChangedSubscription = _libraryService.propertiesChanged.listen(( + _, + ) { + // Notify listeners when library changes (downloads added/removed) + // This allows UI watching isDownloaded to update + }); + } + + final PodcastLibraryService _libraryService; + final Dio _dio; + StreamSubscription? _propertiesChangedSubscription; + + // Read-only access to library service methods + List get feedsWithDownloads => _libraryService.feedsWithDownloads; + String? getDownload(String? url) => _libraryService.getDownload(url); + + /// Downloads an episode to the local filesystem. + /// + /// Used by episode download commands. Progress updates are sent via the + /// onProgress callback. + Future download({ + required EpisodeMedia episode, + required CancelToken cancelToken, + required void Function(int received, int total) onProgress, + }) async { + final url = episode.url; + if (url == null) { + throw Exception('Invalid media, missing URL to download'); + } + + final downloadsDir = di().downloadsDirCommand.value; + if (downloadsDir == null) { + throw Exception('Downloads directory not set'); + } + + if (!Directory(downloadsDir).existsSync()) { + Directory(downloadsDir).createSync(recursive: true); + } + + final path = p.join(downloadsDir, episode.audioDownloadId); + + final response = await _dio.download( + url, + path, + onReceiveProgress: onProgress, + cancelToken: cancelToken, + ); + + if (response.statusCode == 200) { + await _libraryService.addDownload( + episodeUrl: url, + path: path, + feedUrl: episode.feedUrl, + ); + return path; + } + + return null; + } + + /// Deletes a downloaded episode from the filesystem and library. + Future deleteDownload({required EpisodeMedia? media}) async { + if (media?.url != null && media?.feedUrl != null) { + await _libraryService.removeDownload( + episodeUrl: media!.url!, + feedUrl: media.feedUrl, + ); + } + } + + /// Deletes all downloaded episodes. + Future deleteAllDownloads() async { + await _libraryService.removeAllDownloads(); + } + + Future dispose() async { + await _propertiesChangedSubscription?.cancel(); + } +} diff --git a/lib/podcasts/podcast_library_service.dart b/lib/podcasts/podcast_library_service.dart index d9a5664..ae57cb7 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:podcast_search/podcast_search.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../extensions/date_time_x.dart'; @@ -14,8 +16,7 @@ class PodcastLibraryService { final SharedPreferences _sharedPreferences; - // This stream is currently used for downloads - // TODO: replace with download commmand in DownloadManager + // This stream is currently used for updates and feeds with downloads final _propertiesChangedController = StreamController.broadcast(); Stream get propertiesChanged => _propertiesChangedController.stream; Future notify(bool value) async => @@ -40,11 +41,11 @@ class PodcastLibraryService { }).toSet(); } - List getFilteredPodcastsWithMetadata(String? filterText) { + List getFilteredPodcastItems(String? filterText) { final filteredFeedUrls = _getFilteredPodcasts(filterText); - final result = []; + final result = []; for (final feedUrl in filteredFeedUrls) { - final metadata = getPodcastMetadata(feedUrl); + final metadata = getPodcastItem(feedUrl); result.add(metadata); } return result; @@ -123,12 +124,16 @@ class PodcastLibraryService { ); } - PodcastMetadata getPodcastMetadata(String feedUrl) => PodcastMetadata( + Item getPodcastItem(String feedUrl) => Item( feedUrl: feedUrl, - imageUrl: getSubscribedPodcastImage(feedUrl), - name: getSubscribedPodcastName(feedUrl), - artist: getSubscribedPodcastArtist(feedUrl), - genreList: getSubScribedPodcastGenreList(feedUrl), + artworkUrl: getSubscribedPodcastImage(feedUrl), + collectionName: getSubscribedPodcastName(feedUrl), + artistName: getSubscribedPodcastArtist(feedUrl), + genre: + getSubScribedPodcastGenreList( + feedUrl, + )?.mapIndexed((i, e) => Genre(i, e)).toList() ?? + [], ); // Image URL diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 96a4725..8b02f91 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -4,9 +4,13 @@ import 'package:podcast_search/podcast_search.dart'; import '../collection/collection_manager.dart'; import '../common/logging.dart'; import '../extensions/country_x.dart'; +import '../extensions/date_time_x.dart'; +import '../extensions/string_x.dart'; +import '../notifications/notifications_service.dart'; import '../player/data/episode_media.dart'; import '../search/search_manager.dart'; import 'data/podcast_metadata.dart'; +import 'data/podcast_proxy.dart'; import 'podcast_library_service.dart'; import 'podcast_service.dart'; @@ -21,8 +25,10 @@ class PodcastManager { required SearchManager searchManager, required CollectionManager collectionManager, required PodcastLibraryService podcastLibraryService, + required NotificationsService notificationsService, }) : _podcastService = podcastService, - _podcastLibraryService = podcastLibraryService { + _podcastLibraryService = podcastLibraryService, + _notificationsService = notificationsService { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); }; @@ -40,39 +46,183 @@ class PodcastManager { .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); - podcastsCommand = Command.createSync( - (filterText) => - podcastLibraryService.getFilteredPodcastsWithMetadata(filterText), + getSubscribedPodcastsCommand = Command.createSync( + (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), initialValue: [], ); collectionManager.textChangedCommand.listen( - (filterText, sub) => podcastsCommand.run(filterText), + (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) => _podcastService.findEpisodes(item: podcast), - initialValue: [], - ); + checkForUpdatesCommand = + Command.createAsync< + ({String updateMessage, String Function(int) multiUpdateMessage}), + void + >((params) async { + final updatedProxies = []; + + // Check all subscribed podcasts for updates + for (final feedUrl in _podcastLibraryService.podcasts) { + final proxy = getOrCreateProxy(Item(feedUrl: feedUrl)); + final hasUpdate = await _checkForUpdate(proxy); + if (hasUpdate) { + updatedProxies.add(proxy); + } + } + + if (updatedProxies.isNotEmpty) { + final msg = updatedProxies.length == 1 + ? '${params.updateMessage} ${updatedProxies.first.title ?? ''}' + : params.multiUpdateMessage(updatedProxies.length); + await _notificationsService.notify(message: msg); + } + }, initialValue: null); + + togglePodcastSubscriptionCommand = + Command.createUndoableNoResult( + (item, stack) async { + final feedUrl = item.feedUrl; + if (feedUrl == null) return; + + final currentList = getSubscribedPodcastsCommand.value; + final isSubscribed = currentList.any((p) => p.feedUrl == feedUrl); + + // Store operation info for undo + stack.push((wasAdd: !isSubscribed, item: item)); + + // Optimistic update: modify list directly + if (isSubscribed) { + getSubscribedPodcastsCommand.value = currentList + .where((p) => p.feedUrl != feedUrl) + .toList(); + } else { + getSubscribedPodcastsCommand.value = [...currentList, item]; + } + + // Async persist + if (isSubscribed) { + await removePodcast(item); + } else { + await addPodcast(PodcastMetadata.fromItem(item)); + } + }, + undo: (stack, reason) async { + final undoData = stack.pop(); + final currentList = getSubscribedPodcastsCommand.value; - podcastsCommand.run(null); + if (undoData.wasAdd) { + // Was an add, so remove it + getSubscribedPodcastsCommand.value = currentList + .where((p) => p.feedUrl != undoData.item.feedUrl) + .toList(); + } else { + // Was a remove, so add it back + getSubscribedPodcastsCommand.value = [ + ...currentList, + undoData.item, + ]; + } + }, + undoOnExecutionFailure: true, + ); + + getSubscribedPodcastsCommand.run(null); updateSearchCommand.run(null); } final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; + final NotificationsService _notificationsService; + + // Track episodes currently downloading + final activeDownloads = ListNotifier(); + + /// Registers an episode as actively downloading. + /// Called by EpisodeMedia.downloadCommand when download starts. + void registerActiveDownload(EpisodeMedia episode) { + activeDownloads.add(episode); + } + + /// Unregisters an episode from active downloads. + /// Called by EpisodeMedia.downloadCommand on error. + void unregisterActiveDownload(EpisodeMedia episode) { + activeDownloads.remove(episode); + } + + // Proxy cache - each podcast owns its fetchEpisodesCommand + final _proxyCache = {}; + + /// Gets or creates a PodcastProxy for the given Item. + /// The proxy owns the fetchEpisodesCommand. + PodcastProxy getOrCreateProxy(Item item) { + return _proxyCache.putIfAbsent( + item.feedUrl!, + () => PodcastProxy(item: item, podcastService: _podcastService), + ); + } + + /// Checks a single podcast for updates. Returns true if updated. + Future _checkForUpdate(PodcastProxy proxy) async { + final feedUrl = proxy.feedUrl; + final storedTimeStamp = _podcastLibraryService.getPodcastLastUpdated( + feedUrl, + ); + DateTime? feedLastUpdated; + + try { + feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); + } on Exception catch (e) { + printMessageInDebugMode(e); + } + + printMessageInDebugMode('checking update for: ${proxy.title ?? feedUrl}'); + printMessageInDebugMode( + 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', + ); + printMessageInDebugMode( + 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', + ); + + if (feedLastUpdated == null) return false; + + await _podcastLibraryService.addPodcastLastUpdated( + feedUrl: feedUrl, + timestamp: feedLastUpdated.podcastTimeStamp, + ); + + if (storedTimeStamp != null && + !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { + // Clear cached episodes to force refresh + proxy.clearEpisodeCache(); + await proxy.fetchEpisodesCommand.runAsync(); + + await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated); + return true; // Has update + } + return false; + } + late Command updateSearchCommand; - late Command> fetchEpisodeMediaCommand; - late Command> podcastsCommand; + late Command> getSubscribedPodcastsCommand; + late Command< + ({String updateMessage, String Function(int) multiUpdateMessage}), + void + > + checkForUpdatesCommand; + late final Command togglePodcastSubscriptionCommand; Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); - podcastsCommand.run(); + getSubscribedPodcastsCommand.run(); } - Future removePodcast({required String feedUrl}) async { - await _podcastLibraryService.removePodcast(feedUrl); - podcastsCommand.run(); + Future removePodcast(Item item) async { + await _podcastLibraryService.removePodcast(item.feedUrl!); + getSubscribedPodcastsCommand.run(); } + + String? getPodcastDescription(String? feedUrl) => + _proxyCache[feedUrl]?.description; } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 7459097..5000299 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -1,15 +1,11 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:podcast_search/podcast_search.dart'; import '../common/logging.dart'; -import '../extensions/date_time_x.dart'; import '../extensions/podcast_x.dart'; import '../extensions/shared_preferences_x.dart'; -import '../extensions/string_x.dart'; -import '../notifications/notifications_service.dart'; import '../player/data/episode_media.dart'; import '../settings/settings_service.dart'; import 'data/podcast_genre.dart'; @@ -17,15 +13,12 @@ import 'data/simple_language.dart'; import 'podcast_library_service.dart'; class PodcastService { - final NotificationsService _notificationsService; final SettingsService _settingsService; final PodcastLibraryService _libraryService; PodcastService({ - required NotificationsService notificationsService, required SettingsService settingsService, required PodcastLibraryService libraryService, - }) : _notificationsService = notificationsService, - _settingsService = settingsService, + }) : _settingsService = settingsService, _libraryService = libraryService { _search = Search( searchProvider: @@ -83,93 +76,34 @@ class PodcastService { } } - bool _updateLock = false; - - Future checkForUpdates({ - Set? feedUrls, - required String updateMessage, - required String Function(int length) multiUpdateMessage, - }) async { - if (_updateLock) return; - _updateLock = true; - - final newUpdateFeedUrls = {}; - - for (final feedUrl in (feedUrls ?? _libraryService.podcasts)) { - final storedTimeStamp = _libraryService.getPodcastLastUpdated(feedUrl); - DateTime? feedLastUpdated; - try { - feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); - } on Exception catch (e) { - printMessageInDebugMode(e); - } - final name = _libraryService.getSubscribedPodcastName(feedUrl); - - printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); - printMessageInDebugMode( - 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', - ); - printMessageInDebugMode( - 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', - ); - - if (feedLastUpdated == null) continue; - - await _libraryService.addPodcastLastUpdated( - feedUrl: feedUrl, - timestamp: feedLastUpdated.podcastTimeStamp, - ); - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - await findEpisodes(feedUrl: feedUrl, loadFromCache: false); - await _libraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - - newUpdateFeedUrls.add(feedUrl); - } - } - - if (newUpdateFeedUrls.isNotEmpty) { - final msg = newUpdateFeedUrls.length == 1 - ? '$updateMessage${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName != null ? ' ${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName}' : ''}' - : multiUpdateMessage(newUpdateFeedUrls.length); - await _notificationsService.notify(message: msg); - } - - _updateLock = false; - } - - List? getPodcastEpisodesFromCache(String? feedUrl) => - _episodeCache[feedUrl]; - final Map> _episodeCache = {}; - - final Map _podcastDescriptionCache = {}; - String? getPodcastDescriptionFromCache(String? feedUrl) => - _podcastDescriptionCache[feedUrl]; - - Future> findEpisodes({ + // Stateless operation - just fetches podcast and episodes, no caching + Future<({Podcast? podcast, List episodes})> findEpisodes({ Item? item, String? feedUrl, - bool loadFromCache = true, }) async { if (item == null && item?.feedUrl == null && feedUrl == null) { printMessageInDebugMode('findEpisodes called without feedUrl or item'); - return Future.value([]); + return (podcast: null, episodes: []); } final url = feedUrl ?? item!.feedUrl!; - if (_episodeCache.containsKey(url) && loadFromCache) { - if (item?.bestArtworkUrl != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: item!.bestArtworkUrl!, - ); - } - return _episodeCache[url]!; + // Save artwork if available + if (item?.bestArtworkUrl != null) { + _libraryService.addSubscribedPodcastImage( + feedUrl: url, + imageUrl: item!.bestArtworkUrl!, + ); + } + + Podcast? podcast; + try { + podcast = await compute(loadPodcast, url); + } catch (e) { + printMessageInDebugMode('Error loading podcast feed: $e'); + return (podcast: null, episodes: []); } - final Podcast? podcast = await compute(loadPodcast, url); if (podcast?.image != null) { _libraryService.addSubscribedPodcastImage( feedUrl: url, @@ -179,10 +113,7 @@ class PodcastService { final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; - _episodeCache[url] = episodes; - _podcastDescriptionCache[url] = podcast?.description; - - return episodes; + return (podcast: podcast, episodes: episodes); } } diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 1e50f15..b6cb9bb 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -3,47 +3,38 @@ import 'package:flutter_it/flutter_it.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/episode_media.dart'; -import '../../settings/settings_manager.dart'; -import '../data/download_capsule.dart'; -import '../download_manager.dart'; +import '../data/podcast_metadata.dart'; +import '../podcast_manager.dart'; class DownloadButton extends StatelessWidget { - const DownloadButton({ - super.key, - required this.episode, - required this.addPodcast, - }); + const DownloadButton({super.key, required this.episode}); final EpisodeMedia episode; - final void Function()? addPodcast; @override Widget build(BuildContext context) => Stack( alignment: Alignment.center, children: [ _DownloadProgress(episode: episode), - _ProcessDownloadButton(episode: episode, addPodcast: addPodcast), + _ProcessDownloadButton(episode: episode), ], ); } class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { - const _ProcessDownloadButton({required this.episode, this.addPodcast}); + const _ProcessDownloadButton({required this.episode}); final EpisodeMedia episode; - final void Function()? addPodcast; @override Widget build(BuildContext context) { final theme = context.theme; - final isDownloaded = watchPropertyValue( - (DownloadManager m) => m.isDownloaded(episode.url), - ); + final progress = watch(episode.downloadCommand.progress).value; + final isDownloaded = progress == 1.0; + + final isRunning = watch(episode.downloadCommand.isRunning).value; - final downloadsDir = watchValue( - (SettingsManager m) => m.downloadsDirCommand, - ); return IconButton( isSelected: isDownloaded, tooltip: isDownloaded @@ -53,27 +44,25 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { isDownloaded ? Icons.download_done : Icons.download_outlined, color: isDownloaded ? theme.colorScheme.primary : null, ), - onPressed: downloadsDir == null - ? null - : () { - if (isDownloaded) { - di().deleteDownload(media: episode); - } else { - addPodcast?.call(); - di().startOrCancelDownload( - DownloadCapsule( - finishedMessage: context.l10n.downloadFinished( - episode.title ?? '', - ), - canceledMessage: context.l10n.downloadCancelled( - episode.title ?? '', - ), - media: episode, - downloadsDir: downloadsDir, - ), - ); - } - }, + onPressed: () { + if (isDownloaded) { + episode.deleteDownloadCommand.run(); + } else if (isRunning) { + episode.downloadCommand.cancel(); + } else { + // Add podcast to library before downloading + di().addPodcast( + PodcastMetadata( + feedUrl: episode.feedUrl, + imageUrl: episode.albumArtUrl, + name: episode.collectionName, + artist: episode.artist, + genreList: episode.genres, + ), + ); + episode.downloadCommand.run(); + } + }, color: isDownloaded ? theme.colorScheme.primary : theme.colorScheme.onSurface, @@ -82,20 +71,25 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { } class _DownloadProgress extends StatelessWidget with WatchItMixin { - const _DownloadProgress({this.episode}); + const _DownloadProgress({required this.episode}); - final EpisodeMedia? episode; + final EpisodeMedia episode; @override Widget build(BuildContext context) { - final value = watchPropertyValue( - (DownloadManager m) => m.getProgress(episode), - ); + final progress = watch(episode.downloadCommand.progress).value; + final isRunning = watch(episode.downloadCommand.isRunning).value; + + // Show indeterminate spinner when running but no progress yet + // Show determinate progress when we have progress data + // Hide when completed (progress == 1.0) or not running + final showSpinner = isRunning || (progress > 0 && progress < 1.0); + return SizedBox.square( dimension: (context.theme.buttonTheme.height / 2 * 2) - 3, child: CircularProgressIndicator( padding: EdgeInsets.zero, - value: value == null || value == 1.0 ? 0 : value, + value: showSpinner ? (progress > 0 ? progress : null) : 0, backgroundColor: Colors.transparent, ), ); diff --git a/lib/podcasts/view/episode_tile.dart b/lib/podcasts/view/episode_tile.dart index 291f590..d5de30d 100644 --- a/lib/podcasts/view/episode_tile.dart +++ b/lib/podcasts/view/episode_tile.dart @@ -9,8 +9,6 @@ import '../../extensions/duration_x.dart'; import '../../extensions/string_x.dart'; import '../../player/data/episode_media.dart'; import '../../player/player_manager.dart'; -import '../data/podcast_metadata.dart'; -import '../podcast_manager.dart'; import 'download_button.dart'; class EpisodeTile extends StatelessWidget with WatchItMixin { @@ -84,18 +82,7 @@ class EpisodeTile extends StatelessWidget with WatchItMixin { Text( '${episode.creationDateTime!.unixTimeToDateString} · ${episode.duration?.formattedTime ?? 'Unknown duration'}', ), - DownloadButton( - episode: episode, - addPodcast: () => di().addPodcast( - PodcastMetadata( - feedUrl: episode.feedUrl, - imageUrl: podcastImage, - artist: episode.artist ?? '', - name: episode.collectionName ?? '', - genreList: episode.genres, - ), - ), - ), + DownloadButton(episode: episode), ], ), titleTextStyle: theme.textTheme.labelSmall, diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 77be315..aecce5f 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:phoenix_theme/phoenix_theme.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -8,13 +7,12 @@ import '../../common/view/safe_network_image.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; -import '../../player/player_manager.dart'; -import '../download_manager.dart'; -import '../podcast_service.dart'; +import '../podcast_manager.dart'; +import 'podcast_card_play_button.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page.dart'; -class PodcastCard extends StatefulWidget { +class PodcastCard extends StatefulWidget with WatchItStatefulWidgetMixin { const PodcastCard({super.key, required this.podcastItem}); final Item podcastItem; @@ -28,6 +26,28 @@ class _PodcastCardState extends State { @override Widget build(BuildContext context) { + final proxy = createOnce( + () => di().getOrCreateProxy(widget.podcastItem), + ); + + registerHandler( + target: proxy.playEpisodesCommand.results, + handler: (context, CommandResult? result, cancel) { + if (result == null) return; + + if (result.isRunning) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + } else if (result.isSuccess) { + Navigator.of(context).pop(); + } else if (result.hasError) { + Navigator.of(context).pop(); + } + }, + ); final theme = context.theme; final isLight = theme.colorScheme.isLight; const borderRadiusGeometry = BorderRadiusGeometry.only( @@ -94,33 +114,8 @@ class _PodcastCardState extends State { spacing: kBigPadding, mainAxisSize: MainAxisSize.min, children: [ - FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () async { - final res = await showFutureLoadingDialog( - context: context, - future: () async => di() - .findEpisodes(item: widget.podcastItem), - ); - if (res.isValue) { - final episodes = res.asValue!.value; - final withDownloads = episodes.map((e) { - final download = di() - .getDownload(e.id); - if (download != null) { - return e.copyWithX(resource: download); - } - return e; - }).toList(); - if (withDownloads.isNotEmpty) { - await di().setPlaylist( - withDownloads, - index: 0, - ); - } - } - }, - child: const Icon(Icons.play_arrow), + PodcastCardPlayButton( + podcastItem: widget.podcastItem, ), PodcastFavoriteButton.floating( podcastItem: widget.podcastItem, diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart new file mode 100644 index 0000000..d3a9b3c --- /dev/null +++ b/lib/podcasts/view/podcast_card_play_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../podcast_manager.dart'; + +class PodcastCardPlayButton extends StatelessWidget { + const PodcastCardPlayButton({super.key, required this.podcastItem}); + + final Item podcastItem; + + @override + Widget build(BuildContext context) { + return FloatingActionButton.small( + heroTag: 'podcastcardfap', + onPressed: () { + final proxy = di().getOrCreateProxy(podcastItem); + proxy.playEpisodesCommand(0); + }, + child: const Icon(Icons.play_arrow), + ); + } +} diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 9fc8e13..7d26a51 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -1,11 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; -import 'package:podcast_search/podcast_search.dart'; import '../../collection/collection_manager.dart'; import '../../common/view/ui_constants.dart'; -import '../download_manager.dart'; +import '../podcast_library_service.dart'; import '../podcast_manager.dart'; import 'podcast_card.dart'; @@ -14,14 +12,23 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - final feedsWithDownloads = watchPropertyValue( - (DownloadManager m) => m.feedsWithDownloads, - ); + final feedsWithDownloads = + watchStream( + (PodcastLibraryService m) => + m.propertiesChanged.map((_) => m.feedsWithDownloads), + initialValue: di().feedsWithDownloads, + allowStreamChange: true, + preserveState: false, + ).data ?? + {}; + final showOnlyDownloads = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); - return watchValue((PodcastManager m) => m.podcastsCommand.results).toWidget( + return watchValue( + (PodcastManager m) => m.getSubscribedPodcastsCommand.results, + ).toWidget( onData: (pees, _) { final podcasts = showOnlyDownloads ? pees.where((p) => feedsWithDownloads.contains(p.feedUrl)) @@ -32,20 +39,7 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { itemCount: podcasts.length, itemBuilder: (context, index) { final item = podcasts.elementAt(index); - return PodcastCard( - key: ValueKey(item), - podcastItem: Item( - feedUrl: item.feedUrl, - artistName: item.artist, - collectionName: item.name, - artworkUrl: item.imageUrl, - genre: - item.genreList - ?.mapIndexed((i, e) => Genre(i, e)) - .toList() ?? - [], - ), - ); + return PodcastCard(key: ValueKey(item), podcastItem: item); }, ); }, diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index a2e0bdf..b62db8c 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; -import '../data/podcast_metadata.dart'; import '../podcast_manager.dart'; class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @@ -17,33 +16,40 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final isSubscribed = watchValue( - (PodcastManager m) => m.podcastsCommand.select( + (PodcastManager m) => m.getSubscribedPodcastsCommand.select( (podcasts) => podcasts.any((p) => p.feedUrl == podcastItem.feedUrl), ), ); - void onPressed() => isSubscribed - ? di().removePodcast(feedUrl: podcastItem.feedUrl!) - : di().addPodcast( - PodcastMetadata( - feedUrl: podcastItem.feedUrl!, - name: podcastItem.collectionName!, - artist: podcastItem.artistName!, - imageUrl: podcastItem.bestArtworkUrl!, - genreList: - podcastItem.genre?.map((e) => e.name).toList() ?? [], + // Error handler for subscription toggle + registerHandler( + select: (PodcastManager m) => m.togglePodcastSubscriptionCommand.errors, + handler: (context, error, cancel) { + if (error != null && error.error is! UndoException) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update subscription: ${error.error}'), ), ); + } + }, + ); + final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { return FloatingActionButton.small( heroTag: 'favtag', - onPressed: onPressed, + onPressed: () => di().togglePodcastSubscriptionCommand + .run(podcastItem), child: icon, ); } - return IconButton(onPressed: onPressed, icon: icon); + return IconButton( + onPressed: () => di().togglePodcastSubscriptionCommand + .run(podcastItem), + icon: icon, + ); } } diff --git a/lib/podcasts/view/podcast_page.dart b/lib/podcasts/view/podcast_page.dart index e72abaf..19e3957 100644 --- a/lib/podcasts/view/podcast_page.dart +++ b/lib/podcasts/view/podcast_page.dart @@ -14,7 +14,7 @@ import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; import '../../player/view/player_view.dart'; import '../data/podcast_genre.dart'; -import '../podcast_service.dart'; +import '../podcast_manager.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page_episode_list.dart'; import 'recent_downloads_button.dart'; @@ -95,10 +95,9 @@ class _PodcastPageState extends State { wrapInFakeScroll: false, color: Colors.white, text: - di() - .getPodcastDescriptionFromCache( - widget.podcastItem.feedUrl, - ) ?? + di().getPodcastDescription( + widget.podcastItem.feedUrl, + ) ?? '', ), ), diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index c988c4d..3f14b4e 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -4,7 +4,6 @@ import 'package:podcast_search/podcast_search.dart'; import '../../collection/collection_manager.dart'; import '../../player/player_manager.dart'; -import '../download_manager.dart'; import '../podcast_manager.dart'; import 'episode_tile.dart'; @@ -15,24 +14,18 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - callOnce( - (context) => di().fetchEpisodeMediaCommand(podcastItem), - ); + final proxy = di().getOrCreateProxy(podcastItem); + + callOnceAfterThisBuild((_) => proxy.fetchEpisodesCommand.run()); final downloadsOnly = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); - return watchValue( - (PodcastManager m) => m.fetchEpisodeMediaCommand.results, - ).toWidget( + return watch(proxy.fetchEpisodesCommand.results).value.toWidget( onData: (episodesX, param) { final episodes = downloadsOnly - ? episodesX - .where( - (e) => di().getDownload(e.url) != null, - ) - .toList() + ? episodesX.where((e) => e.isDownloaded).toList() : episodesX; return SliverList.builder( itemCount: episodes.length, diff --git a/lib/podcasts/view/recent_downloads_button.dart b/lib/podcasts/view/recent_downloads_button.dart index 445d820..5ab1862 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -4,7 +4,7 @@ import 'package:yaru/yaru.dart'; import '../../extensions/build_context_x.dart'; import '../../player/player_manager.dart'; -import '../download_manager.dart'; +import '../podcast_manager.dart'; import 'download_button.dart'; class RecentDownloadsButton extends StatefulWidget @@ -42,18 +42,12 @@ class _RecentDownloadsButtonState extends State @override Widget build(BuildContext context) { final theme = context.theme; - final episodeToProgress = watchPropertyValue( - (DownloadManager m) => m.episodeToProgress, - ); - final episodeToProgressLength = watchPropertyValue( - (DownloadManager m) => m.episodeToProgress.length, - ); + final activeDownloads = watch(di().activeDownloads).value; - final downloadsInProgress = watchPropertyValue( - (DownloadManager m) => m.getDownloadsInProgress(), - ); + final hasAnyDownloads = activeDownloads.isNotEmpty; + final hasInProgressDownloads = activeDownloads.any((e) => !e.isDownloaded); - if (downloadsInProgress) { + if (hasInProgressDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); } @@ -65,21 +59,19 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: episodeToProgressLength > 0 ? 1.0 : 0.0, + opacity: hasAnyDownloads ? 1.0 : 0.0, child: IconButton( - icon: downloadsInProgress + icon: hasInProgressDownloads ? FadeTransition( opacity: _animation, child: Icon( Icons.download_for_offline, - color: downloadsInProgress - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, + color: theme.colorScheme.primary, ), ) : Icon( Icons.download_for_offline, - color: episodeToProgress.isNotEmpty + color: hasAnyDownloads ? theme.colorScheme.primary : theme.colorScheme.onSurface, ), @@ -98,32 +90,20 @@ class _RecentDownloadsButtonState extends State child: CustomScrollView( slivers: [ SliverList.builder( - itemCount: episodeToProgress.keys.length, - itemBuilder: (context, index) => ListTile( - onTap: () { - final download = di().getDownload( - episodeToProgress.keys.elementAt(index).url, - ); - di().setPlaylist([ - if (download != null) - episodeToProgress.keys - .elementAt(index) - .copyWithX(resource: download), - ]); - }, - title: Text( - episodeToProgress.keys.elementAt(index).title ?? - context.l10n.unknown, - ), - subtitle: Text( - episodeToProgress.keys.elementAt(index).artist ?? - context.l10n.unknown, - ), - trailing: DownloadButton( - episode: episodeToProgress.keys.elementAt(index), - addPodcast: () {}, - ), - ), + itemCount: activeDownloads.length, + itemBuilder: (context, index) { + final episode = activeDownloads[index]; + return ListTile( + onTap: () { + if (episode.isDownloaded) { + di().setPlaylist([episode]); + } + }, + title: Text(episode.title ?? context.l10n.unknown), + subtitle: Text(episode.artist ?? context.l10n.unknown), + trailing: DownloadButton(episode: episode), + ); + }, ), ], ), diff --git a/lib/radio/radio_manager.dart b/lib/radio/radio_manager.dart index 4847001..522270b 100644 --- a/lib/radio/radio_manager.dart +++ b/lib/radio/radio_manager.dart @@ -15,13 +15,13 @@ class RadioManager { }) : _radioLibraryService = radioLibraryService, _radioService = radioService, _collectionManager = collectionManager { - favoriteStationsCommand = Command.createAsync( + getFavoriteStationsCommand = Command.createAsync( _loadFavorites, initialValue: [], ); _collectionManager.textChangedCommand.listen( - (filterText, sub) => favoriteStationsCommand.run(filterText), + (filterText, sub) => getFavoriteStationsCommand.run(filterText), ); searchManager.textChangedCommand @@ -33,14 +33,83 @@ class RadioManager { initialValue: [], ); - favoriteStationsCommand.run(); + toggleFavoriteStationCommand = + Command.createUndoableNoResult< + String, + ({bool wasAdd, StationMedia? media}) + >( + (stationUuid, stack) async { + final currentList = getFavoriteStationsCommand.value; + final isFavorite = currentList.any((s) => s.id == stationUuid); + + // Store operation info for undo + if (isFavorite) { + // Removing: store the station being removed + final stationToRemove = currentList.firstWhere( + (s) => s.id == stationUuid, + ); + stack.push((wasAdd: false, media: stationToRemove)); + + // Optimistic: remove from list + getFavoriteStationsCommand.value = currentList + .where((s) => s.id != stationUuid) + .toList(); + } else { + // Adding: try to get cached media for optimistic update + final cachedStation = StationMedia.getCachedStationMedia( + stationUuid, + ); + stack.push((wasAdd: true, media: cachedStation)); + + // Optimistic: add if we have cached media + if (cachedStation != null) { + getFavoriteStationsCommand.value = [ + ...currentList, + cachedStation, + ]; + } + } + + // Async persist + await (isFavorite + ? _radioLibraryService.removeFavoriteStation(stationUuid) + : _radioLibraryService.addFavoriteStation(stationUuid)); + + // Refresh to ensure consistency (fetches from network if needed) + getFavoriteStationsCommand.run(); + }, + undo: (stack, reason) async { + final undoData = stack.pop(); + final currentList = getFavoriteStationsCommand.value; + + if (undoData.wasAdd) { + // Was an add, so remove it + if (undoData.media != null) { + getFavoriteStationsCommand.value = currentList + .where((s) => s.id != undoData.media!.id) + .toList(); + } + } else { + // Was a remove, so add it back + if (undoData.media != null) { + getFavoriteStationsCommand.value = [ + ...currentList, + undoData.media!, + ]; + } + } + }, + ); + + getFavoriteStationsCommand.run(); } final RadioLibraryService _radioLibraryService; final CollectionManager _collectionManager; final RadioService _radioService; - late Command> favoriteStationsCommand; + late Command> getFavoriteStationsCommand; late Command> updateSearchCommand; + late final Command toggleFavoriteStationCommand; Future> _loadMedia({ String? country, @@ -89,11 +158,11 @@ class RadioManager { Future addFavoriteStation(String stationUuid) async { await _radioLibraryService.addFavoriteStation(stationUuid); - favoriteStationsCommand.run(); + getFavoriteStationsCommand.run(); } Future removeFavoriteStation(String stationUuid) async { await _radioLibraryService.removeFavoriteStation(stationUuid); - favoriteStationsCommand.run(); + getFavoriteStationsCommand.run(); } } diff --git a/lib/radio/view/radio_browser_station_star_button.dart b/lib/radio/view/radio_browser_station_star_button.dart index 576a5df..6f35416 100644 --- a/lib/radio/view/radio_browser_station_star_button.dart +++ b/lib/radio/view/radio_browser_station_star_button.dart @@ -14,14 +14,28 @@ class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final isFavorite = watchValue( - (RadioManager s) => s.favoriteStationsCommand.select( + (RadioManager s) => s.getFavoriteStationsCommand.select( (favorites) => favorites.any((m) => m.id == media.id), ), ); + + // Error handler for favorite toggle + registerHandler( + select: (RadioManager m) => m.toggleFavoriteStationCommand.errors, + handler: (context, error, cancel) { + if (error != null && error.error is! UndoException) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update favorite: ${error.error}'), + ), + ); + } + }, + ); + return IconButton( - onPressed: () => isFavorite - ? di().removeFavoriteStation(media.id) - : di().addFavoriteStation(media.id), + onPressed: () => + di().toggleFavoriteStationCommand.run(media.id), icon: Icon(isFavorite ? YaruIcons.star_filled : YaruIcons.star), ); } @@ -38,16 +52,31 @@ class RadioStationStarButton extends StatelessWidget with WatchItMixin { preserveState: false, ).data; final isFavorite = watchValue( - (RadioManager s) => s.favoriteStationsCommand.select( + (RadioManager s) => s.getFavoriteStationsCommand.select( (favorites) => favorites.any((m) => m.id == currentMedia?.id), ), ); + + // Error handler for favorite toggle + registerHandler( + select: (RadioManager m) => m.toggleFavoriteStationCommand.errors, + handler: (context, error, cancel) { + if (error != null && error.error is! UndoException) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to update favorite: ${error.error}'), + ), + ); + } + }, + ); + return IconButton( onPressed: currentMedia == null ? null - : () => isFavorite - ? di().removeFavoriteStation(currentMedia.id) - : di().addFavoriteStation(currentMedia.id), + : () => di().toggleFavoriteStationCommand.run( + currentMedia.id, + ), icon: Icon(isFavorite ? YaruIcons.star_filled : YaruIcons.star), ); } diff --git a/lib/radio/view/radio_favorites_list.dart b/lib/radio/view/radio_favorites_list.dart index 7fbb50c..fa44913 100644 --- a/lib/radio/view/radio_favorites_list.dart +++ b/lib/radio/view/radio_favorites_list.dart @@ -16,7 +16,7 @@ class RadioFavoritesList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) => watchValue( - (RadioManager s) => s.favoriteStationsCommand.results, + (RadioManager s) => s.getFavoriteStationsCommand.results, ).toWidget( onData: (favorites, _) => ListView.builder( padding: const EdgeInsets.only( @@ -40,7 +40,7 @@ class RadioFavoritesList extends StatelessWidget with WatchItMixin { const Center(child: CircularProgressIndicator.adaptive()), onError: (error, _, _) => RadioHostNotConnectedContent( message: 'Error: $error', - onRetry: di().favoriteStationsCommand.run, + onRetry: di().getFavoriteStationsCommand.run, ), ); } diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index 880dc36..b5f0907 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -16,7 +16,7 @@ import 'common/platforms.dart'; import 'notifications/notifications_service.dart'; import 'online_art/online_art_service.dart'; import 'player/player_manager.dart'; -import 'podcasts/download_manager.dart'; +import 'podcasts/download_service.dart'; import 'podcasts/podcast_library_service.dart'; import 'podcasts/podcast_manager.dart'; import 'podcasts/podcast_service.dart'; @@ -95,7 +95,6 @@ void registerDependencies() { ..registerSingletonWithDependencies( () => PodcastService( libraryService: di(), - notificationsService: di(), settingsService: di(), ), dependsOn: [PodcastLibraryService, SettingsService], @@ -107,6 +106,7 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), podcastLibraryService: di(), + notificationsService: di(), ), dependsOn: [PodcastService], ) @@ -120,8 +120,8 @@ void registerDependencies() { ), dependsOn: [SettingsService], ) - ..registerLazySingleton( - () => DownloadManager( + ..registerLazySingleton( + () => DownloadService( libraryService: di(), dio: di(), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a0194bd..20fc55f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,7 +13,6 @@ import local_notifier import media_kit_libs_macos_video import media_kit_video import package_info_plus -import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import sqflite_darwin @@ -32,7 +31,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index dce2123..59dae1a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,10 +15,9 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS - - package_info_plus (0.0.1): + - objective_c (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter + - package_info_plus (0.0.1): - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS @@ -48,8 +47,8 @@ DEPENDENCIES: - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -76,10 +75,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos media_kit_video: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + objective_c: + :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos shared_preferences_foundation: @@ -106,8 +105,8 @@ SPEC CHECKSUMS: local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + objective_c: ec13431e45ba099cb734eb2829a5c1cd37986cba package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/pubspec.lock b/pubspec.lock index 90954a8..d8f588f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,10 +198,10 @@ packages: dependency: transitive description: name: command_it - sha256: "6198e223b5a4d0e23281fd9514a67d264fb9239eeb3c1372176f49367aa2b198" + sha256: "838052aabbf66e403aee3af195636e580b90c479befda1acec16569a92036f42" url: "https://pub.dev" source: hosted - version: "9.0.2" + version: "9.4.1" convert: dependency: transitive description: @@ -214,10 +214,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+1" crypto: dependency: transitive description: @@ -302,58 +302,58 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" url: "https://pub.dev" source: hosted - version: "10.3.3" + version: "10.3.7" file_selector: dependency: "direct main" description: name: file_selector - sha256: "5f1d15a7f17115038f433d1b0ea57513cc9e29a9d5338d166cb0bef3fa90a7a0" + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: "2db9a2d05f66b49a3b45c4a7c2f040dd5fcd457ca30f39df7cdcf80b8cd7f2d4" + sha256: "51e8fd0446de75e4b62c065b76db2210c704562d072339d333bd89c57a7f8a7c" url: "https://pub.dev" source: hosted - version: "0.5.2+1" + version: "0.5.2+4" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: fc3c3fc567cd9bcae784dfeb98d37c46a8ded9e8757d37ea67e975c399bc14e0 + sha256: "628ec99afd8bb40620b4c8707d5fd5fc9e89d83e9b0b327d471fe5f7bc5fc33f" url: "https://pub.dev" source: hosted - version: "0.5.3+3" + version: "0.5.3+4" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.4" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2" + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" url: "https://pub.dev" source: hosted - version: "0.9.4+5" + version: "0.9.5" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.7.0" file_selector_web: dependency: transitive description: @@ -366,10 +366,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" url: "https://pub.dev" source: hosted - version: "0.9.3+4" + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -424,10 +424,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.33" flutter_tabler_icons: dependency: "direct main" description: @@ -466,10 +466,10 @@ packages: dependency: transitive description: name: get_it - sha256: "84792561b731b6463d053e9761a5236da967c369da10b134b8585a5e18429956" + sha256: "368c1abda38084fb5c0c280bfdd5e4ddb010eaa022ff3e953e8b503f7b334b7d" url: "https://pub.dev" source: hosted - version: "9.0.5" + version: "9.1.0" gsettings: dependency: transitive description: @@ -506,10 +506,10 @@ packages: dependency: transitive description: name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" http_parser: dependency: transitive description: @@ -709,10 +709,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -721,6 +721,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" + url: "https://pub.dev" + source: hosted + version: "9.1.0" octo_image: dependency: transitive description: @@ -781,18 +789,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16 + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.20" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738 + sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba" url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.5.0" path_provider_linux: dependency: transitive description: @@ -930,6 +938,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" quiver: dependency: transitive description: @@ -1038,18 +1054,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.17" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -1195,10 +1211,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: @@ -1235,34 +1251,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.3.6" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1283,10 +1299,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" uuid: dependency: transitive description: @@ -1339,10 +1355,10 @@ packages: dependency: transitive description: name: watch_it - sha256: "98e091d39aab70c57c6d38883ad83b9a22a0e12683e0222391d20d61cda4d250" + sha256: "22ca46b22da37d3c0b8d1ffb59d1f98715509e4ff47312de36459d76f20478ec" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" web: dependency: transitive description: