From a4f822c52906c165fb6cd3f350d9545a4b7ee534 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 03:42:18 -0500 Subject: [PATCH 01/12] refactor: use command_it for download management with progress tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded to command_it 9.4.1 for progress/cancellation support - Each EpisodeMedia now owns its downloadCommand with built-in progress - Added PodcastManager.activeDownloads (ListNotifier) to track active downloads - Renamed DownloadManager → DownloadService (stateless operations only) - Removed state tracking from DownloadService (no ChangeNotifier, no maps) - Replaced messageStream with Command.globalErrors for error notifications - Added isDownloaded getter based on command progress (progress == 1.0) - Factory constructor pattern for EpisodeMedia (single getDownload call) - Episodes automatically use local path when downloaded (no copyWithX needed) - Simplified UI: watch command properties directly Benefits: - Commands own execution state (no separate registry) - Single source of truth for download state - Better type safety (all ValueListenable) - Cleaner UI code (no manual state management) - Real-time progress updates and cancellation support --- ARCHITECTURE_CHANGES.md | 271 ++++++ CLAUDE.md | 354 ++++++++ DOWNLOAD_ARCHITECTURE_ANALYSIS.md | 842 ++++++++++++++++++ lib/app/home.dart | 14 +- lib/player/data/episode_media.dart | 116 ++- lib/podcasts/download_manager.dart | 186 ---- lib/podcasts/download_service.dart | 100 +++ lib/podcasts/podcast_manager.dart | 4 + lib/podcasts/view/download_button.dart | 80 +- lib/podcasts/view/episode_tile.dart | 15 +- lib/podcasts/view/podcast_card.dart | 13 +- .../view/podcast_collection_view.dart | 4 +- .../view/podcast_page_episode_list.dart | 7 +- .../view/recent_downloads_button.dart | 67 +- lib/register_dependencies.dart | 6 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 116 +-- 17 files changed, 1821 insertions(+), 376 deletions(-) create mode 100644 ARCHITECTURE_CHANGES.md create mode 100644 CLAUDE.md create mode 100644 DOWNLOAD_ARCHITECTURE_ANALYSIS.md delete mode 100644 lib/podcasts/download_manager.dart create mode 100644 lib/podcasts/download_service.dart diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md new file mode 100644 index 0000000..da9671a --- /dev/null +++ b/ARCHITECTURE_CHANGES.md @@ -0,0 +1,271 @@ +# 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. 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 + +--- + +## Key Files Changed + +### Core Changes +- **lib/player/data/episode_media.dart** - Added downloadCommand, factory constructor +- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier +- **lib/podcasts/download_service.dart** - Removed state, kept operations only +- **lib/app/home.dart** - Added Command.globalErrors handler + +### UI Updates +- **lib/podcasts/view/download_button.dart** - Watch command progress/isRunning +- **lib/podcasts/view/recent_downloads_button.dart** - Watch activeDownloads +- **lib/podcasts/view/podcast_card.dart** - Simplified (no copyWithX) +- **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) + +--- + +## 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..3d0934b 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -1,17 +1,60 @@ 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( - super.resource, { - super.extras, - super.httpHeaders, - super.start, - super.end, + // Factory constructor that computes download path only once + 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, + }) { + // Call getDownload only once + final downloadPath = di().getDownload(episode.contentUrl); + final wasDownloaded = downloadPath != null; + final effectiveResource = downloadPath ?? resource; + + return EpisodeMedia._( + effectiveResource, + wasDownloaded: wasDownloaded, + 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._( + String resource, { + required bool wasDownloaded, + Map? extras, + Map? httpHeaders, + Duration? start, + Duration? end, required this.episode, required String feedUrl, int? bitRate, @@ -24,8 +67,17 @@ class EpisodeMedia extends UniqueMedia { _albumArtUrl = albumArtUrl, _genres = genres, _collectionName = collectionName, - _artist = artist; - + _artist = artist, + _wasDownloadedOnCreation = wasDownloaded, + super( + resource, + extras: extras, + httpHeaders: httpHeaders, + start: start, + end: end, + ); + + final bool _wasDownloadedOnCreation; final Episode episode; final String _feedUrl; final int? _bitRate; @@ -119,4 +171,52 @@ 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 (progress is 100%) + bool get isDownloaded => downloadCommand.progress.value == 1.0; + + // Download command with progress and cancellation support + late final downloadCommand = _createDownloadCommand(); + + Command _createDownloadCommand() { + final command = + Command.createAsyncNoParamNoResultWithProgress((handle) async { + // 1. Add to active downloads + di().activeDownloads.add(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 + await di().download( + episode: this, + cancelToken: cancelToken, + onProgress: (received, total) { + handle.updateProgress(received / total); + }, + ); + + // 5. Success: remove from active downloads + di().activeDownloads.remove(this); + }, errorFilter: const LocalAndGlobalErrorFilter()) + ..errors.listen((error, subscription) { + // 6. Error handler: remove from active downloads + di().activeDownloads.remove(this); + }); + + // Initialize progress to 1.0 if already downloaded + if (_wasDownloadedOnCreation) { + command.resetProgress(progress: 1.0); + } + + return command; + } } 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_manager.dart b/lib/podcasts/podcast_manager.dart index 96a4725..8cbd4d7 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -62,6 +62,10 @@ class PodcastManager { final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; + + // Track episodes currently downloading + final activeDownloads = ListNotifier(); + late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; late Command> podcastsCommand; diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 1e50f15..939852f 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -3,47 +3,39 @@ 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 '../download_service.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 +45,26 @@ 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) { + di().deleteDownload(media: episode); + episode.downloadCommand.resetProgress(); + } 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 +73,19 @@ 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; + 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: progress > 0 && progress < 1.0 ? progress : null, 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..9a1f686 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -9,7 +9,6 @@ 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_favorite_button.dart'; import 'podcast_page.dart'; @@ -104,17 +103,9 @@ class _PodcastCardState extends State { ); 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) { + if (episodes.isNotEmpty) { await di().setPlaylist( - withDownloads, + episodes, index: 0, ); } diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 9fc8e13..1fb42ff 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -5,7 +5,7 @@ import 'package:podcast_search/podcast_search.dart'; import '../../collection/collection_manager.dart'; import '../../common/view/ui_constants.dart'; -import '../download_manager.dart'; +import '../download_service.dart'; import '../podcast_manager.dart'; import 'podcast_card.dart'; @@ -15,7 +15,7 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final feedsWithDownloads = watchPropertyValue( - (DownloadManager m) => m.feedsWithDownloads, + (DownloadService m) => m.feedsWithDownloads, ); final showOnlyDownloads = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index c988c4d..6a71309 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'; @@ -28,11 +27,7 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { ).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..c5faf35 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,11 @@ 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 = watchValue((PodcastManager m) => m.activeDownloads); - final downloadsInProgress = watchPropertyValue( - (DownloadManager m) => m.getDownloadsInProgress(), - ); + final hasActiveDownloads = activeDownloads.isNotEmpty; - if (downloadsInProgress) { + if (hasActiveDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); } @@ -65,23 +58,19 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: episodeToProgressLength > 0 ? 1.0 : 0.0, + opacity: hasActiveDownloads ? 1.0 : 0.0, child: IconButton( - icon: downloadsInProgress + icon: hasActiveDownloads ? 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 - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, + color: theme.colorScheme.onSurface, ), onPressed: () => showDialog( context: context, @@ -98,32 +87,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/register_dependencies.dart b/lib/register_dependencies.dart index 880dc36..13aab37 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'; @@ -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/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: From 492fdddba62641900f8a0019b21eb2de9755f7a0 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 11:29:50 -0500 Subject: [PATCH 02/12] refactor: move episode cache from PodcastService to PodcastManager Move episode and description caching from PodcastService to PodcastManager to align with architecture pattern where Services are stateless operations and Managers handle state and caching. Changes: - PodcastService.findEpisodes() now returns record with episodes + description - PodcastManager caches both episodes and descriptions - Added PodcastManager.getPodcastDescription() method - Updated UI to use new caching location - Ensures same episode instances returned from cache for command state consistency This completes the service/manager separation - all state is now in managers. --- ARCHITECTURE_CHANGES.md | 69 +++++++++++++++++++++++++++-- lib/podcasts/podcast_manager.dart | 31 +++++++++++-- lib/podcasts/podcast_service.dart | 38 +++++----------- lib/podcasts/view/podcast_card.dart | 3 +- lib/podcasts/view/podcast_page.dart | 9 ++-- 5 files changed, 110 insertions(+), 40 deletions(-) diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md index da9671a..3b886e0 100644 --- a/ARCHITECTURE_CHANGES.md +++ b/ARCHITECTURE_CHANGES.md @@ -172,7 +172,67 @@ bool get isDownloaded => downloadCommand.progress.value == 1.0; --- -### 6. UI Simplification +### 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. UI Simplification **Before**: ```dart @@ -224,20 +284,23 @@ di().setPlaylist([episode]); ### Core Changes - **lib/player/data/episode_media.dart** - Added downloadCommand, factory constructor -- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier +- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier, episode/description caching +- **lib/podcasts/podcast_service.dart** - Now stateless (removed caching, returns record with episodes + description) - **lib/podcasts/download_service.dart** - Removed state, kept operations only - **lib/app/home.dart** - Added Command.globalErrors handler ### UI Updates - **lib/podcasts/view/download_button.dart** - Watch command progress/isRunning - **lib/podcasts/view/recent_downloads_button.dart** - Watch activeDownloads -- **lib/podcasts/view/podcast_card.dart** - Simplified (no copyWithX) +- **lib/podcasts/view/podcast_card.dart** - Destructures record from findEpisodes (no copyWithX) +- **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() --- diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 8cbd4d7..24fc698 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -50,10 +50,26 @@ class PodcastManager { (filterText, sub) => podcastsCommand.run(filterText), ); - fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) => _podcastService.findEpisodes(item: podcast), - initialValue: [], - ); + 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 (no longer caches internally) + final result = await _podcastService.findEpisodes(item: podcast); + + // Cache both episodes and description + _episodeCache[feedUrl] = result.episodes; + _podcastDescriptionCache[feedUrl] = result.description; + + return result.episodes; + }, initialValue: []); podcastsCommand.run(null); @@ -66,6 +82,10 @@ class PodcastManager { // Track episodes currently downloading final activeDownloads = ListNotifier(); + // Episode cache - ensures same instances across app for command state + final _episodeCache = >{}; + final _podcastDescriptionCache = {}; + late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; late Command> podcastsCommand; @@ -79,4 +99,7 @@ class PodcastManager { await _podcastLibraryService.removePodcast(feedUrl); podcastsCommand.run(); } + + String? getPodcastDescription(String? feedUrl) => + _podcastDescriptionCache[feedUrl]; } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 7459097..dc06546 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -122,16 +121,14 @@ class PodcastService { 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}' : ''}' + ? updateMessage : multiUpdateMessage(newUpdateFeedUrls.length); await _notificationsService.notify(message: msg); } @@ -139,34 +136,24 @@ class PodcastService { _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 episodes and description, no caching + Future<({List episodes, String? description})> 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 (episodes: [], description: null); } 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!, + ); } final Podcast? podcast = await compute(loadPodcast, url); @@ -179,10 +166,7 @@ class PodcastService { final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; - _episodeCache[url] = episodes; - _podcastDescriptionCache[url] = podcast?.description; - - return episodes; + return (episodes: episodes, description: podcast?.description); } } diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 9a1f686..e0730b6 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -102,7 +102,8 @@ class _PodcastCardState extends State { .findEpisodes(item: widget.podcastItem), ); if (res.isValue) { - final episodes = res.asValue!.value; + final result = res.asValue!.value; + final episodes = result.episodes; if (episodes.isNotEmpty) { await di().setPlaylist( episodes, 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, + ) ?? '', ), ), From 3d842a493ea5c6b327400f5c2ab9730551387bcc Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 12:37:23 -0500 Subject: [PATCH 03/12] refactor: convert checkForUpdates to command and use watch_it in podcast card Converts checkForUpdates() method to checkForUpdatesCommand to eliminate manual lock management and align with command pattern. Refactors podcast_card.dart to use fetchEpisodeMediaCommand with registerHandler for reactive loading dialog and auto-play, replacing direct service calls with proper watch_it observables. --- ARCHITECTURE_CHANGES.md | 98 ++++++++++++++++++++++++++++- lib/podcasts/podcast_manager.dart | 83 +++++++++++++++++++++++- lib/podcasts/podcast_service.dart | 62 +----------------- lib/podcasts/view/podcast_card.dart | 52 +++++++++------ lib/register_dependencies.dart | 4 +- 5 files changed, 212 insertions(+), 87 deletions(-) diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md index 3b886e0..630bddc 100644 --- a/ARCHITECTURE_CHANGES.md +++ b/ARCHITECTURE_CHANGES.md @@ -232,7 +232,95 @@ String? getPodcastDescription(String? feedUrl) => --- -### 7. UI Simplification +### 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 @@ -284,10 +372,11 @@ di().setPlaylist([episode]); ### Core Changes - **lib/player/data/episode_media.dart** - Added downloadCommand, factory constructor -- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier, episode/description caching -- **lib/podcasts/podcast_service.dart** - Now stateless (removed caching, returns record with episodes + description) +- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier, episode/description caching, checkForUpdatesCommand +- **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** - Watch command progress/isRunning @@ -301,6 +390,9 @@ di().setPlaylist([episode]); - 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) --- diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 24fc698..95e350f 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -4,6 +4,9 @@ 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'; @@ -21,8 +24,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); }; @@ -71,6 +76,72 @@ class PodcastManager { return result.episodes; }, initialValue: []); + checkForUpdatesCommand = + Command.createAsync< + ({ + Set? feedUrls, + String updateMessage, + String Function(int) multiUpdateMessage, + }), + void + >((params) async { + final newUpdateFeedUrls = {}; + + for (final feedUrl + in (params.feedUrls ?? _podcastLibraryService.podcasts)) { + final storedTimeStamp = _podcastLibraryService + .getPodcastLastUpdated(feedUrl); + DateTime? feedLastUpdated; + try { + feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); + } on Exception catch (e) { + printMessageInDebugMode(e); + } + final name = _podcastLibraryService.getSubscribedPodcastName( + feedUrl, + ); + + printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); + printMessageInDebugMode( + 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', + ); + printMessageInDebugMode( + 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', + ); + + if (feedLastUpdated == null) continue; + + await _podcastLibraryService.addPodcastLastUpdated( + feedUrl: feedUrl, + timestamp: feedLastUpdated.podcastTimeStamp, + ); + + if (storedTimeStamp != null && + !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { + // Fetch episodes to refresh cache + await fetchEpisodeMediaCommand.runAsync(Item(feedUrl: feedUrl)); + + await _podcastLibraryService.addPodcastUpdate( + feedUrl, + feedLastUpdated, + ); + newUpdateFeedUrls.add(feedUrl); + } + } + + if (newUpdateFeedUrls.isNotEmpty) { + 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); + podcastsCommand.run(null); updateSearchCommand.run(null); @@ -78,6 +149,7 @@ class PodcastManager { final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; + final NotificationsService _notificationsService; // Track episodes currently downloading final activeDownloads = ListNotifier(); @@ -89,6 +161,15 @@ class PodcastManager { late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; late Command> podcastsCommand; + late Command< + ({ + Set? feedUrls, + String updateMessage, + String Function(int) multiUpdateMessage, + }), + void + > + checkForUpdatesCommand; Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index dc06546..548d9f8 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -4,11 +4,8 @@ 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'; @@ -16,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: @@ -82,60 +76,6 @@ 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 _libraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - newUpdateFeedUrls.add(feedUrl); - } - } - - if (newUpdateFeedUrls.isNotEmpty) { - final msg = newUpdateFeedUrls.length == 1 - ? updateMessage - : multiUpdateMessage(newUpdateFeedUrls.length); - await _notificationsService.notify(message: msg); - } - - _updateLock = false; - } - // Stateless operation - just fetches episodes and description, no caching Future<({List episodes, String? description})> findEpisodes({ Item? item, diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index e0730b6..8b2929d 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'; @@ -9,11 +8,11 @@ import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; import '../../player/player_manager.dart'; -import '../podcast_service.dart'; +import '../podcast_manager.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; @@ -27,6 +26,33 @@ class _PodcastCardState extends State { @override Widget build(BuildContext context) { + // Handle loading dialog and auto-play when episodes are fetched + registerHandler( + select: (PodcastManager m) => m.fetchEpisodeMediaCommand.results, + handler: (context, result, cancel) { + if (result.isRunning) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center( + child: CircularProgressIndicator(), + ), + ); + } else if (result.isSuccess) { + // Dismiss dialog + Navigator.of(context).pop(); + + // Play episodes if available + if (result.data != null && result.data!.isNotEmpty) { + di().setPlaylist(result.data!, index: 0); + } + } else if (result.hasError) { + // Dismiss dialog on error + Navigator.of(context).pop(); + } + }, + ); + final theme = context.theme; final isLight = theme.colorScheme.isLight; const borderRadiusGeometry = BorderRadiusGeometry.only( @@ -95,23 +121,9 @@ class _PodcastCardState extends State { children: [ FloatingActionButton.small( heroTag: 'podcastcardfap', - onPressed: () async { - final res = await showFutureLoadingDialog( - context: context, - future: () async => di() - .findEpisodes(item: widget.podcastItem), - ); - if (res.isValue) { - final result = res.asValue!.value; - final episodes = result.episodes; - if (episodes.isNotEmpty) { - await di().setPlaylist( - episodes, - index: 0, - ); - } - } - }, + onPressed: () => di() + .fetchEpisodeMediaCommand + .run(widget.podcastItem), child: const Icon(Icons.play_arrow), ), PodcastFavoriteButton.floating( diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index 13aab37..c215a39 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -95,7 +95,6 @@ void registerDependencies() { ..registerSingletonWithDependencies( () => PodcastService( libraryService: di(), - notificationsService: di(), settingsService: di(), ), dependsOn: [PodcastLibraryService, SettingsService], @@ -107,8 +106,9 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), podcastLibraryService: di(), + notificationsService: di(), ), - dependsOn: [PodcastService], + dependsOn: [PodcastService, NotificationsService], ) ..registerLazySingleton( () => const ExternalPathService(), From 458f94b05d1a0c37edf118bd1c132f835bbd70e9 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 13:24:37 -0500 Subject: [PATCH 04/12] refactor: add toggle commands and inline command initialization Adds toggle commands for podcast subscriptions and radio favorites to eliminate direct async calls from UI. Inlines command initialization using immediately invoked functions instead of factory methods for consistency with command pattern conventions. Changes: - Add togglePodcastCommand to PodcastManager (passes Item object) - Add toggleFavoriteStationCommand to RadioManager (passes station ID) - Add deleteDownloadCommand to EpisodeMedia - Inline downloadCommand and deleteDownloadCommand initialization - Update all UI components to use .run() instead of direct async calls --- ARCHITECTURE_CHANGES.md | 103 +++++++++++++++++- lib/player/data/episode_media.dart | 18 ++- lib/podcasts/podcast_manager.dart | 22 ++++ lib/podcasts/view/download_button.dart | 4 +- .../view/podcast_favorite_button.dart | 22 ++-- lib/radio/radio_manager.dart | 16 +++ .../radio_browser_station_star_button.dart | 11 +- 7 files changed, 164 insertions(+), 32 deletions(-) diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md index 630bddc..5c54eb8 100644 --- a/ARCHITECTURE_CHANGES.md +++ b/ARCHITECTURE_CHANGES.md @@ -368,20 +368,115 @@ di().setPlaylist([episode]); --- +### 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, factory constructor -- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier, episode/description caching, checkForUpdatesCommand +- **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** - Watch command progress/isRunning +- **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** - Destructures record from findEpisodes (no copyWithX) +- **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 diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 3d0934b..a4857d4 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -176,9 +176,7 @@ class EpisodeMedia extends UniqueMedia { bool get isDownloaded => downloadCommand.progress.value == 1.0; // Download command with progress and cancellation support - late final downloadCommand = _createDownloadCommand(); - - Command _createDownloadCommand() { + late final downloadCommand = (() { final command = Command.createAsyncNoParamNoResultWithProgress((handle) async { // 1. Add to active downloads @@ -218,5 +216,17 @@ class EpisodeMedia extends UniqueMedia { } return command; - } + })(); + + // Delete download command + late final deleteDownloadCommand = Command.createAsyncNoParamNoResult( + () async { + // Delete the download + await di().deleteDownload(media: this); + + // Reset download progress to 0.0 + downloadCommand.resetProgress(progress: 0.0); + }, + errorFilter: const LocalAndGlobalErrorFilter(), + ); } diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 95e350f..279950c 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -142,6 +142,27 @@ class PodcastManager { } }, initialValue: null); + togglePodcastCommand = Command.createAsync((item) async { + final feedUrl = item.feedUrl; + if (feedUrl == null) return; + + // Check if already subscribed + final isSubscribed = _podcastLibraryService.podcasts.contains(feedUrl); + + if (isSubscribed) { + await removePodcast(feedUrl: feedUrl); + } else { + // Extract metadata from Item + await addPodcast( + PodcastMetadata( + feedUrl: feedUrl, + name: item.collectionName, + imageUrl: item.bestArtworkUrl, + ), + ); + } + }, initialValue: null); + podcastsCommand.run(null); updateSearchCommand.run(null); @@ -170,6 +191,7 @@ class PodcastManager { void > checkForUpdatesCommand; + late Command togglePodcastCommand; Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 939852f..d96f52f 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -4,7 +4,6 @@ import 'package:flutter_it/flutter_it.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/episode_media.dart'; import '../data/podcast_metadata.dart'; -import '../download_service.dart'; import '../podcast_manager.dart'; class DownloadButton extends StatelessWidget { @@ -47,8 +46,7 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { ), onPressed: () { if (isDownloaded) { - di().deleteDownload(media: episode); - episode.downloadCommand.resetProgress(); + episode.deleteDownloadCommand.run(); } else if (isRunning) { episode.downloadCommand.cancel(); } else { diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index a2e0bdf..c1ffb3b 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 { @@ -22,28 +21,21 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { ), ); - 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() ?? [], - ), - ); final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { return FloatingActionButton.small( heroTag: 'favtag', - onPressed: onPressed, + onPressed: () => + di().togglePodcastCommand.run(podcastItem), child: icon, ); } - return IconButton(onPressed: onPressed, icon: icon); + return IconButton( + onPressed: () => + di().togglePodcastCommand.run(podcastItem), + icon: icon, + ); } } diff --git a/lib/radio/radio_manager.dart b/lib/radio/radio_manager.dart index 4847001..ec51086 100644 --- a/lib/radio/radio_manager.dart +++ b/lib/radio/radio_manager.dart @@ -33,6 +33,21 @@ class RadioManager { initialValue: [], ); + toggleFavoriteStationCommand = Command.createAsync(( + stationUuid, + ) async { + // Check if station is already a favorite + final isFavorite = _radioLibraryService.favoriteStations.contains( + stationUuid, + ); + + if (isFavorite) { + await removeFavoriteStation(stationUuid); + } else { + await addFavoriteStation(stationUuid); + } + }, initialValue: null); + favoriteStationsCommand.run(); } @@ -41,6 +56,7 @@ class RadioManager { final RadioService _radioService; late Command> favoriteStationsCommand; late Command> updateSearchCommand; + late Command toggleFavoriteStationCommand; Future> _loadMedia({ String? country, diff --git a/lib/radio/view/radio_browser_station_star_button.dart b/lib/radio/view/radio_browser_station_star_button.dart index 576a5df..a698619 100644 --- a/lib/radio/view/radio_browser_station_star_button.dart +++ b/lib/radio/view/radio_browser_station_star_button.dart @@ -19,9 +19,8 @@ class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { ), ); 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), ); } @@ -45,9 +44,9 @@ class RadioStationStarButton extends StatelessWidget with WatchItMixin { 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), ); } From a6b0eb5ceab41cd553aab7272dbf7bce90a50364 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 15:10:01 -0500 Subject: [PATCH 05/12] Rename favoriteStationsCommand to getFavoriteStationsCommand - Consistent naming with getSubscribedPodcastsCommand - Remove redundant undoOnExecutionFailure: true (default) --- lib/radio/radio_manager.dart | 95 +++++++++++++++---- .../radio_browser_station_star_button.dart | 34 ++++++- lib/radio/view/radio_favorites_list.dart | 4 +- 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/lib/radio/radio_manager.dart b/lib/radio/radio_manager.dart index ec51086..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,30 +33,83 @@ class RadioManager { initialValue: [], ); - toggleFavoriteStationCommand = Command.createAsync(( - stationUuid, - ) async { - // Check if station is already a favorite - final isFavorite = _radioLibraryService.favoriteStations.contains( - stationUuid, - ); - - if (isFavorite) { - await removeFavoriteStation(stationUuid); - } else { - await addFavoriteStation(stationUuid); - } - }, initialValue: null); + 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!, + ]; + } + } + }, + ); - favoriteStationsCommand.run(); + getFavoriteStationsCommand.run(); } final RadioLibraryService _radioLibraryService; final CollectionManager _collectionManager; final RadioService _radioService; - late Command> favoriteStationsCommand; + late Command> getFavoriteStationsCommand; late Command> updateSearchCommand; - late Command toggleFavoriteStationCommand; + late final Command toggleFavoriteStationCommand; Future> _loadMedia({ String? country, @@ -105,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 a698619..6f35416 100644 --- a/lib/radio/view/radio_browser_station_star_button.dart +++ b/lib/radio/view/radio_browser_station_star_button.dart @@ -14,10 +14,25 @@ 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: () => di().toggleFavoriteStationCommand.run(media.id), @@ -37,10 +52,25 @@ 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 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, ), ); } From 0649bfa9df879cc322044c966a5529c1883937ae Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 15:19:29 -0500 Subject: [PATCH 06/12] Rename podcast commands and add undoable subscription toggle - Rename podcastsCommand to getSubscribedPodcastsCommand - Rename togglePodcastCommand to togglePodcastSubscriptionCommand - Make togglePodcastSubscriptionCommand undoable with optimistic updates - Add error handler for subscription toggle in PodcastFavoriteButton - Add optimistic delete for deleteDownloadCommand with rollback on error --- lib/player/data/episode_media.dart | 26 +++--- lib/podcasts/podcast_manager.dart | 86 +++++++++++++------ lib/podcasts/view/podcast_card.dart | 4 +- .../view/podcast_collection_view.dart | 4 +- .../view/podcast_favorite_button.dart | 24 ++++-- 5 files changed, 98 insertions(+), 46 deletions(-) diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index a4857d4..4260610 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -218,15 +218,19 @@ class EpisodeMedia extends UniqueMedia { return command; })(); - // Delete download command - late final deleteDownloadCommand = Command.createAsyncNoParamNoResult( - () async { - // Delete the download - await di().deleteDownload(media: this); - - // Reset download progress to 0.0 - downloadCommand.resetProgress(progress: 0.0); - }, - errorFilter: const LocalAndGlobalErrorFilter(), - ); + // Delete download command with optimistic update + late final deleteDownloadCommand = + Command.createAsyncNoParamNoResult(() async { + // Optimistic: reset progress immediately for instant UI feedback + downloadCommand.resetProgress(progress: 0.0); + + // Then delete async + await di().deleteDownload(media: this); + }, errorFilter: const LocalAndGlobalErrorFilter()) + ..errors.listen((error, _) { + // Simple rollback: restore progress on error + if (error != null) { + downloadCommand.resetProgress(progress: 1.0); + } + }); } diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 279950c..f23c454 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -45,14 +45,14 @@ class PodcastManager { .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); - podcastsCommand = Command.createSync( + getSubscribedPodcastsCommand = Command.createSync( (filterText) => podcastLibraryService.getFilteredPodcastsWithMetadata(filterText), initialValue: [], ); collectionManager.textChangedCommand.listen( - (filterText, sub) => podcastsCommand.run(filterText), + (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); fetchEpisodeMediaCommand = Command.createAsync>(( @@ -142,28 +142,62 @@ class PodcastManager { } }, initialValue: null); - togglePodcastCommand = Command.createAsync((item) async { - final feedUrl = item.feedUrl; - if (feedUrl == null) return; - - // Check if already subscribed - final isSubscribed = _podcastLibraryService.podcasts.contains(feedUrl); - - if (isSubscribed) { - await removePodcast(feedUrl: feedUrl); - } else { - // Extract metadata from Item - await addPodcast( - PodcastMetadata( - feedUrl: feedUrl, - name: item.collectionName, - imageUrl: item.bestArtworkUrl, - ), + togglePodcastSubscriptionCommand = + Command.createUndoableNoResult< + Item, + ({bool wasAdd, PodcastMetadata metadata}) + >( + (item, stack) async { + final feedUrl = item.feedUrl; + if (feedUrl == null) return; + + final currentList = getSubscribedPodcastsCommand.value; + final isSubscribed = currentList.any((p) => p.feedUrl == feedUrl); + + final metadata = PodcastMetadata( + feedUrl: feedUrl, + name: item.collectionName, + imageUrl: item.bestArtworkUrl, + ); + + // Store operation info for undo + stack.push((wasAdd: !isSubscribed, metadata: metadata)); + + // Optimistic update: modify list directly + if (isSubscribed) { + getSubscribedPodcastsCommand.value = currentList + .where((p) => p.feedUrl != feedUrl) + .toList(); + } else { + getSubscribedPodcastsCommand.value = [...currentList, metadata]; + } + + // Async persist + await (isSubscribed + ? _podcastLibraryService.removePodcast(feedUrl) + : _podcastLibraryService.addPodcast(metadata)); + }, + undo: (stack, reason) async { + final undoData = stack.pop(); + final currentList = getSubscribedPodcastsCommand.value; + + if (undoData.wasAdd) { + // Was an add, so remove it + getSubscribedPodcastsCommand.value = currentList + .where((p) => p.feedUrl != undoData.metadata.feedUrl) + .toList(); + } else { + // Was a remove, so add it back + getSubscribedPodcastsCommand.value = [ + ...currentList, + undoData.metadata, + ]; + } + }, + undoOnExecutionFailure: true, ); - } - }, initialValue: null); - podcastsCommand.run(null); + getSubscribedPodcastsCommand.run(null); updateSearchCommand.run(null); } @@ -181,7 +215,7 @@ class PodcastManager { late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; - late Command> podcastsCommand; + late Command> getSubscribedPodcastsCommand; late Command< ({ Set? feedUrls, @@ -191,16 +225,16 @@ class PodcastManager { void > checkForUpdatesCommand; - late Command togglePodcastCommand; + 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(); + getSubscribedPodcastsCommand.run(); } String? getPodcastDescription(String? feedUrl) => diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 8b2929d..6fe5105 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -34,9 +34,7 @@ class _PodcastCardState extends State { showDialog( context: context, barrierDismissible: false, - builder: (_) => const Center( - child: CircularProgressIndicator(), - ), + builder: (_) => const Center(child: CircularProgressIndicator()), ); } else if (result.isSuccess) { // Dismiss dialog diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 1fb42ff..36c78dc 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -21,7 +21,9 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { (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)) diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index c1ffb3b..b62db8c 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -16,25 +16,39 @@ 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), ), ); + // 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: () => - di().togglePodcastCommand.run(podcastItem), + onPressed: () => di().togglePodcastSubscriptionCommand + .run(podcastItem), child: icon, ); } return IconButton( - onPressed: () => - di().togglePodcastCommand.run(podcastItem), + onPressed: () => di().togglePodcastSubscriptionCommand + .run(podcastItem), icon: icon, ); } From 8625373772080f92d2ebbdb38bd4ddf2b498fd32 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 24 Nov 2025 22:35:43 +0100 Subject: [PATCH 07/12] refactor: post fix - only play on play button - fix registration - fix linter - callOnceAfterThisBuild to avoid marked dirty error - fix infinite progress loop --- lib/player/data/episode_media.dart | 19 +++----- lib/podcasts/podcast_manager.dart | 43 ++++++++++--------- lib/podcasts/view/download_button.dart | 2 +- lib/podcasts/view/podcast_card.dart | 36 ++-------------- .../view/podcast_card_play_button.dart | 30 +++++++++++++ .../view/podcast_page_episode_list.dart | 2 +- lib/register_dependencies.dart | 2 +- macos/Podfile.lock | 13 +++--- 8 files changed, 71 insertions(+), 76 deletions(-) create mode 100644 lib/podcasts/view/podcast_card_play_button.dart diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 4260610..6ce6f95 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -49,12 +49,12 @@ class EpisodeMedia extends UniqueMedia { // Private constructor that receives pre-computed values EpisodeMedia._( - String resource, { + super.resource, { required bool wasDownloaded, - Map? extras, - Map? httpHeaders, - Duration? start, - Duration? end, + super.extras, + super.httpHeaders, + super.start, + super.end, required this.episode, required String feedUrl, int? bitRate, @@ -68,14 +68,7 @@ class EpisodeMedia extends UniqueMedia { _genres = genres, _collectionName = collectionName, _artist = artist, - _wasDownloadedOnCreation = wasDownloaded, - super( - resource, - extras: extras, - httpHeaders: httpHeaders, - start: start, - end: end, - ); + _wasDownloadedOnCreation = wasDownloaded; final bool _wasDownloadedOnCreation; final Episode episode; diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index f23c454..85f81f7 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -55,26 +55,10 @@ class PodcastManager { (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - 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 (no longer caches internally) - final result = await _podcastService.findEpisodes(item: podcast); - - // Cache both episodes and description - _episodeCache[feedUrl] = result.episodes; - _podcastDescriptionCache[feedUrl] = result.description; - - return result.episodes; - }, initialValue: []); + fetchEpisodeMediaCommand = Command.createAsync>( + fetchEpisodes, + initialValue: [], + ); checkForUpdatesCommand = Command.createAsync< @@ -202,6 +186,25 @@ class PodcastManager { updateSearchCommand.run(null); } + Future> fetchEpisodes(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 (no longer caches internally) + final result = await _podcastService.findEpisodes(item: podcast); + + // Cache both episodes and description + _episodeCache[feedUrl] = result.episodes; + _podcastDescriptionCache[feedUrl] = result.description; + + return result.episodes; + } + final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; final NotificationsService _notificationsService; diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index d96f52f..28c3694 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -83,7 +83,7 @@ class _DownloadProgress extends StatelessWidget with WatchItMixin { dimension: (context.theme.buttonTheme.height / 2 * 2) - 3, child: CircularProgressIndicator( padding: EdgeInsets.zero, - value: progress > 0 && progress < 1.0 ? progress : null, + value: progress == 1.0 ? 0 : progress, backgroundColor: Colors.transparent, ), ); diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 6fe5105..70204fe 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -7,8 +7,7 @@ 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 '../podcast_manager.dart'; +import 'podcast_card_play_button.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page.dart'; @@ -26,31 +25,6 @@ class _PodcastCardState extends State { @override Widget build(BuildContext context) { - // Handle loading dialog and auto-play when episodes are fetched - registerHandler( - select: (PodcastManager m) => m.fetchEpisodeMediaCommand.results, - handler: (context, result, cancel) { - if (result.isRunning) { - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), - ); - } else if (result.isSuccess) { - // Dismiss dialog - Navigator.of(context).pop(); - - // Play episodes if available - if (result.data != null && result.data!.isNotEmpty) { - di().setPlaylist(result.data!, index: 0); - } - } else if (result.hasError) { - // Dismiss dialog on error - Navigator.of(context).pop(); - } - }, - ); - final theme = context.theme; final isLight = theme.colorScheme.isLight; const borderRadiusGeometry = BorderRadiusGeometry.only( @@ -117,12 +91,8 @@ class _PodcastCardState extends State { spacing: kBigPadding, mainAxisSize: MainAxisSize.min, children: [ - FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () => di() - .fetchEpisodeMediaCommand - .run(widget.podcastItem), - 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..c984e04 --- /dev/null +++ b/lib/podcasts/view/podcast_card_play_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../../extensions/build_context_x.dart'; +import '../../player/player_manager.dart'; +import '../podcast_manager.dart'; + +class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { + const PodcastCardPlayButton({super.key, required this.podcastItem}); + + final Item podcastItem; + + @override + Widget build(BuildContext context) => FloatingActionButton.small( + heroTag: 'podcastcardfap', + onPressed: () => + showFutureLoadingDialog( + context: context, + title: context.l10n.loadingPodcastFeed, + future: () => di().fetchEpisodes(podcastItem), + ).then((result) { + if (result.isValue) { + di().setPlaylist(result.asValue!.value); + } + }), + child: const Icon(Icons.play_arrow), + ); +} diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index 6a71309..8dfec50 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -14,7 +14,7 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - callOnce( + callOnceAfterThisBuild( (context) => di().fetchEpisodeMediaCommand(podcastItem), ); diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index c215a39..b5f0907 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -108,7 +108,7 @@ void registerDependencies() { podcastLibraryService: di(), notificationsService: di(), ), - dependsOn: [PodcastService, NotificationsService], + dependsOn: [PodcastService], ) ..registerLazySingleton( () => const ExternalPathService(), 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 From 757b02cf78813e599a3bf66d540d95b0537f75e2 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 20:33:58 -0500 Subject: [PATCH 08/12] refactor: use registerHandler in play button for scoped command handling Replace showFutureLoadingDialog with registerHandler pattern in PodcastCardPlayButton. This scopes the auto-play behavior to the button widget - when PodcastPageEpisodeList fetches episodes via the same command, the play handler won't fire since the button is in a different widget tree. Also inline fetchEpisodes logic back into fetchEpisodeMediaCommand. --- lib/podcasts/podcast_manager.dart | 43 +++++++++--------- .../view/podcast_card_play_button.dart | 44 ++++++++++++------- 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 85f81f7..f23c454 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -55,10 +55,26 @@ class PodcastManager { (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - fetchEpisodeMediaCommand = Command.createAsync>( - fetchEpisodes, - initialValue: [], - ); + 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 (no longer caches internally) + final result = await _podcastService.findEpisodes(item: podcast); + + // Cache both episodes and description + _episodeCache[feedUrl] = result.episodes; + _podcastDescriptionCache[feedUrl] = result.description; + + return result.episodes; + }, initialValue: []); checkForUpdatesCommand = Command.createAsync< @@ -186,25 +202,6 @@ class PodcastManager { updateSearchCommand.run(null); } - Future> fetchEpisodes(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 (no longer caches internally) - final result = await _podcastService.findEpisodes(item: podcast); - - // Cache both episodes and description - _episodeCache[feedUrl] = result.episodes; - _podcastDescriptionCache[feedUrl] = result.description; - - return result.episodes; - } - final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; final NotificationsService _notificationsService; diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart index c984e04..4d1c8f6 100644 --- a/lib/podcasts/view/podcast_card_play_button.dart +++ b/lib/podcasts/view/podcast_card_play_button.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:podcast_search/podcast_search.dart'; -import '../../extensions/build_context_x.dart'; import '../../player/player_manager.dart'; import '../podcast_manager.dart'; @@ -13,18 +11,34 @@ class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { final Item podcastItem; @override - Widget build(BuildContext context) => FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () => - showFutureLoadingDialog( - context: context, - title: context.l10n.loadingPodcastFeed, - future: () => di().fetchEpisodes(podcastItem), - ).then((result) { - if (result.isValue) { - di().setPlaylist(result.asValue!.value); + Widget build(BuildContext context) { + // Handler only exists while this button is mounted - won't fire when + // PodcastPageEpisodeList fetches episodes (different widget tree) + registerHandler( + select: (PodcastManager m) => m.fetchEpisodeMediaCommand.results, + handler: (context, result, cancel) { + if (result.isRunning) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + } else if (result.isSuccess) { + Navigator.of(context).pop(); + if (result.data != null && result.data!.isNotEmpty) { + di().setPlaylist(result.data!, index: 0); } - }), - child: const Icon(Icons.play_arrow), - ); + } else if (result.hasError) { + Navigator.of(context).pop(); + } + }, + ); + + return FloatingActionButton.small( + heroTag: 'podcastcardfap', + onPressed: () => + di().fetchEpisodeMediaCommand.run(podcastItem), + child: const Icon(Icons.play_arrow), + ); + } } From ef87e23a29d999767f55a1f06e995f388bfa0799 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 20:48:46 -0500 Subject: [PATCH 09/12] fix: keep completed downloads visible in Recent Downloads dialog Regression fix: After refactoring to command-based downloads, finished downloads were immediately removed from activeDownloads, causing them to disappear from the Recent Downloads dialog. Changes: - episode_media.dart: Don't remove from activeDownloads on success (only remove on error) - recent_downloads_button.dart: Distinguish between hasAnyDownloads (button visibility) and hasInProgressDownloads (animation) This restores the original behavior where completed downloads remain visible until the user deletes them or starts a new session. --- lib/player/data/episode_media.dart | 4 ++-- lib/podcasts/view/recent_downloads_button.dart | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 6ce6f95..e121232 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -195,8 +195,8 @@ class EpisodeMedia extends UniqueMedia { }, ); - // 5. Success: remove from active downloads - di().activeDownloads.remove(this); + // 5. Success: 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) { // 6. Error handler: remove from active downloads diff --git a/lib/podcasts/view/recent_downloads_button.dart b/lib/podcasts/view/recent_downloads_button.dart index c5faf35..d7ecd67 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -44,9 +44,10 @@ class _RecentDownloadsButtonState extends State final theme = context.theme; final activeDownloads = watchValue((PodcastManager m) => m.activeDownloads); - final hasActiveDownloads = activeDownloads.isNotEmpty; + final hasAnyDownloads = activeDownloads.isNotEmpty; + final hasInProgressDownloads = activeDownloads.any((e) => !e.isDownloaded); - if (hasActiveDownloads) { + if (hasInProgressDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); } @@ -58,9 +59,9 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: hasActiveDownloads ? 1.0 : 0.0, + opacity: hasAnyDownloads ? 1.0 : 0.0, child: IconButton( - icon: hasActiveDownloads + icon: hasInProgressDownloads ? FadeTransition( opacity: _animation, child: Icon( @@ -70,7 +71,9 @@ class _RecentDownloadsButtonState extends State ) : Icon( Icons.download_for_offline, - color: theme.colorScheme.onSurface, + color: hasAnyDownloads + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, ), onPressed: () => showDialog( context: context, From bbdcb04e12546f705338e41cbf35c4bd06e6bd06 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 21:13:36 -0500 Subject: [PATCH 10/12] refactor: encapsulate activeDownloads manipulation behind methods Add registerActiveDownload/unregisterActiveDownload methods to PodcastManager instead of letting EpisodeMedia directly manipulate the list. This improves encapsulation and makes future changes easier. --- lib/player/data/episode_media.dart | 4 ++-- lib/podcasts/podcast_manager.dart | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index e121232..3eaec33 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -173,7 +173,7 @@ class EpisodeMedia extends UniqueMedia { final command = Command.createAsyncNoParamNoResultWithProgress((handle) async { // 1. Add to active downloads - di().activeDownloads.add(this); + di().registerActiveDownload(this); // 2. Create CancelToken final cancelToken = CancelToken(); @@ -200,7 +200,7 @@ class EpisodeMedia extends UniqueMedia { }, errorFilter: const LocalAndGlobalErrorFilter()) ..errors.listen((error, subscription) { // 6. Error handler: remove from active downloads - di().activeDownloads.remove(this); + di().unregisterActiveDownload(this); }); // Initialize progress to 1.0 if already downloaded diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index f23c454..0bc43db 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -209,6 +209,18 @@ class PodcastManager { // 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); + } + // Episode cache - ensures same instances across app for command state final _episodeCache = >{}; final _podcastDescriptionCache = {}; From e1df8ef109b346fdf51ee308dbe5f6c0e8f7722d Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Tue, 25 Nov 2025 01:15:36 -0500 Subject: [PATCH 11/12] refactor: introduce PodcastProxy with OOP command ownership - Add PodcastProxy class that wraps Item and owns fetchEpisodesCommand and playEpisodesCommand (fetch-if-needed then play) - Replace two caches in PodcastManager with single proxy cache - Move checkForUpdate logic from proxy to manager as async function - Add mutable downloadPath to EpisodeMedia for proper download playback - PlayerManager.setPlaylist now uses downloadPath when available - Fix RecentDownloadsButton to properly watch ListNotifier - Fix download button to show spinner immediately when isRunning - Add error handling for malformed RSS feeds in podcast_service --- lib/player/data/episode_media.dart | 43 +++-- lib/player/player_manager.dart | 12 +- lib/podcasts/data/podcast_proxy.dart | 59 +++++++ lib/podcasts/podcast_manager.dart | 154 ++++++++---------- lib/podcasts/podcast_service.dart | 17 +- lib/podcasts/view/download_button.dart | 8 +- .../view/podcast_card_play_button.dart | 17 +- .../view/podcast_page_episode_list.dart | 10 +- .../view/recent_downloads_button.dart | 2 +- 9 files changed, 194 insertions(+), 128 deletions(-) create mode 100644 lib/podcasts/data/podcast_proxy.dart diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 3eaec33..7249fb2 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -10,7 +10,7 @@ import '../../podcasts/podcast_manager.dart'; import 'unique_media.dart'; class EpisodeMedia extends UniqueMedia { - // Factory constructor that computes download path only once + // Factory constructor that checks for persisted downloads factory EpisodeMedia( String resource, { Map? extras, @@ -25,14 +25,12 @@ class EpisodeMedia extends UniqueMedia { String? collectionName, String? artist, }) { - // Call getDownload only once + // Check if episode was previously downloaded (persisted in SharedPreferences) final downloadPath = di().getDownload(episode.contentUrl); - final wasDownloaded = downloadPath != null; - final effectiveResource = downloadPath ?? resource; return EpisodeMedia._( - effectiveResource, - wasDownloaded: wasDownloaded, + resource, // Always use original URL as resource + downloadPath: downloadPath, extras: extras, httpHeaders: httpHeaders, start: start, @@ -50,7 +48,7 @@ class EpisodeMedia extends UniqueMedia { // Private constructor that receives pre-computed values EpisodeMedia._( super.resource, { - required bool wasDownloaded, + String? downloadPath, super.extras, super.httpHeaders, super.start, @@ -68,9 +66,12 @@ class EpisodeMedia extends UniqueMedia { _genres = genres, _collectionName = collectionName, _artist = artist, - _wasDownloadedOnCreation = wasDownloaded; + _downloadPath = downloadPath; - final bool _wasDownloadedOnCreation; + /// 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; @@ -165,8 +166,8 @@ class EpisodeMedia extends UniqueMedia { .replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); } - /// Returns true if this episode has been downloaded (progress is 100%) - bool get isDownloaded => downloadCommand.progress.value == 1.0; + /// Returns true if this episode has been downloaded + bool get isDownloaded => _downloadPath != null; // Download command with progress and cancellation support late final downloadCommand = (() { @@ -187,7 +188,7 @@ class EpisodeMedia extends UniqueMedia { }); // 4. Download with progress updates - await di().download( + final path = await di().download( episode: this, cancelToken: cancelToken, onProgress: (received, total) { @@ -195,33 +196,39 @@ class EpisodeMedia extends UniqueMedia { }, ); - // 5. Success: keep in active downloads so user can see completed downloads + // 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) { - // 6. Error handler: remove from active downloads + // Error handler: remove from active downloads di().unregisterActiveDownload(this); }); // Initialize progress to 1.0 if already downloaded - if (_wasDownloadedOnCreation) { + if (_downloadPath != null) { command.resetProgress(progress: 1.0); } return command; })(); - // Delete download command with optimistic update + // 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); - // Then delete async + // Delete async await di().deleteDownload(media: this); + + // Clear downloadPath only after successful delete + _downloadPath = null; }, errorFilter: const LocalAndGlobalErrorFilter()) ..errors.listen((error, _) { - // Simple rollback: restore progress on 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_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/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 0bc43db..1825b8f 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -10,6 +10,7 @@ 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'; @@ -55,89 +56,26 @@ class PodcastManager { (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - 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 (no longer caches internally) - final result = await _podcastService.findEpisodes(item: podcast); - - // Cache both episodes and description - _episodeCache[feedUrl] = result.episodes; - _podcastDescriptionCache[feedUrl] = result.description; - - return result.episodes; - }, initialValue: []); - checkForUpdatesCommand = Command.createAsync< - ({ - Set? feedUrls, - String updateMessage, - String Function(int) multiUpdateMessage, - }), + ({String updateMessage, String Function(int) multiUpdateMessage}), void >((params) async { - final newUpdateFeedUrls = {}; - - for (final feedUrl - in (params.feedUrls ?? _podcastLibraryService.podcasts)) { - final storedTimeStamp = _podcastLibraryService - .getPodcastLastUpdated(feedUrl); - DateTime? feedLastUpdated; - try { - feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); - } on Exception catch (e) { - printMessageInDebugMode(e); - } - final name = _podcastLibraryService.getSubscribedPodcastName( - feedUrl, - ); - - printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); - printMessageInDebugMode( - 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', - ); - printMessageInDebugMode( - 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', - ); - - if (feedLastUpdated == null) continue; - - await _podcastLibraryService.addPodcastLastUpdated( - feedUrl: feedUrl, - timestamp: feedLastUpdated.podcastTimeStamp, - ); - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - // Fetch episodes to refresh cache - await fetchEpisodeMediaCommand.runAsync(Item(feedUrl: feedUrl)); - - await _podcastLibraryService.addPodcastUpdate( - feedUrl, - feedLastUpdated, - ); - newUpdateFeedUrls.add(feedUrl); + 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 (newUpdateFeedUrls.isNotEmpty) { - final podcastName = newUpdateFeedUrls.length == 1 - ? _podcastLibraryService.getSubscribedPodcastName( - newUpdateFeedUrls.first, - ) - : null; - final msg = newUpdateFeedUrls.length == 1 - ? '${params.updateMessage}${podcastName != null ? ' $podcastName' : ''}' - : params.multiUpdateMessage(newUpdateFeedUrls.length); + if (updatedProxies.isNotEmpty) { + final msg = updatedProxies.length == 1 + ? '${params.updateMessage} ${updatedProxies.first.title ?? ''}' + : params.multiUpdateMessage(updatedProxies.length); await _notificationsService.notify(message: msg); } }, initialValue: null); @@ -221,19 +159,63 @@ class PodcastManager { activeDownloads.remove(episode); } - // Episode cache - ensures same instances across app for command state - final _episodeCache = >{}; - final _podcastDescriptionCache = {}; + // 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> getSubscribedPodcastsCommand; late Command< - ({ - Set? feedUrls, - String updateMessage, - String Function(int) multiUpdateMessage, - }), + ({String updateMessage, String Function(int) multiUpdateMessage}), void > checkForUpdatesCommand; @@ -250,5 +232,5 @@ class PodcastManager { } String? getPodcastDescription(String? feedUrl) => - _podcastDescriptionCache[feedUrl]; + _proxyCache[feedUrl]?.description; } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 548d9f8..5000299 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -76,14 +76,14 @@ class PodcastService { } } - // Stateless operation - just fetches episodes and description, no caching - Future<({List episodes, String? description})> findEpisodes({ + // Stateless operation - just fetches podcast and episodes, no caching + Future<({Podcast? podcast, List episodes})> findEpisodes({ Item? item, String? feedUrl, }) async { if (item == null && item?.feedUrl == null && feedUrl == null) { printMessageInDebugMode('findEpisodes called without feedUrl or item'); - return (episodes: [], description: null); + return (podcast: null, episodes: []); } final url = feedUrl ?? item!.feedUrl!; @@ -96,7 +96,14 @@ class PodcastService { ); } - final Podcast? podcast = await compute(loadPodcast, url); + Podcast? podcast; + try { + podcast = await compute(loadPodcast, url); + } catch (e) { + printMessageInDebugMode('Error loading podcast feed: $e'); + return (podcast: null, episodes: []); + } + if (podcast?.image != null) { _libraryService.addSubscribedPodcastImage( feedUrl: url, @@ -106,7 +113,7 @@ class PodcastService { final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; - return (episodes: episodes, description: podcast?.description); + return (podcast: podcast, episodes: episodes); } } diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 28c3694..b6cb9bb 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -78,12 +78,18 @@ class _DownloadProgress extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { 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: progress == 1.0 ? 0 : progress, + value: showSpinner ? (progress > 0 ? progress : null) : 0, backgroundColor: Colors.transparent, ), ); diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart index 4d1c8f6..40962c4 100644 --- a/lib/podcasts/view/podcast_card_play_button.dart +++ b/lib/podcasts/view/podcast_card_play_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 '../../player/player_manager.dart'; import '../podcast_manager.dart'; class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { @@ -12,11 +11,13 @@ class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - // Handler only exists while this button is mounted - won't fire when - // PodcastPageEpisodeList fetches episodes (different widget tree) + final proxy = di().getOrCreateProxy(podcastItem); + registerHandler( - select: (PodcastManager m) => m.fetchEpisodeMediaCommand.results, - handler: (context, result, cancel) { + target: proxy.playEpisodesCommand.results, + handler: (context, CommandResult? result, cancel) { + if (result == null) return; + if (result.isRunning) { showDialog( context: context, @@ -25,9 +26,6 @@ class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { ); } else if (result.isSuccess) { Navigator.of(context).pop(); - if (result.data != null && result.data!.isNotEmpty) { - di().setPlaylist(result.data!, index: 0); - } } else if (result.hasError) { Navigator.of(context).pop(); } @@ -36,8 +34,7 @@ class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { return FloatingActionButton.small( heroTag: 'podcastcardfap', - onPressed: () => - di().fetchEpisodeMediaCommand.run(podcastItem), + onPressed: () => proxy.playEpisodesCommand(0), child: const Icon(Icons.play_arrow), ); } diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index 8dfec50..3f14b4e 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -14,17 +14,15 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - callOnceAfterThisBuild( - (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) => e.isDownloaded).toList() diff --git a/lib/podcasts/view/recent_downloads_button.dart b/lib/podcasts/view/recent_downloads_button.dart index d7ecd67..5ab1862 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -42,7 +42,7 @@ class _RecentDownloadsButtonState extends State @override Widget build(BuildContext context) { final theme = context.theme; - final activeDownloads = watchValue((PodcastManager m) => m.activeDownloads); + final activeDownloads = watch(di().activeDownloads).value; final hasAnyDownloads = activeDownloads.isNotEmpty; final hasInProgressDownloads = activeDownloads.any((e) => !e.isDownloaded); From da03275a9c147d79d6913fe3b16a313a11fa5092 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 25 Nov 2025 18:43:45 +0100 Subject: [PATCH 12/12] refactor: enhance podcast management with new data structures and UI components --- lib/player/view/player_track_info.dart | 12 +++++++- lib/podcasts/data/podcast_metadata.dart | 15 ++++++++++ lib/podcasts/podcast_library_service.dart | 25 ++++++++++------- lib/podcasts/podcast_manager.dart | 28 +++++++------------ .../view/podcast_collection_view.dart | 17 +---------- .../view/podcast_favorite_button.dart | 27 +++++------------- 6 files changed, 59 insertions(+), 65 deletions(-) diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 017e0f6..f40693c 100644 --- a/lib/player/view/player_track_info.dart +++ b/lib/player/view/player_track_info.dart @@ -4,8 +4,11 @@ import 'package:flutter_it/flutter_it.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/duration_x.dart'; +import '../../podcasts/podcast_library_service.dart'; +import '../../podcasts/view/podcast_favorite_button.dart'; import '../../radio/view/radio_browser_station_star_button.dart'; import '../../search/copy_to_clipboard_content.dart'; +import '../data/episode_media.dart'; import '../data/station_media.dart'; import '../player_manager.dart'; @@ -90,7 +93,14 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { ], ), ), - if (media is StationMedia) const RadioStationStarButton(), + if (media is StationMedia) + const RadioStationStarButton() + else if (media is EpisodeMedia) + PodcastFavoriteButton( + podcastItem: di().getPodcastItem( + media.feedUrl, + ), + ), ], ), ); 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/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 1825b8f..9867281 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -47,8 +47,7 @@ class PodcastManager { .listen((filterText, sub) => updateSearchCommand.run(filterText)); getSubscribedPodcastsCommand = Command.createSync( - (filterText) => - podcastLibraryService.getFilteredPodcastsWithMetadata(filterText), + (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), initialValue: [], ); @@ -81,10 +80,7 @@ class PodcastManager { }, initialValue: null); togglePodcastSubscriptionCommand = - Command.createUndoableNoResult< - Item, - ({bool wasAdd, PodcastMetadata metadata}) - >( + Command.createUndoableNoResult( (item, stack) async { final feedUrl = item.feedUrl; if (feedUrl == null) return; @@ -92,14 +88,8 @@ class PodcastManager { final currentList = getSubscribedPodcastsCommand.value; final isSubscribed = currentList.any((p) => p.feedUrl == feedUrl); - final metadata = PodcastMetadata( - feedUrl: feedUrl, - name: item.collectionName, - imageUrl: item.bestArtworkUrl, - ); - // Store operation info for undo - stack.push((wasAdd: !isSubscribed, metadata: metadata)); + stack.push((wasAdd: !isSubscribed, item: item)); // Optimistic update: modify list directly if (isSubscribed) { @@ -107,13 +97,15 @@ class PodcastManager { .where((p) => p.feedUrl != feedUrl) .toList(); } else { - getSubscribedPodcastsCommand.value = [...currentList, metadata]; + getSubscribedPodcastsCommand.value = [...currentList, item]; } // Async persist await (isSubscribed ? _podcastLibraryService.removePodcast(feedUrl) - : _podcastLibraryService.addPodcast(metadata)); + : _podcastLibraryService.addPodcast( + PodcastMetadata.fromItem(item), + )); }, undo: (stack, reason) async { final undoData = stack.pop(); @@ -122,13 +114,13 @@ class PodcastManager { if (undoData.wasAdd) { // Was an add, so remove it getSubscribedPodcastsCommand.value = currentList - .where((p) => p.feedUrl != undoData.metadata.feedUrl) + .where((p) => p.feedUrl != undoData.item.feedUrl) .toList(); } else { // Was a remove, so add it back getSubscribedPodcastsCommand.value = [ ...currentList, - undoData.metadata, + undoData.item, ]; } }, @@ -213,7 +205,7 @@ class PodcastManager { } late Command updateSearchCommand; - late Command> getSubscribedPodcastsCommand; + late Command> getSubscribedPodcastsCommand; late Command< ({String updateMessage, String Function(int) multiUpdateMessage}), void diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 36c78dc..1b4c7dc 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -1,7 +1,5 @@ -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'; @@ -34,20 +32,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 b62db8c..610273b 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -2,6 +2,7 @@ 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 { @@ -21,35 +22,21 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { ), ); - // 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}'), - ), + void onPressed() => isSubscribed + ? di().removePodcast(feedUrl: podcastItem.feedUrl!) + : di().addPodcast( + PodcastMetadata.fromItem(podcastItem), ); - } - }, - ); - final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { return FloatingActionButton.small( heroTag: 'favtag', - onPressed: () => di().togglePodcastSubscriptionCommand - .run(podcastItem), + onPressed: onPressed, child: icon, ); } - return IconButton( - onPressed: () => di().togglePodcastSubscriptionCommand - .run(podcastItem), - icon: icon, - ); + return IconButton(onPressed: onPressed, icon: icon); } }