From a4f822c52906c165fb6cd3f350d9545a4b7ee534 Mon Sep 17 00:00:00 2001 From: Thomas Burkhart Date: Mon, 24 Nov 2025 03:42:18 -0500 Subject: [PATCH 1/8] 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 c427f9833ea6872a7e7f8d2b6d45b1e2b40064ea Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 24 Nov 2025 22:07:09 +0100 Subject: [PATCH 2/8] refactor: move command creation to podcast manager --- lib/extensions/podcast_x.dart | 20 ----- lib/player/data/episode_media.dart | 81 ++----------------- lib/podcasts/download_service.dart | 14 ++-- lib/podcasts/podcast_library_service.dart | 3 +- lib/podcasts/podcast_manager.dart | 50 +++++++++++- lib/podcasts/podcast_service.dart | 21 ++++- lib/podcasts/view/download_button.dart | 18 +++-- .../view/podcast_collection_view.dart | 15 +++- .../view/podcast_page_episode_list.dart | 13 ++- .../view/recent_downloads_button.dart | 28 ++++++- lib/register_dependencies.dart | 15 ++-- macos/Podfile.lock | 13 ++- pubspec.lock | 8 +- 13 files changed, 157 insertions(+), 142 deletions(-) delete mode 100644 lib/extensions/podcast_x.dart diff --git a/lib/extensions/podcast_x.dart b/lib/extensions/podcast_x.dart deleted file mode 100644 index 5c28034..0000000 --- a/lib/extensions/podcast_x.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:podcast_search/podcast_search.dart'; - -import '../player/data/episode_media.dart'; - -extension PodcastX on Podcast { - List toEpisodeMediaList(String url, Item? item) => episodes - .where((e) => e.contentUrl != null) - .map( - (e) => EpisodeMedia( - e.contentUrl!, - episode: e, - feedUrl: url, - albumArtUrl: item?.artworkUrl600 ?? item?.artworkUrl ?? image, - collectionName: title, - artist: copyright, - genres: [if (item?.primaryGenreName != null) item!.primaryGenreName!], - ), - ) - .toList(); -} diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 3d0934b..af81f83 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -1,12 +1,8 @@ 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 { @@ -24,15 +20,12 @@ class EpisodeMedia extends UniqueMedia { List genres = const [], String? collectionName, String? artist, + String? downloadPath, }) { - // 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, @@ -49,12 +42,11 @@ class EpisodeMedia extends UniqueMedia { // Private constructor that receives pre-computed values EpisodeMedia._( - String resource, { - required bool wasDownloaded, - Map? extras, - Map? httpHeaders, - Duration? start, - Duration? end, + super.resource, { + super.extras, + super.httpHeaders, + super.start, + super.end, required this.episode, required String feedUrl, int? bitRate, @@ -67,17 +59,8 @@ class EpisodeMedia extends UniqueMedia { _albumArtUrl = albumArtUrl, _genres = genres, _collectionName = collectionName, - _artist = artist, - _wasDownloadedOnCreation = wasDownloaded, - super( - resource, - extras: extras, - httpHeaders: httpHeaders, - start: start, - end: end, - ); - - final bool _wasDownloadedOnCreation; + _artist = artist; + final Episode episode; final String _feedUrl; final int? _bitRate; @@ -171,52 +154,4 @@ 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_service.dart b/lib/podcasts/download_service.dart index 2d1148f..20ae277 100644 --- a/lib/podcasts/download_service.dart +++ b/lib/podcasts/download_service.dart @@ -2,11 +2,10 @@ 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 '../settings/settings_service.dart'; import 'podcast_library_service.dart'; /// Service for downloading podcast episodes. @@ -17,8 +16,10 @@ class DownloadService { DownloadService({ required PodcastLibraryService libraryService, required Dio dio, + required SettingsService settingsService, }) : _libraryService = libraryService, - _dio = dio { + _dio = dio, + _settingsService = settingsService { _propertiesChangedSubscription = _libraryService.propertiesChanged.listen(( _, ) { @@ -28,13 +29,10 @@ class DownloadService { } final PodcastLibraryService _libraryService; + final SettingsService _settingsService; 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 @@ -49,7 +47,7 @@ class DownloadService { throw Exception('Invalid media, missing URL to download'); } - final downloadsDir = di().downloadsDirCommand.value; + final downloadsDir = _settingsService.downloadsDir; if (downloadsDir == null) { throw Exception('Downloads directory not set'); } diff --git a/lib/podcasts/podcast_library_service.dart b/lib/podcasts/podcast_library_service.dart index d9a5664..936f8af 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -14,8 +14,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 => diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 8cbd4d7..ba9911d 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -7,6 +8,7 @@ import '../extensions/country_x.dart'; import '../player/data/episode_media.dart'; import '../search/search_manager.dart'; import 'data/podcast_metadata.dart'; +import 'download_service.dart'; import 'podcast_library_service.dart'; import 'podcast_service.dart'; @@ -18,10 +20,12 @@ import 'podcast_service.dart'; class PodcastManager { PodcastManager({ required PodcastService podcastService, + required DownloadService downloadService, required SearchManager searchManager, required CollectionManager collectionManager, required PodcastLibraryService podcastLibraryService, }) : _podcastService = podcastService, + _downloadService = downloadService, _podcastLibraryService = podcastLibraryService { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); @@ -62,14 +66,54 @@ class PodcastManager { final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; - - // Track episodes currently downloading - final activeDownloads = ListNotifier(); + final DownloadService _downloadService; late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; late Command> podcastsCommand; + final downloadCommands = >{}; + final activeDownloads = ListNotifier(); + final recentDownloads = ListNotifier(); + + Command getDownloadCommand(EpisodeMedia media) => + downloadCommands.putIfAbsent(media, () => _createDownloadCommand(media)); + + Command _createDownloadCommand(EpisodeMedia media) { + final command = Command.createAsyncNoParamNoResultWithProgress(( + handle, + ) async { + activeDownloads.add(media); + + final cancelToken = CancelToken(); + + handle.isCanceled.listen((canceled, subscription) { + if (canceled) { + activeDownloads.remove(media); + cancelToken.cancel(); + subscription.cancel(); + } + }); + + await _downloadService.download( + episode: media, + cancelToken: cancelToken, + onProgress: (received, total) { + handle.updateProgress(received / total); + }, + ); + + activeDownloads.remove(media); + recentDownloads.add(media); + }, errorFilter: const LocalAndGlobalErrorFilter()); + + if (_podcastLibraryService.getDownload(media.url) != null) { + command.resetProgress(progress: 1.0); + } + + return command; + } + Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); podcastsCommand.run(); diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 7459097..722795b 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -6,7 +6,6 @@ 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'; @@ -177,7 +176,25 @@ class PodcastService { ); } - final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; + final episodes = + podcast?.episodes + .where((e) => e.contentUrl != null) + .map( + (e) => EpisodeMedia( + e.contentUrl!, + episode: e, + feedUrl: url, + albumArtUrl: + item?.artworkUrl600 ?? item?.artworkUrl ?? podcast.image, + collectionName: podcast.title, + artist: podcast.copyright, + genres: [ + if (item?.primaryGenreName != null) item!.primaryGenreName!, + ], + ), + ) + .toList() ?? + []; _episodeCache[url] = episodes; _podcastDescriptionCache[url] = podcast?.description; diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index 939852f..df83e2f 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -30,11 +30,12 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final theme = context.theme; + final downloadCommand = di().getDownloadCommand(episode); - final progress = watch(episode.downloadCommand.progress).value; + final progress = watch(downloadCommand.progress).value; final isDownloaded = progress == 1.0; - final isRunning = watch(episode.downloadCommand.isRunning).value; + final isRunning = watch(downloadCommand.isRunning).value; return IconButton( isSelected: isDownloaded, @@ -48,9 +49,10 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { onPressed: () { if (isDownloaded) { di().deleteDownload(media: episode); - episode.downloadCommand.resetProgress(); + di().recentDownloads.remove(episode); + downloadCommand.resetProgress(); } else if (isRunning) { - episode.downloadCommand.cancel(); + downloadCommand.cancel(); } else { // Add podcast to library before downloading di().addPodcast( @@ -62,7 +64,7 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { genreList: episode.genres, ), ); - episode.downloadCommand.run(); + downloadCommand.run(); } }, color: isDownloaded @@ -79,13 +81,15 @@ class _DownloadProgress extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - final progress = watch(episode.downloadCommand.progress).value; + final progress = watch( + di().getDownloadCommand(episode).progress, + ).value; return SizedBox.square( 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_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 1fb42ff..4401100 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_service.dart'; +import '../podcast_library_service.dart'; import '../podcast_manager.dart'; import 'podcast_card.dart'; @@ -14,9 +14,16 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - final feedsWithDownloads = watchPropertyValue( - (DownloadService m) => m.feedsWithDownloads, - ); + final feedsWithDownloads = + watchStream( + (PodcastLibraryService m) => + m.propertiesChanged.map((_) => m.feedsWithDownloads), + initialValue: di().feedsWithDownloads, + allowStreamChange: true, + preserveState: false, + ).data ?? + {}; + final showOnlyDownloads = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index 6a71309..d532c01 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), ); @@ -27,7 +27,16 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { ).toWidget( onData: (episodesX, param) { final episodes = downloadsOnly - ? episodesX.where((e) => e.isDownloaded).toList() + ? episodesX + .where( + (e) => + di() + .getDownloadCommand(e) + .progress + .value == + 1.0, + ) + .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 c5faf35..982e60f 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -43,9 +43,11 @@ class _RecentDownloadsButtonState extends State Widget build(BuildContext context) { final theme = context.theme; final activeDownloads = watchValue((PodcastManager m) => m.activeDownloads); - final hasActiveDownloads = activeDownloads.isNotEmpty; + final recentDownloads = watchValue((PodcastManager m) => m.recentDownloads); + final hasRecentDownloads = recentDownloads.isNotEmpty; + if (hasActiveDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); @@ -58,7 +60,7 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: hasActiveDownloads ? 1.0 : 0.0, + opacity: hasActiveDownloads || hasRecentDownloads ? 1.0 : 0.0, child: IconButton( icon: hasActiveDownloads ? FadeTransition( @@ -89,10 +91,14 @@ class _RecentDownloadsButtonState extends State SliverList.builder( itemCount: activeDownloads.length, itemBuilder: (context, index) { - final episode = activeDownloads[index]; + final episode = activeDownloads.elementAt(index); return ListTile( onTap: () { - if (episode.isDownloaded) { + if (di() + .getDownloadCommand(episode) + .progress + .value == + 1.0) { di().setPlaylist([episode]); } }, @@ -102,6 +108,20 @@ class _RecentDownloadsButtonState extends State ); }, ), + SliverList.builder( + itemBuilder: (context, index) { + final episode = recentDownloads.elementAt(index); + return ListTile( + onTap: () { + di().setPlaylist([episode]); + }, + title: Text(episode.title ?? context.l10n.unknown), + subtitle: Text(episode.artist ?? context.l10n.unknown), + trailing: DownloadButton(episode: episode), + ); + }, + itemCount: recentDownloads.length, + ), ], ), ), diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index 13aab37..b5af2d3 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -92,6 +92,14 @@ void registerDependencies() { () => PodcastLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], ) + ..registerSingletonWithDependencies( + () => DownloadService( + libraryService: di(), + settingsService: di(), + dio: di(), + ), + dependsOn: [SettingsService], + ) ..registerSingletonWithDependencies( () => PodcastService( libraryService: di(), @@ -107,6 +115,7 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), podcastLibraryService: di(), + downloadService: di(), ), dependsOn: [PodcastService], ) @@ -120,12 +129,6 @@ void registerDependencies() { ), dependsOn: [SettingsService], ) - ..registerLazySingleton( - () => DownloadService( - libraryService: di(), - dio: di(), - ), - ) ..registerSingletonWithDependencies( () => RadioLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], diff --git a/macos/Podfile.lock b/macos/Podfile.lock index dce2123..59dae1a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -15,10 +15,9 @@ PODS: - FlutterMacOS - media_kit_video (0.0.1): - FlutterMacOS - - package_info_plus (0.0.1): + - objective_c (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter + - package_info_plus (0.0.1): - FlutterMacOS - screen_retriever_macos (0.0.1): - FlutterMacOS @@ -48,8 +47,8 @@ DEPENDENCIES: - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) @@ -76,10 +75,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos media_kit_video: :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + objective_c: + :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin screen_retriever_macos: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos shared_preferences_foundation: @@ -106,8 +105,8 @@ SPEC CHECKSUMS: local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + objective_c: ec13431e45ba099cb734eb2829a5c1cd37986cba package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/pubspec.lock b/pubspec.lock index d8f588f..37aff55 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -709,10 +709,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: "direct main" description: @@ -1211,10 +1211,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" typed_data: dependency: transitive description: From 155b5a194a94591216356615c0c43e55c640116c Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Mon, 24 Nov 2025 23:29:26 +0100 Subject: [PATCH 3/8] refactor: map podcasts in manager --- lib/extensions/podcast_x.dart | 20 +++ lib/player/data/episode_media.dart | 37 +---- lib/player/player_manager.dart | 26 +++- lib/podcasts/podcast_manager.dart | 104 +++++++++++++- lib/podcasts/podcast_service.dart | 135 +----------------- lib/podcasts/view/podcast_card.dart | 26 +--- .../view/podcast_card_play_button.dart | 30 ++++ lib/podcasts/view/podcast_page.dart | 4 +- lib/register_dependencies.dart | 42 +++--- 9 files changed, 209 insertions(+), 215 deletions(-) create mode 100644 lib/extensions/podcast_x.dart create mode 100644 lib/podcasts/view/podcast_card_play_button.dart diff --git a/lib/extensions/podcast_x.dart b/lib/extensions/podcast_x.dart new file mode 100644 index 0000000..5c28034 --- /dev/null +++ b/lib/extensions/podcast_x.dart @@ -0,0 +1,20 @@ +import 'package:podcast_search/podcast_search.dart'; + +import '../player/data/episode_media.dart'; + +extension PodcastX on Podcast { + List toEpisodeMediaList(String url, Item? item) => episodes + .where((e) => e.contentUrl != null) + .map( + (e) => EpisodeMedia( + e.contentUrl!, + episode: e, + feedUrl: url, + albumArtUrl: item?.artworkUrl600 ?? item?.artworkUrl ?? image, + collectionName: title, + artist: copyright, + genres: [if (item?.primaryGenreName != null) item!.primaryGenreName!], + ), + ) + .toList(); +} diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index af81f83..100c895 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -6,42 +6,7 @@ import '../../extensions/date_time_x.dart'; import 'unique_media.dart'; class EpisodeMedia extends UniqueMedia { - // 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, - String? downloadPath, - }) { - final effectiveResource = downloadPath ?? resource; - - return EpisodeMedia._( - effectiveResource, - 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._( + EpisodeMedia( super.resource, { super.extras, super.httpHeaders, diff --git a/lib/player/player_manager.dart b/lib/player/player_manager.dart index eef8ea5..0aedb6d 100644 --- a/lib/player/player_manager.dart +++ b/lib/player/player_manager.dart @@ -7,12 +7,17 @@ import 'package:media_kit_video/media_kit_video.dart'; import '../common/logging.dart'; import '../extensions/color_x.dart'; +import '../podcasts/podcast_library_service.dart'; +import 'data/episode_media.dart'; import 'data/unique_media.dart'; import 'view/player_view_state.dart'; class PlayerManager extends BaseAudioHandler with SeekHandler { - PlayerManager({required VideoController controller}) - : _controller = controller { + PlayerManager({ + required VideoController controller, + required PodcastLibraryService podcastLibraryService, + }) : _controller = controller, + _podcastLibraryService = podcastLibraryService { playbackState.add( PlaybackState( playing: false, @@ -40,6 +45,7 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { } final VideoController _controller; + final PodcastLibraryService _podcastLibraryService; VideoController get videoController => _controller; final playerViewState = ValueNotifier( @@ -234,7 +240,21 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { }) async { if (mediaList.isEmpty) return; updateState(resetRemoteSource: true); - await _player.open(Playlist(mediaList, index: index), play: play); + await _player.open( + Playlist( + mediaList.map((e) { + if (e is EpisodeMedia && + _podcastLibraryService.getDownload(e.url) != null) { + return e.copyWithX( + resource: _podcastLibraryService.getDownload(e.url)!, + ); + } + return e; + }).toList(), + index: index, + ), + play: play, + ); } Future addToPlaylist(UniqueMedia media) async => _player.add(media); diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index ba9911d..19dd706 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -5,6 +5,10 @@ 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/podcast_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'; @@ -24,9 +28,11 @@ class PodcastManager { required SearchManager searchManager, required CollectionManager collectionManager, required PodcastLibraryService podcastLibraryService, + required NotificationsService notificationsService, }) : _podcastService = podcastService, _downloadService = downloadService, - _podcastLibraryService = podcastLibraryService { + _podcastLibraryService = podcastLibraryService, + _notificationsService = notificationsService { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); }; @@ -55,7 +61,7 @@ class PodcastManager { ); fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) => _podcastService.findEpisodes(item: podcast), + (podcast) => findEpisodes(item: podcast), initialValue: [], ); @@ -67,6 +73,7 @@ class PodcastManager { final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; final DownloadService _downloadService; + final NotificationsService _notificationsService; late Command updateSearchCommand; late Command> fetchEpisodeMediaCommand; @@ -123,4 +130,97 @@ class PodcastManager { await _podcastLibraryService.removePodcast(feedUrl); podcastsCommand.run(); } + + final Map _podcastCache = {}; + String? getPodcastDescriptionFromCache(String? feedUrl) => + _podcastCache[feedUrl]?.description; + + Future> findEpisodes({ + Item? item, + String? feedUrl, + bool loadFromCache = true, + }) async { + if (item == null && item?.feedUrl == null && feedUrl == null) { + return Future.error( + ArgumentError('Either item or feedUrl must be provided'), + ); + } + + final url = feedUrl ?? item!.feedUrl!; + + Podcast? podcast; + if (loadFromCache && _podcastCache.containsKey(url)) { + podcast = _podcastCache[url]; + } else { + podcast = await _podcastService.fetchPodcast(item: item, feedUrl: url); + if (podcast != null) { + _podcastCache[url] = podcast; + } + } + + if (podcast?.image != null) { + _podcastLibraryService.addSubscribedPodcastImage( + feedUrl: url, + imageUrl: podcast!.image!, + ); + } else if (item?.bestArtworkUrl != null) { + _podcastLibraryService.addSubscribedPodcastImage( + feedUrl: url, + imageUrl: item!.bestArtworkUrl!, + ); + } + + return podcast?.toEpisodeMediaList(url, item) ?? []; + } + + Future checkForUpdates({ + Set? feedUrls, + required String updateMessage, + required String Function(int length) multiUpdateMessage, + }) async { + final newUpdateFeedUrls = {}; + + for (final feedUrl in (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)) { + await findEpisodes(feedUrl: feedUrl, loadFromCache: false); + await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated); + + newUpdateFeedUrls.add(feedUrl); + } + } + + if (newUpdateFeedUrls.isNotEmpty) { + final msg = newUpdateFeedUrls.length == 1 + ? '$updateMessage${_podcastCache[newUpdateFeedUrls.first]?.title != null ? ' ${_podcastCache[newUpdateFeedUrls.first]?.title}' : ''}' + : multiUpdateMessage(newUpdateFeedUrls.length); + await _notificationsService.notify(message: msg); + } + } } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 722795b..97bc4a4 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -1,31 +1,18 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:podcast_search/podcast_search.dart'; import '../common/logging.dart'; -import '../extensions/date_time_x.dart'; import '../extensions/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'; 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, - _libraryService = libraryService { + PodcastService({required SettingsService settingsService}) + : _settingsService = settingsService { _search = Search( searchProvider: _settingsService.getBool(SPKeys.usePodcastIndex) == true && @@ -82,124 +69,16 @@ class PodcastService { } } - bool _updateLock = false; - - Future checkForUpdates({ - Set? feedUrls, - required String updateMessage, - required String Function(int length) multiUpdateMessage, - }) async { - if (_updateLock) return; - _updateLock = true; - - final newUpdateFeedUrls = {}; - - for (final feedUrl in (feedUrls ?? _libraryService.podcasts)) { - final storedTimeStamp = _libraryService.getPodcastLastUpdated(feedUrl); - DateTime? feedLastUpdated; - try { - feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); - } on Exception catch (e) { - printMessageInDebugMode(e); - } - final name = _libraryService.getSubscribedPodcastName(feedUrl); - - printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); - printMessageInDebugMode( - 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', - ); - printMessageInDebugMode( - 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', - ); - - if (feedLastUpdated == null) continue; - - await _libraryService.addPodcastLastUpdated( - feedUrl: feedUrl, - timestamp: feedLastUpdated.podcastTimeStamp, - ); - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - await findEpisodes(feedUrl: feedUrl, loadFromCache: false); - await _libraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - - newUpdateFeedUrls.add(feedUrl); - } - } - - if (newUpdateFeedUrls.isNotEmpty) { - final msg = newUpdateFeedUrls.length == 1 - ? '$updateMessage${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName != null ? ' ${_episodeCache[newUpdateFeedUrls.first]?.firstOrNull?.collectionName}' : ''}' - : multiUpdateMessage(newUpdateFeedUrls.length); - await _notificationsService.notify(message: msg); - } - - _updateLock = false; - } - - List? getPodcastEpisodesFromCache(String? feedUrl) => - _episodeCache[feedUrl]; - final Map> _episodeCache = {}; - - final Map _podcastDescriptionCache = {}; - String? getPodcastDescriptionFromCache(String? feedUrl) => - _podcastDescriptionCache[feedUrl]; - - Future> findEpisodes({ - Item? item, - String? feedUrl, - bool loadFromCache = true, - }) async { + Future fetchPodcast({Item? item, String? feedUrl}) async { if (item == null && item?.feedUrl == null && feedUrl == null) { - printMessageInDebugMode('findEpisodes called without feedUrl or item'); - return Future.value([]); - } - - final url = feedUrl ?? item!.feedUrl!; - - if (_episodeCache.containsKey(url) && loadFromCache) { - if (item?.bestArtworkUrl != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: item!.bestArtworkUrl!, - ); - } - return _episodeCache[url]!; - } - - final Podcast? podcast = await compute(loadPodcast, url); - if (podcast?.image != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: podcast!.image!, + return Future.error( + ArgumentError('Either item or feedUrl must be provided'), ); } - final episodes = - podcast?.episodes - .where((e) => e.contentUrl != null) - .map( - (e) => EpisodeMedia( - e.contentUrl!, - episode: e, - feedUrl: url, - albumArtUrl: - item?.artworkUrl600 ?? item?.artworkUrl ?? podcast.image, - collectionName: podcast.title, - artist: podcast.copyright, - genres: [ - if (item?.primaryGenreName != null) item!.primaryGenreName!, - ], - ), - ) - .toList() ?? - []; - - _episodeCache[url] = episodes; - _podcastDescriptionCache[url] = podcast?.description; + final url = feedUrl ?? item!.feedUrl!; - return episodes; + return compute(loadPodcast, url); } } diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index 9a1f686..471b646 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_it/flutter_it.dart'; -import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:phoenix_theme/phoenix_theme.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -8,8 +6,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_service.dart'; +import 'podcast_card_play_button.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page.dart'; @@ -93,25 +90,8 @@ class _PodcastCardState extends State { spacing: kBigPadding, mainAxisSize: MainAxisSize.min, children: [ - FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () async { - final res = await showFutureLoadingDialog( - context: context, - future: () async => di() - .findEpisodes(item: widget.podcastItem), - ); - if (res.isValue) { - final episodes = res.asValue!.value; - if (episodes.isNotEmpty) { - await di().setPlaylist( - episodes, - index: 0, - ); - } - } - }, - child: const Icon(Icons.play_arrow), + PodcastCardPlayButton( + podcastItem: widget.podcastItem, ), PodcastFavoriteButton.floating( podcastItem: widget.podcastItem, diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart new file mode 100644 index 0000000..0e06f8a --- /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().findEpisodes(item: 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.dart b/lib/podcasts/view/podcast_page.dart index e72abaf..1169236 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,7 +95,7 @@ class _PodcastPageState extends State { wrapInFakeScroll: false, color: Colors.white, text: - di() + di() .getPodcastDescriptionFromCache( widget.podcastItem.feedUrl, ) ?? diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index b5af2d3..965e1f4 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -50,6 +50,13 @@ void registerDependencies() { return wm; }) ..registerSingletonAsync(SharedPreferences.getInstance) + ..registerSingletonAsync(() async { + final service = SettingsService( + sharedPreferences: di(), + ); + await service.init(); + return service; + }, dependsOn: [SharedPreferences]) ..registerLazySingleton(() { MediaKit.ensureInitialized(); return VideoController( @@ -63,6 +70,14 @@ void registerDependencies() { dio.options.headers = {HttpHeaders.acceptEncodingHeader: '*'}; return dio; }, dispose: (s) => s.close()) + ..registerSingletonWithDependencies( + () => DownloadService( + libraryService: di(), + settingsService: di(), + dio: di(), + ), + dependsOn: [SettingsService], + ) ..registerSingletonAsync( () async => AudioService.init( config: AudioServiceConfig( @@ -75,37 +90,21 @@ void registerDependencies() { : null, androidNotificationChannelDescription: 'MusicPod Media Controls', ), - builder: () => PlayerManager(controller: di()), + builder: () => PlayerManager( + controller: di(), + podcastLibraryService: di(), + ), ), // dependsOn: [VideoController], dispose: (s) async => s.dispose(), ) - ..registerSingletonAsync(() async { - final service = SettingsService( - sharedPreferences: di(), - ); - await service.init(); - return service; - }, dependsOn: [SharedPreferences]) ..registerLazySingleton(() => NotificationsService()) ..registerSingletonWithDependencies( () => PodcastLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], ) - ..registerSingletonWithDependencies( - () => DownloadService( - libraryService: di(), - settingsService: di(), - dio: di(), - ), - dependsOn: [SettingsService], - ) ..registerSingletonWithDependencies( - () => PodcastService( - libraryService: di(), - notificationsService: di(), - settingsService: di(), - ), + () => PodcastService(settingsService: di()), dependsOn: [PodcastLibraryService, SettingsService], ) ..registerSingleton(SearchManager()) @@ -116,6 +115,7 @@ void registerDependencies() { collectionManager: di(), podcastLibraryService: di(), downloadService: di(), + notificationsService: di(), ), dependsOn: [PodcastService], ) From ee29ec49cdd577b04117a0843fda8ee7ef900b02 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 25 Nov 2025 09:28:18 +0100 Subject: [PATCH 4/8] refactor: map fetch episodes commands --- ARCHITECTURE_CHANGES.md | 271 ------ CLAUDE.md | 354 -------- DOWNLOAD_ARCHITECTURE_ANALYSIS.md | 842 ------------------ lib/podcasts/podcast_manager.dart | 28 +- .../view/podcast_page_episode_list.dart | 6 +- 5 files changed, 23 insertions(+), 1478 deletions(-) delete mode 100644 ARCHITECTURE_CHANGES.md delete mode 100644 CLAUDE.md delete mode 100644 DOWNLOAD_ARCHITECTURE_ANALYSIS.md diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md deleted file mode 100644 index da9671a..0000000 --- a/ARCHITECTURE_CHANGES.md +++ /dev/null @@ -1,271 +0,0 @@ -# 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 deleted file mode 100644 index 5be17a0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,354 +0,0 @@ -# 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 deleted file mode 100644 index d64aba1..0000000 --- a/DOWNLOAD_ARCHITECTURE_ANALYSIS.md +++ /dev/null @@ -1,842 +0,0 @@ -# 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/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 19dd706..542d375 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -60,23 +60,39 @@ class PodcastManager { (filterText, sub) => podcastsCommand.run(filterText), ); - fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) => findEpisodes(item: podcast), - initialValue: [], - ); - podcastsCommand.run(null); updateSearchCommand.run(null); } + // Map of feedUrl to fetch episodes command + final fetchEpisodeMediaCommands = + >>{}; + + Command> _getFetchEpisodesCommand(Item item) { + if (item.feedUrl == null) { + throw ArgumentError('Item must have a feedUrl to fetch episodes'); + } + return fetchEpisodeMediaCommands.putIfAbsent( + item.feedUrl!, + () => Command.createAsync>( + (item) async => findEpisodes(item: item), + initialValue: [], + ), + ); + } + + Command> runFetchEpisodesCommand(Item item) { + _getFetchEpisodesCommand(item).run(item); + return _getFetchEpisodesCommand(item); + } + final PodcastService _podcastService; final PodcastLibraryService _podcastLibraryService; final DownloadService _downloadService; final NotificationsService _notificationsService; late Command updateSearchCommand; - late Command> fetchEpisodeMediaCommand; late Command> podcastsCommand; final downloadCommands = >{}; diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index d532c01..5f72627 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -14,16 +14,12 @@ class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - callOnceAfterThisBuild( - (context) => di().fetchEpisodeMediaCommand(podcastItem), - ); - final downloadsOnly = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); return watchValue( - (PodcastManager m) => m.fetchEpisodeMediaCommand.results, + (PodcastManager m) => m.runFetchEpisodesCommand(podcastItem).results, ).toWidget( onData: (episodesX, param) { final episodes = downloadsOnly From 8fa2e207dd01b5ba5b91c0bc7e349c4ba50da776 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 25 Nov 2025 18:33:32 +0100 Subject: [PATCH 5/8] refactor: update podcast management to use Item type and improve favorite button functionality --- lib/player/view/player_track_info.dart | 12 ++++++- lib/podcasts/data/podcast_metadata.dart | 15 +++++++++ lib/podcasts/podcast_library_service.dart | 22 ++++++++----- lib/podcasts/podcast_manager.dart | 33 +++++++++---------- .../view/podcast_collection_view.dart | 21 +++--------- .../view/podcast_favorite_button.dart | 11 ++----- .../radio_browser_station_star_button.dart | 21 +++++------- 7 files changed, 70 insertions(+), 65 deletions(-) diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 017e0f6..4fa518b 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) + RadioStationStarButton(currentMedia: media) + 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 936f8af..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'; @@ -39,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; @@ -122,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 542d375..465216c 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -50,30 +50,34 @@ class PodcastManager { .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); - podcastsCommand = Command.createSync( - (filterText) => - podcastLibraryService.getFilteredPodcastsWithMetadata(filterText), + podcastCollectionCommand = Command.createSync( + (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), initialValue: [], ); collectionManager.textChangedCommand.listen( - (filterText, sub) => podcastsCommand.run(filterText), + (filterText, sub) => podcastCollectionCommand.run(filterText), ); - podcastsCommand.run(null); + podcastCollectionCommand.run(null); updateSearchCommand.run(null); } + final PodcastService _podcastService; + final PodcastLibraryService _podcastLibraryService; + final DownloadService _downloadService; + final NotificationsService _notificationsService; + // Map of feedUrl to fetch episodes command - final fetchEpisodeMediaCommands = + final _fetchEpisodeMediaCommands = >>{}; Command> _getFetchEpisodesCommand(Item item) { if (item.feedUrl == null) { throw ArgumentError('Item must have a feedUrl to fetch episodes'); } - return fetchEpisodeMediaCommands.putIfAbsent( + return _fetchEpisodeMediaCommands.putIfAbsent( item.feedUrl!, () => Command.createAsync>( (item) async => findEpisodes(item: item), @@ -87,20 +91,15 @@ class PodcastManager { return _getFetchEpisodesCommand(item); } - final PodcastService _podcastService; - final PodcastLibraryService _podcastLibraryService; - final DownloadService _downloadService; - final NotificationsService _notificationsService; - late Command updateSearchCommand; - late Command> podcastsCommand; + late Command> podcastCollectionCommand; - final downloadCommands = >{}; + final _downloadCommands = >{}; final activeDownloads = ListNotifier(); final recentDownloads = ListNotifier(); Command getDownloadCommand(EpisodeMedia media) => - downloadCommands.putIfAbsent(media, () => _createDownloadCommand(media)); + _downloadCommands.putIfAbsent(media, () => _createDownloadCommand(media)); Command _createDownloadCommand(EpisodeMedia media) { final command = Command.createAsyncNoParamNoResultWithProgress(( @@ -139,12 +138,12 @@ class PodcastManager { Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); - podcastsCommand.run(); + podcastCollectionCommand.run(); } Future removePodcast({required String feedUrl}) async { await _podcastLibraryService.removePodcast(feedUrl); - podcastsCommand.run(); + podcastCollectionCommand.run(); } final Map _podcastCache = {}; diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 4401100..03f91c8 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'; @@ -28,7 +26,9 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { (CollectionManager m) => m.showOnlyDownloadsNotifier, ); - return watchValue((PodcastManager m) => m.podcastsCommand.results).toWidget( + return watchValue( + (PodcastManager m) => m.podcastCollectionCommand.results, + ).toWidget( onData: (pees, _) { final podcasts = showOnlyDownloads ? pees.where((p) => feedsWithDownloads.contains(p.feedUrl)) @@ -39,20 +39,7 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { itemCount: podcasts.length, itemBuilder: (context, index) { final item = podcasts.elementAt(index); - return PodcastCard( - key: ValueKey(item), - podcastItem: Item( - feedUrl: item.feedUrl, - artistName: item.artist, - collectionName: item.name, - artworkUrl: item.imageUrl, - genre: - item.genreList - ?.mapIndexed((i, e) => Genre(i, e)) - .toList() ?? - [], - ), - ); + return PodcastCard(key: ValueKey(item), podcastItem: item); }, ); }, diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index a2e0bdf..8db1af9 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -17,7 +17,7 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final isSubscribed = watchValue( - (PodcastManager m) => m.podcastsCommand.select( + (PodcastManager m) => m.podcastCollectionCommand.select( (podcasts) => podcasts.any((p) => p.feedUrl == podcastItem.feedUrl), ), ); @@ -25,14 +25,7 @@ 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() ?? [], - ), + PodcastMetadata.fromItem(podcastItem), ); final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); diff --git a/lib/radio/view/radio_browser_station_star_button.dart b/lib/radio/view/radio_browser_station_star_button.dart index 576a5df..b2459ff 100644 --- a/lib/radio/view/radio_browser_station_star_button.dart +++ b/lib/radio/view/radio_browser_station_star_button.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:yaru/yaru.dart'; +import '../../player/data/station_media.dart'; import '../../player/data/unique_media.dart'; -import '../../player/player_manager.dart'; import '../radio_manager.dart'; class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { @@ -28,26 +28,21 @@ class RadioBrowserStationStarButton extends StatelessWidget with WatchItMixin { } class RadioStationStarButton extends StatelessWidget with WatchItMixin { - const RadioStationStarButton({super.key}); + const RadioStationStarButton({super.key, required this.currentMedia}); + + final StationMedia currentMedia; @override Widget build(BuildContext context) { - final currentMedia = watchStream( - (PlayerManager p) => p.currentMediaStream, - initialValue: di().currentMedia, - preserveState: false, - ).data; final isFavorite = watchValue( (RadioManager s) => s.favoriteStationsCommand.select( - (favorites) => favorites.any((m) => m.id == currentMedia?.id), + (favs) => favs.contains(currentMedia), ), ); return IconButton( - onPressed: currentMedia == null - ? null - : () => isFavorite - ? di().removeFavoriteStation(currentMedia.id) - : di().addFavoriteStation(currentMedia.id), + onPressed: () => isFavorite + ? di().removeFavoriteStation(currentMedia.id) + : di().addFavoriteStation(currentMedia.id), icon: Icon(isFavorite ? YaruIcons.star_filled : YaruIcons.star), ); } From f05991812c81b45b774743228d0d93868fc75d3f Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 25 Nov 2025 18:46:00 +0100 Subject: [PATCH 6/8] refactor: rename podcastCollectionCommand to getSubscribedPodcastsCommand for clarity --- lib/podcasts/podcast_manager.dart | 12 ++++++------ lib/podcasts/view/podcast_collection_view.dart | 2 +- lib/podcasts/view/podcast_favorite_button.dart | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 465216c..0bef49f 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -50,16 +50,16 @@ class PodcastManager { .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); - podcastCollectionCommand = Command.createSync( + getSubscribedPodcastsCommand = Command.createSync( (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), initialValue: [], ); collectionManager.textChangedCommand.listen( - (filterText, sub) => podcastCollectionCommand.run(filterText), + (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - podcastCollectionCommand.run(null); + getSubscribedPodcastsCommand.run(null); updateSearchCommand.run(null); } @@ -92,7 +92,7 @@ class PodcastManager { } late Command updateSearchCommand; - late Command> podcastCollectionCommand; + late Command> getSubscribedPodcastsCommand; final _downloadCommands = >{}; final activeDownloads = ListNotifier(); @@ -138,12 +138,12 @@ class PodcastManager { Future addPodcast(PodcastMetadata metadata) async { await _podcastLibraryService.addPodcast(metadata); - podcastCollectionCommand.run(); + getSubscribedPodcastsCommand.run(); } Future removePodcast({required String feedUrl}) async { await _podcastLibraryService.removePodcast(feedUrl); - podcastCollectionCommand.run(); + getSubscribedPodcastsCommand.run(); } final Map _podcastCache = {}; diff --git a/lib/podcasts/view/podcast_collection_view.dart b/lib/podcasts/view/podcast_collection_view.dart index 03f91c8..7d26a51 100644 --- a/lib/podcasts/view/podcast_collection_view.dart +++ b/lib/podcasts/view/podcast_collection_view.dart @@ -27,7 +27,7 @@ class PodcastCollectionView extends StatelessWidget with WatchItMixin { ); return watchValue( - (PodcastManager m) => m.podcastCollectionCommand.results, + (PodcastManager m) => m.getSubscribedPodcastsCommand.results, ).toWidget( onData: (pees, _) { final podcasts = showOnlyDownloads diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index 8db1af9..610273b 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -17,7 +17,7 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { final isSubscribed = watchValue( - (PodcastManager m) => m.podcastCollectionCommand.select( + (PodcastManager m) => m.getSubscribedPodcastsCommand.select( (podcasts) => podcasts.any((p) => p.feedUrl == podcastItem.feedUrl), ), ); From f0e87c38649a5f0e11b91d332843642bf9496403 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Tue, 25 Nov 2025 20:18:37 +0100 Subject: [PATCH 7/8] refactor: introduce PlayerPodcastFavoriteButton and update podcast favorite button integration --- .../view/player_podcast_favorite_button.dart | 49 +++++++++++++++ lib/player/view/player_track_info.dart | 9 +-- lib/podcasts/data/podcast_metadata.dart | 30 --------- lib/podcasts/podcast_library_service.dart | 62 ++++++++++--------- lib/podcasts/podcast_manager.dart | 9 +-- lib/podcasts/view/download_button.dart | 15 +++-- .../view/podcast_favorite_button.dart | 5 +- 7 files changed, 99 insertions(+), 80 deletions(-) create mode 100644 lib/player/view/player_podcast_favorite_button.dart delete mode 100644 lib/podcasts/data/podcast_metadata.dart diff --git a/lib/player/view/player_podcast_favorite_button.dart b/lib/player/view/player_podcast_favorite_button.dart new file mode 100644 index 0000000..df24d38 --- /dev/null +++ b/lib/player/view/player_podcast_favorite_button.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../../podcasts/podcast_manager.dart'; +import '../data/episode_media.dart'; + +class PlayerPodcastFavoriteButton extends StatelessWidget with WatchItMixin { + const PlayerPodcastFavoriteButton({super.key, required this.episodeMedia}) + : _floating = false; + const PlayerPodcastFavoriteButton.floating({ + super.key, + required this.episodeMedia, + }) : _floating = true; + + final EpisodeMedia episodeMedia; + final bool _floating; + + @override + Widget build(BuildContext context) { + final isSubscribed = watchValue( + (PodcastManager m) => m.getSubscribedPodcastsCommand.select( + (podcasts) => podcasts.any((p) => p.feedUrl == episodeMedia.feedUrl), + ), + ); + + void onPressed() => isSubscribed + ? di().removePodcast(feedUrl: episodeMedia.feedUrl) + : di().addPodcast( + Item( + feedUrl: episodeMedia.feedUrl, + artworkUrl: episodeMedia.albumArtUrl, + collectionName: episodeMedia.collectionName, + artistName: episodeMedia.artist, + ), + ); + final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); + + if (_floating) { + return FloatingActionButton.small( + heroTag: 'favtag', + onPressed: onPressed, + child: icon, + ); + } + + return IconButton(onPressed: onPressed, icon: icon); + } +} diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 4fa518b..6cc4843 100644 --- a/lib/player/view/player_track_info.dart +++ b/lib/player/view/player_track_info.dart @@ -4,13 +4,12 @@ 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'; +import 'player_podcast_favorite_button.dart'; class PlayerTrackInfo extends StatelessWidget with WatchItMixin { const PlayerTrackInfo({ @@ -96,11 +95,7 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { if (media is StationMedia) RadioStationStarButton(currentMedia: media) else if (media is EpisodeMedia) - PodcastFavoriteButton( - podcastItem: di().getPodcastItem( - media.feedUrl, - ), - ), + PlayerPodcastFavoriteButton(episodeMedia: media), ], ), ); diff --git a/lib/podcasts/data/podcast_metadata.dart b/lib/podcasts/data/podcast_metadata.dart deleted file mode 100644 index 8e6fab2..0000000 --- a/lib/podcasts/data/podcast_metadata.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:podcast_search/podcast_search.dart'; - -class PodcastMetadata { - const PodcastMetadata({ - required this.feedUrl, - this.imageUrl, - this.name, - this.artist, - this.genreList, - }); - - final String feedUrl; - final String? imageUrl; - 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 ae57cb7..97fdf22 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -7,7 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../extensions/date_time_x.dart'; import '../extensions/shared_preferences_x.dart'; -import 'data/podcast_metadata.dart'; class PodcastLibraryService { PodcastLibraryService({required SharedPreferences sharedPreferences}) @@ -16,7 +15,7 @@ class PodcastLibraryService { final SharedPreferences _sharedPreferences; - // This stream is currently used for updates and feeds with downloads + // This stream is currently used for downloads final _propertiesChangedController = StreamController.broadcast(); Stream get propertiesChanged => _propertiesChangedController.stream; Future notify(bool value) async => @@ -41,7 +40,7 @@ class PodcastLibraryService { }).toSet(); } - List getFilteredPodcastItems(String? filterText) { + List getFilteredPodcastsItems(String? filterText) { final filteredFeedUrls = _getFilteredPodcasts(filterText); final result = []; for (final feedUrl in filteredFeedUrls) { @@ -51,7 +50,8 @@ class PodcastLibraryService { return result; } - bool isPodcastSubscribed(String feedUrl) => _podcasts.contains(feedUrl); + bool isPodcastSubscribed(String? feedUrl) => + feedUrl != null && _podcasts.contains(feedUrl); List get podcastFeedUrls => _podcasts.toList(); Set get podcasts => _podcasts; int get podcastsLength => _podcasts.length; @@ -59,22 +59,22 @@ class PodcastLibraryService { // Adding and removing Podcasts // ------------------ - Future addPodcast(PodcastMetadata metadata) async { - if (isPodcastSubscribed(metadata.feedUrl)) return; - await _addPodcastMetadata(metadata); + Future addPodcast(Item item) async { + if (isPodcastSubscribed(item.feedUrl)) return; + await _addPodcastMetadata(item); await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, [ ...List.from(_podcasts), - metadata.feedUrl, + item.feedUrl!, ]); } - Future addPodcasts(List metadata) async { + Future addPodcasts(List metadata) async { if (metadata.isEmpty) return; final newList = List.from(_podcasts); for (var p in metadata) { - if (!newList.contains(p.feedUrl)) { + if (p.feedUrl != null && !newList.contains(p.feedUrl)) { await _addPodcastMetadata(p); - newList.add(p.feedUrl); + newList.add(p.feedUrl!); } } await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, newList); @@ -96,30 +96,36 @@ class PodcastLibraryService { // Podcast Metadata // ------------------ - Future _addPodcastMetadata(PodcastMetadata metadata) async { - if (metadata.imageUrl != null) { + Future _addPodcastMetadata(Item item) async { + if (item.feedUrl == null) { + return; + } + if (item.bestArtworkUrl != null) { addSubscribedPodcastImage( - feedUrl: metadata.feedUrl, - imageUrl: metadata.imageUrl!, + feedUrl: item.feedUrl!, + imageUrl: item.bestArtworkUrl!, ); } - if (metadata.name != null) { - addSubscribedPodcastName(feedUrl: metadata.feedUrl, name: metadata.name!); + if (item.collectionName != null) { + addSubscribedPodcastName( + feedUrl: item.feedUrl!, + name: item.collectionName!, + ); } - if (metadata.artist != null) { + if (item.artistName != null) { addSubscribedPodcastArtist( - feedUrl: metadata.feedUrl, - artist: metadata.artist!, + feedUrl: item.feedUrl!, + artist: item.artistName!, ); } - if (metadata.genreList != null) { + if (item.genre != null) { addSubscribedPodcastGenreList( - feedUrl: metadata.feedUrl, - genreList: metadata.genreList!, + feedUrl: item.feedUrl!, + genreList: item.genre!.map((g) => g.name).toList(), ); } await addPodcastLastUpdated( - feedUrl: metadata.feedUrl, + feedUrl: item.feedUrl!, timestamp: DateTime.now().podcastTimeStamp, ); } @@ -129,11 +135,9 @@ class PodcastLibraryService { artworkUrl: getSubscribedPodcastImage(feedUrl), collectionName: getSubscribedPodcastName(feedUrl), artistName: getSubscribedPodcastArtist(feedUrl), - genre: - getSubScribedPodcastGenreList( - feedUrl, - )?.mapIndexed((i, e) => Genre(i, e)).toList() ?? - [], + genre: getSubScribedPodcastGenreList( + feedUrl, + )?.mapIndexed((i, g) => Genre(i, g)).toList(), ); // Image URL diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 0bef49f..1e1f5c2 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -11,7 +11,6 @@ import '../extensions/string_x.dart'; import '../notifications/notifications_service.dart'; import '../player/data/episode_media.dart'; import '../search/search_manager.dart'; -import 'data/podcast_metadata.dart'; import 'download_service.dart'; import 'podcast_library_service.dart'; import 'podcast_service.dart'; @@ -51,7 +50,8 @@ class PodcastManager { .listen((filterText, sub) => updateSearchCommand.run(filterText)); getSubscribedPodcastsCommand = Command.createSync( - (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), + (filterText) => + podcastLibraryService.getFilteredPodcastsItems(filterText), initialValue: [], ); @@ -136,8 +136,8 @@ class PodcastManager { return command; } - Future addPodcast(PodcastMetadata metadata) async { - await _podcastLibraryService.addPodcast(metadata); + Future addPodcast(Item item) async { + await _podcastLibraryService.addPodcast(item); getSubscribedPodcastsCommand.run(); } @@ -147,6 +147,7 @@ class PodcastManager { } final Map _podcastCache = {}; + Podcast? getPodcastFromCache(String? feedUrl) => _podcastCache[feedUrl]; String? getPodcastDescriptionFromCache(String? feedUrl) => _podcastCache[feedUrl]?.description; diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index df83e2f..1287749 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -1,9 +1,10 @@ +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 '../../extensions/build_context_x.dart'; import '../../player/data/episode_media.dart'; -import '../data/podcast_metadata.dart'; import '../download_service.dart'; import '../podcast_manager.dart'; @@ -56,12 +57,14 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { } else { // Add podcast to library before downloading di().addPodcast( - PodcastMetadata( + Item( feedUrl: episode.feedUrl, - imageUrl: episode.albumArtUrl, - name: episode.collectionName, - artist: episode.artist, - genreList: episode.genres, + artworkUrl: episode.albumArtUrl, + collectionName: episode.collectionName, + artistName: episode.artist, + genre: episode.genres + .mapIndexed((index, genre) => Genre(index, genre)) + .toList(), ), ); downloadCommand.run(); diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index 610273b..d6eea6b 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 { @@ -24,9 +23,7 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { void onPressed() => isSubscribed ? di().removePodcast(feedUrl: podcastItem.feedUrl!) - : di().addPodcast( - PodcastMetadata.fromItem(podcastItem), - ); + : di().addPodcast(podcastItem); final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { From d6acfc609b7a2bba847cdfae5a7131f24791a337 Mon Sep 17 00:00:00 2001 From: Feichtmeier Date: Wed, 26 Nov 2025 18:44:06 +0100 Subject: [PATCH 8/8] refactor: unify media klasses TODO: create ONE class for all types of playable media - don't use third party libs in library services or player managers --- lib/extensions/podcast_item_x.dart | 32 +++ lib/extensions/shared_preferences_x.dart | 5 +- .../view/player_podcast_favorite_button.dart | 1 + lib/player/view/player_track_info.dart | 1 + lib/podcasts/download_service.dart | 19 +- lib/podcasts/podcast_library_service.dart | 214 ++++++------------ lib/podcasts/podcast_manager.dart | 30 +-- pubspec.lock | 20 +- pubspec.yaml | 2 +- 9 files changed, 123 insertions(+), 201 deletions(-) create mode 100644 lib/extensions/podcast_item_x.dart diff --git a/lib/extensions/podcast_item_x.dart b/lib/extensions/podcast_item_x.dart new file mode 100644 index 0000000..7f12fa9 --- /dev/null +++ b/lib/extensions/podcast_item_x.dart @@ -0,0 +1,32 @@ +import 'package:podcast_search/podcast_search.dart'; + +extension PodcastItemX on Item { + Map toJson() => { + 'artistId': artistId, + 'collectionId': collectionId, + 'trackId': trackId, + 'guid': guid, + 'artistName': artistName, + 'collectionName': collectionName, + 'collectionExplicitness': collectionExplicitness, + 'trackExplicitness': trackExplicitness, + 'trackName': trackName, + 'trackCount': trackCount, + 'collectionCensoredName': collectionCensoredName, + 'trackCensoredName': trackCensoredName, + 'artistViewUrl': artistViewUrl, + 'collectionViewUrl': collectionViewUrl, + 'feedUrl': feedUrl, + 'trackViewUrl': trackViewUrl, + 'artworkUrl30': artworkUrl30, + 'artworkUrl60': artworkUrl60, + 'artworkUrl100': artworkUrl100, + 'artworkUrl600': artworkUrl600, + 'releaseDate': releaseDate?.toIso8601String(), + 'country': country, + 'primaryGenreName': primaryGenreName, + 'contentAdvisoryRating': contentAdvisoryRating, + 'genreIds': genre?.map((g) => g.id.toString()).toList(), + 'genres': genre?.map((g) => g.name).toList(), + }; +} diff --git a/lib/extensions/shared_preferences_x.dart b/lib/extensions/shared_preferences_x.dart index 496b03e..4b77a53 100644 --- a/lib/extensions/shared_preferences_x.dart +++ b/lib/extensions/shared_preferences_x.dart @@ -22,10 +22,7 @@ extension SPKeys on SharedPreferences { static const usePlayerColor = 'usePlayerColor'; static const saveWindowSize = 'saveWindowSize'; static const podcastFeedUrls = 'podcastFeedUrls'; - static const podcastImageUrlSuffix = '_imageUrl'; - static const podcastNameSuffix = '_name'; - static const podcastArtistSuffix = '_artist'; - static const podcastGenreListSuffix = '_genreList'; + static const podcastDataSuffix = '_podcastData'; static const podcastEpisodeDownloadedSuffix = '_episodeDownloaded'; static const podcastsWithDownloads = 'podcastsWithDownloads'; static const podcastsWithUpdates = 'podcastsWithUpdates'; diff --git a/lib/player/view/player_podcast_favorite_button.dart b/lib/player/view/player_podcast_favorite_button.dart index df24d38..fb7e184 100644 --- a/lib/player/view/player_podcast_favorite_button.dart +++ b/lib/player/view/player_podcast_favorite_button.dart @@ -30,6 +30,7 @@ class PlayerPodcastFavoriteButton extends StatelessWidget with WatchItMixin { Item( feedUrl: episodeMedia.feedUrl, artworkUrl: episodeMedia.albumArtUrl, + artworkUrl600: episodeMedia.albumArtUrl, collectionName: episodeMedia.collectionName, artistName: episodeMedia.artist, ), diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 6cc4843..d3390cd 100644 --- a/lib/player/view/player_track_info.dart +++ b/lib/player/view/player_track_info.dart @@ -32,6 +32,7 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { final media = watchStream( (PlayerManager p) => p.currentMediaStream, initialValue: di().currentMedia, + allowStreamChange: true, preserveState: false, ).data; diff --git a/lib/podcasts/download_service.dart b/lib/podcasts/download_service.dart index 20ae277..63694ed 100644 --- a/lib/podcasts/download_service.dart +++ b/lib/podcasts/download_service.dart @@ -19,19 +19,11 @@ class DownloadService { required SettingsService settingsService, }) : _libraryService = libraryService, _dio = dio, - _settingsService = settingsService { - _propertiesChangedSubscription = _libraryService.propertiesChanged.listen(( - _, - ) { - // Notify listeners when library changes (downloads added/removed) - // This allows UI watching isDownloaded to update - }); - } + _settingsService = settingsService; final PodcastLibraryService _libraryService; final SettingsService _settingsService; final Dio _dio; - StreamSubscription? _propertiesChangedSubscription; /// Downloads an episode to the local filesystem. /// @@ -88,11 +80,6 @@ class DownloadService { } /// Deletes all downloaded episodes. - Future deleteAllDownloads() async { - await _libraryService.removeAllDownloads(); - } - - Future dispose() async { - await _propertiesChangedSubscription?.cancel(); - } + Future deleteAllDownloads() async => + _libraryService.removeAllDownloads(); } diff --git a/lib/podcasts/podcast_library_service.dart b/lib/podcasts/podcast_library_service.dart index 97fdf22..46b89d8 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -1,11 +1,12 @@ import 'dart:async'; +import 'dart:convert'; 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'; +import '../extensions/podcast_item_x.dart'; import '../extensions/shared_preferences_x.dart'; class PodcastLibraryService { @@ -15,7 +16,8 @@ class PodcastLibraryService { final SharedPreferences _sharedPreferences; - // This stream is currently used for downloads + // This stream is currently used for notifying whoever is listening to changes + // final _propertiesChangedController = StreamController.broadcast(); Stream get propertiesChanged => _propertiesChangedController.stream; Future notify(bool value) async => @@ -25,68 +27,52 @@ class PodcastLibraryService { /// Podcasts /// - Set get _podcasts => + Set get _subscribedPodcastFeeds => _sharedPreferences.getStringList(SPKeys.podcastFeedUrls)?.toSet() ?? {}; - Set _getFilteredPodcasts(String? filterText) { - return podcasts.where((feedUrl) { - if (filterText == null || filterText.isEmpty) return true; - final name = getSubscribedPodcastName(feedUrl); - final artist = getSubscribedPodcastArtist(feedUrl); - return (name != null && - name.toLowerCase().contains(filterText.toLowerCase())) || - (artist != null && - artist.toLowerCase().contains(filterText.toLowerCase())); - }).toSet(); - } + List getFilteredPodcastsItems(String? filterText) => podcasts + .map((feedUrl) => getPodcastItem(feedUrl)) + .whereType() + .where((item) { + if (filterText == null || filterText.isEmpty) return true; - List getFilteredPodcastsItems(String? filterText) { - final filteredFeedUrls = _getFilteredPodcasts(filterText); - final result = []; - for (final feedUrl in filteredFeedUrls) { - final metadata = getPodcastItem(feedUrl); - result.add(metadata); - } - return result; - } + final term = filterText.toLowerCase(); + final name = item.collectionName?.toLowerCase() ?? ''; + final artist = item.artistName?.toLowerCase() ?? ''; + + return name.contains(term) || artist.contains(term); + }) + .toList(); bool isPodcastSubscribed(String? feedUrl) => - feedUrl != null && _podcasts.contains(feedUrl); - List get podcastFeedUrls => _podcasts.toList(); - Set get podcasts => _podcasts; - int get podcastsLength => _podcasts.length; + feedUrl != null && _subscribedPodcastFeeds.contains(feedUrl); + List get podcastFeedUrls => _subscribedPodcastFeeds.toList(); + Set get podcasts => _subscribedPodcastFeeds; + int get podcastsLength => _subscribedPodcastFeeds.length; // Adding and removing Podcasts // ------------------ - Future addPodcast(Item item) async { - if (isPodcastSubscribed(item.feedUrl)) return; - await _addPodcastMetadata(item); - await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, [ - ...List.from(_podcasts), - item.feedUrl!, - ]); - } - - Future addPodcasts(List metadata) async { - if (metadata.isEmpty) return; - final newList = List.from(_podcasts); - for (var p in metadata) { - if (p.feedUrl != null && !newList.contains(p.feedUrl)) { - await _addPodcastMetadata(p); - newList.add(p.feedUrl!); + Future addSubscribedPodcasts(List items) async { + if (items.isEmpty) return; + final newList = List.from(_subscribedPodcastFeeds); + for (var item in items) { + if (item.feedUrl != null && !newList.contains(item.feedUrl)) { + await addPodcastData(item); + newList.add(item.feedUrl!); } } await _sharedPreferences.setStringList(SPKeys.podcastFeedUrls, newList); } - Future removePodcast(String feedUrl, {bool update = true}) async { + Future removeSubscribedPodcast( + String feedUrl, { + bool update = true, + }) async { if (!isPodcastSubscribed(feedUrl)) return; - final newList = List.from(_podcasts)..remove(feedUrl); + final newList = List.from(_subscribedPodcastFeeds)..remove(feedUrl); await _removeFeedWithDownload(feedUrl); - removeSubscribedPodcastImage(feedUrl); - removeSubscribedPodcastName(feedUrl); - removeSubscribedPodcastArtist(feedUrl); + await removeSubscribedPodcastData(feedUrl); _removePodcastLastUpdated(feedUrl); if (update) { @@ -96,98 +82,29 @@ class PodcastLibraryService { // Podcast Metadata // ------------------ - Future _addPodcastMetadata(Item item) async { - if (item.feedUrl == null) { - return; - } - if (item.bestArtworkUrl != null) { - addSubscribedPodcastImage( - feedUrl: item.feedUrl!, - imageUrl: item.bestArtworkUrl!, - ); - } - if (item.collectionName != null) { - addSubscribedPodcastName( - feedUrl: item.feedUrl!, - name: item.collectionName!, - ); - } - if (item.artistName != null) { - addSubscribedPodcastArtist( - feedUrl: item.feedUrl!, - artist: item.artistName!, - ); - } - if (item.genre != null) { - addSubscribedPodcastGenreList( - feedUrl: item.feedUrl!, - genreList: item.genre!.map((g) => g.name).toList(), - ); - } - await addPodcastLastUpdated( - feedUrl: item.feedUrl!, - timestamp: DateTime.now().podcastTimeStamp, + + Future addPodcastData(Item item) { + if (item.feedUrl == null) return Future.value(false); + final jsonString = jsonEncode(item.toJson()); + return _sharedPreferences.setString( + item.feedUrl! + SPKeys.podcastDataSuffix, + jsonString, ); } - Item getPodcastItem(String feedUrl) => Item( - feedUrl: feedUrl, - artworkUrl: getSubscribedPodcastImage(feedUrl), - collectionName: getSubscribedPodcastName(feedUrl), - artistName: getSubscribedPodcastArtist(feedUrl), - genre: getSubScribedPodcastGenreList( - feedUrl, - )?.mapIndexed((i, g) => Genre(i, g)).toList(), - ); - - // Image URL - String? getSubscribedPodcastImage(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastImageUrlSuffix); - void addSubscribedPodcastImage({ - required String feedUrl, - required String imageUrl, - }) => _sharedPreferences.setString( - feedUrl + SPKeys.podcastImageUrlSuffix, - imageUrl, - ); - void removeSubscribedPodcastImage(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastImageUrlSuffix); - - // Name - String? getSubscribedPodcastName(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastNameSuffix); - void addSubscribedPodcastName({ - required String feedUrl, - required String name, - }) => _sharedPreferences.setString(feedUrl + SPKeys.podcastNameSuffix, name); - void removeSubscribedPodcastName(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastNameSuffix); - - // Artist - String? getSubscribedPodcastArtist(String feedUrl) => - _sharedPreferences.getString(feedUrl + SPKeys.podcastArtistSuffix); - void addSubscribedPodcastArtist({ - required String feedUrl, - required String artist, - }) => _sharedPreferences.setString( - feedUrl + SPKeys.podcastArtistSuffix, - artist, - ); - void removeSubscribedPodcastArtist(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastArtistSuffix); - - // Genre List - List? getSubScribedPodcastGenreList(String feedUrl) => - _sharedPreferences.getStringList(feedUrl + SPKeys.podcastGenreListSuffix); - void addSubscribedPodcastGenreList({ - required String feedUrl, - required List genreList, - }) => _sharedPreferences.setStringList( - feedUrl + SPKeys.podcastGenreListSuffix, - genreList, - ); - void removeSubscribedPodcastGenreList(String feedUrl) => - _sharedPreferences.remove(feedUrl + SPKeys.podcastGenreListSuffix); + Item? getPodcastItem(String feedUrl) { + var string = _sharedPreferences.getString( + feedUrl + SPKeys.podcastDataSuffix, + ); + Map? json; + if (string != null) { + json = jsonDecode(string); + } + return Item.fromJson(json: json); + } + + Future removeSubscribedPodcastData(String feedUrl) => + _sharedPreferences.remove(feedUrl + SPKeys.podcastDataSuffix); // Podcast Downloads // ------------------ @@ -276,8 +193,7 @@ class PodcastLibraryService { _deleteDownload(key); await _sharedPreferences.remove(key); } - await _sharedPreferences.remove(SPKeys.podcastsWithDownloads); - _propertiesChangedController.add(true); + await _sharedPreferences.remove(SPKeys.podcastsWithDownloads).then(notify); } Future _removeFeedWithDownload(String feedUrl) async { @@ -296,10 +212,9 @@ class PodcastLibraryService { Future addPodcastLastUpdated({ required String feedUrl, required String timestamp, - }) async => _sharedPreferences.setString( - feedUrl + SPKeys.podcastLastUpdatedSuffix, - timestamp, - ); + }) async => _sharedPreferences + .setString(feedUrl + SPKeys.podcastLastUpdatedSuffix, timestamp) + .then(notify); void _removePodcastLastUpdated(String feedUrl) => _sharedPreferences.remove(feedUrl + SPKeys.podcastLastUpdatedSuffix); @@ -317,12 +232,11 @@ class PodcastLibraryService { await _sharedPreferences .setStringList(SPKeys.podcastsWithUpdates, updatedFeeds.toList()) .then( - (_) => addPodcastLastUpdated( + (v) => addPodcastLastUpdated( feedUrl: feedUrl, timestamp: lastUpdated.podcastTimeStamp, ), - ) - .then((_) => _propertiesChangedController.add(true)); + ); } Future removePodcastUpdate(String feedUrl) async { @@ -331,7 +245,7 @@ class PodcastLibraryService { final updatedFeeds = Set.from(_podcastUpdates!)..remove(feedUrl); await _sharedPreferences .setStringList(SPKeys.podcastsWithUpdates, updatedFeeds.toList()) - .then((_) => _propertiesChangedController.add(true)); + .then(notify); } // Podcast Episode Ordering @@ -342,13 +256,11 @@ class PodcastLibraryService { Future _addAscendingPodcast(String feedUrl) async { await _sharedPreferences .setBool(SPKeys.ascendingFeeds + feedUrl, true) - .then((_) => _propertiesChangedController.add(true)); + .then(notify); } Future _removeAscendingPodcast(String feedUrl) async => - _sharedPreferences - .remove(SPKeys.ascendingFeeds + feedUrl) - .then((_) => _propertiesChangedController.add(true)); + _sharedPreferences.remove(SPKeys.ascendingFeeds + feedUrl).then(notify); Future reorderPodcast({ required String feedUrl, diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 1e1f5c2..cbd20a7 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -31,7 +31,8 @@ class PodcastManager { }) : _podcastService = podcastService, _downloadService = downloadService, _podcastLibraryService = podcastLibraryService, - _notificationsService = notificationsService { + _notificationsService = notificationsService, + _searchManager = searchManager { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); }; @@ -45,7 +46,7 @@ class PodcastManager { ); // Subscription doesn't need disposal - manager lives for app lifetime - searchManager.textChangedCommand + _searchManager.textChangedCommand .debounce(const Duration(milliseconds: 500)) .listen((filterText, sub) => updateSearchCommand.run(filterText)); @@ -68,6 +69,7 @@ class PodcastManager { final PodcastLibraryService _podcastLibraryService; final DownloadService _downloadService; final NotificationsService _notificationsService; + final SearchManager _searchManager; // Map of feedUrl to fetch episodes command final _fetchEpisodeMediaCommands = @@ -137,13 +139,13 @@ class PodcastManager { } Future addPodcast(Item item) async { - await _podcastLibraryService.addPodcast(item); - getSubscribedPodcastsCommand.run(); + await _podcastLibraryService.addSubscribedPodcasts([item]); + getSubscribedPodcastsCommand.run(_searchManager.textChangedCommand.value); } Future removePodcast({required String feedUrl}) async { - await _podcastLibraryService.removePodcast(feedUrl); - getSubscribedPodcastsCommand.run(); + await _podcastLibraryService.removeSubscribedPodcast(feedUrl); + getSubscribedPodcastsCommand.run(_searchManager.textChangedCommand.value); } final Map _podcastCache = {}; @@ -174,18 +176,6 @@ class PodcastManager { } } - if (podcast?.image != null) { - _podcastLibraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: podcast!.image!, - ); - } else if (item?.bestArtworkUrl != null) { - _podcastLibraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: item!.bestArtworkUrl!, - ); - } - return podcast?.toEpisodeMediaList(url, item) ?? []; } @@ -206,7 +196,9 @@ class PodcastManager { } on Exception catch (e) { printMessageInDebugMode(e); } - final name = _podcastLibraryService.getSubscribedPodcastName(feedUrl); + final name = _podcastLibraryService + .getPodcastItem(feedUrl) + ?.collectionName; printMessageInDebugMode('checking update for: ${name ?? feedUrl} '); printMessageInDebugMode( diff --git a/pubspec.lock b/pubspec.lock index 37aff55..90a5c8a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -198,10 +198,10 @@ packages: dependency: transitive description: name: command_it - sha256: "838052aabbf66e403aee3af195636e580b90c479befda1acec16569a92036f42" + sha256: "4f1cd9131565e0d03569213b40180d7ea12830cc31fcb563a8081cd9fdddec3b" url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.4.2" convert: dependency: transitive description: @@ -395,10 +395,10 @@ packages: dependency: "direct main" description: name: flutter_it - sha256: "81fe23d007abd87f26a3dec0b52ad2772ecd3b6d3c8f7e6e1daa24c04c9b1a2d" + sha256: "12ad0696a2aaabbe9e86eee9b296f8906e043d31e0283b09c3a12c309d6affac" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -466,10 +466,10 @@ packages: dependency: transitive description: name: get_it - sha256: "368c1abda38084fb5c0c280bfdd5e4ddb010eaa022ff3e953e8b503f7b334b7d" + sha256: "2c120112e34bbd5f4ce2ca1ad3076a5b2068eb1e48aae8dd7b717b26cdd38543" url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "9.1.1" gsettings: dependency: transitive description: @@ -586,10 +586,10 @@ packages: dependency: transitive description: name: listen_it - sha256: "516bfd486ca6fcb5d73c5640ed59e3c281a2871ee11863ffd4713e581b11c8d0" + sha256: "461f46cc86e9c3be0d679f1ee4d94c8893c0a9ce42d0aad4474fe7e154e62c4a" url: "https://pub.dev" source: hosted - version: "5.3.3" + version: "5.3.4" local_notifier: dependency: "direct main" description: @@ -1355,10 +1355,10 @@ packages: dependency: transitive description: name: watch_it - sha256: "22ca46b22da37d3c0b8d1ffb59d1f98715509e4ff47312de36459d76f20478ec" + sha256: "561910e534af41dda7c9bce2eca2b3d65c48aa88b6570964b3dcb29fb1cc6dc3" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.1" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26a788c..8e90884 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: flutter: sdk: flutter flutter_cache_manager: ^3.4.1 - flutter_it: ^2.0.0 + flutter_it: ^2.0.1 flutter_localizations: sdk: flutter flutter_tabler_icons: ^1.43.0