diff --git a/ARCHITECTURE_CHANGES.md b/ARCHITECTURE_CHANGES.md deleted file mode 100644 index 5c54eb8..0000000 --- a/ARCHITECTURE_CHANGES.md +++ /dev/null @@ -1,521 +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. Episode Cache Migration - -**What**: Moved episode and description caching from PodcastService to PodcastManager - -**Why**: Aligns with architecture pattern - Services are stateless, Managers handle state and caching - -**Changes in PodcastService**: -- ❌ Removed `_episodeCache` map -- ❌ Removed `_podcastDescriptionCache` map -- ❌ Removed `getPodcastEpisodesFromCache()` method -- ❌ Removed `getPodcastDescriptionFromCache()` method -- ❌ Removed `loadFromCache` parameter -- ✅ Changed `findEpisodes()` to return record: `({List episodes, String? description})` - -**Changes in PodcastManager**: -```dart -// Episode cache - ensures same instances across app for command state -final _episodeCache = >{}; -final _podcastDescriptionCache = {}; - -// Updated fetchEpisodeMediaCommand to cache both episodes and description -fetchEpisodeMediaCommand = Command.createAsync>( - (podcast) async { - final feedUrl = podcast.feedUrl; - if (feedUrl == null) return []; - - // Check cache first - returns same instances so downloadCommands work - if (_episodeCache.containsKey(feedUrl)) { - return _episodeCache[feedUrl]!; - } - - // Fetch from service - destructure the record - final result = await _podcastService.findEpisodes(item: podcast); - - // Cache both episodes and description - _episodeCache[feedUrl] = result.episodes; - _podcastDescriptionCache[feedUrl] = result.description; - - return result.episodes; - }, - initialValue: [], -); - -// New method to get cached description -String? getPodcastDescription(String? feedUrl) => - _podcastDescriptionCache[feedUrl]; -``` - -**Benefits**: -- ✅ Service is now truly stateless (only operations, no caching) -- ✅ Manager owns all caching logic in one place -- ✅ Same episode instances returned from cache (ensures downloadCommands work correctly) -- ✅ Description cached alongside episodes for efficiency - -**Updated UI**: -- `podcast_page.dart` now calls `di().getPodcastDescription(feedUrl)` -- `podcast_card.dart` destructures the record: `final episodes = result.episodes` - ---- - -### 7. Moved checkForUpdates() to PodcastManager & Converted to Command - -**What**: Moved podcast update checking from PodcastService to PodcastManager and converted to Command pattern - -**Why**: -- checkForUpdates() needs to invalidate/refresh the episode cache (owned by PodcastManager) -- Commands provide built-in execution management (no manual lock needed) -- Consistent with other manager patterns -- UI can watch command state directly - -**Changes in PodcastService**: -- ❌ Removed `checkForUpdates()` method -- ❌ Removed `_updateLock` field -- ❌ Removed `NotificationsService` dependency (no longer needed) - -**Changes in PodcastManager**: -- ✅ Added `checkForUpdatesCommand` (Command with record parameters) -- ✅ Removed `_updateLock` field (Command handles this with `isRunning`) -- ✅ Added `NotificationsService` dependency -- ✅ Fixed bug: Episodes are now fetched when updates detected (was accidentally removed) -- ✅ Restored podcast name in single-update notifications - -**Command Definition**: -```dart -late Command< - ({ - Set? feedUrls, - String updateMessage, - String Function(int) multiUpdateMessage - }), - void> checkForUpdatesCommand; - -// Initialized in constructor: -checkForUpdatesCommand = Command.createAsync<...>((params) async { - final newUpdateFeedUrls = {}; - - for (final feedUrl in (params.feedUrls ?? _podcastLibraryService.podcasts)) { - // Check for updates... - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - // Fetch episodes to refresh cache using runAsync - await fetchEpisodeMediaCommand.runAsync(Item(feedUrl: feedUrl)); - - await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - newUpdateFeedUrls.add(feedUrl); - } - } - - if (newUpdateFeedUrls.isNotEmpty) { - // Include podcast name in single-update notification - final podcastName = newUpdateFeedUrls.length == 1 - ? _podcastLibraryService.getSubscribedPodcastName(newUpdateFeedUrls.first) - : null; - final msg = newUpdateFeedUrls.length == 1 - ? '${params.updateMessage}${podcastName != null ? ' $podcastName' : ''}' - : params.multiUpdateMessage(newUpdateFeedUrls.length); - await _notificationsService.notify(message: msg); - } -}, initialValue: null); -``` - -**Usage**: -```dart -// Run the command: -podcastManager.checkForUpdatesCommand.run(( - feedUrls: null, // or specific set - updateMessage: 'New episode available', - multiUpdateMessage: (count) => '$count new episodes available' -)); - -// UI can watch command state: -watch(podcastManager.checkForUpdatesCommand.isRunning).value -``` - -**Benefits**: -- ✅ No manual lock needed (Command prevents concurrent execution automatically) -- ✅ UI can watch `command.isRunning` for loading state -- ✅ Consistent with other manager commands -- ✅ Manager owns cache invalidation logic -- ✅ Service remains stateless -- ✅ Bug fixed: Episodes fetched when updates detected -- ✅ More informative notifications (includes podcast name) - -**Note**: `checkForUpdatesCommand` is fully implemented but not yet called anywhere in the app (planned future feature with UI integration pending). - ---- - -### 8. UI Simplification - -**Before**: -```dart -final isDownloaded = watchPropertyValue( - (DownloadService m) => m.isDownloaded(episode.url), -); -if (isDownloaded) { - final download = di().getDownload(episode.url); - episode.copyWithX(resource: download!); -} -``` - -**After**: -```dart -final progress = watch(episode.downloadCommand.progress).value; -final isDownloaded = progress == 1.0; - -// Episode resource is already correct - just use it directly -di().setPlaylist([episode]); -``` - ---- - -## Architecture Pattern - -``` -┌─────────────────┐ -│ EpisodeMedia │ ← Owns downloadCommand with progress/cancellation -│ - downloadCommand -│ - isDownloaded -└─────────────────┘ - │ - ├──→ DownloadService (stateless operations) - │ - └──→ PodcastManager.activeDownloads (tracks active) -``` - -**State Flow**: -1. User clicks download → `episode.downloadCommand.run()` -2. Command adds to `activeDownloads` → UI shows in list -3. Command calls `DownloadService.download()` with progress callback -4. Progress updates via `handle.updateProgress()` → UI shows indicator -5. On success: removes from `activeDownloads`, progress stays 1.0 -6. On error: removes from `activeDownloads`, routes to global error stream - ---- - -### 8. Toggle Commands for UI Actions - -**What**: Converted direct async method calls from UI to command pattern - -**Why**: Consistent architecture, eliminates async/await in UI, enables reactive state management - -**Changes**: - -#### PodcastManager.togglePodcastCommand -```dart -late Command togglePodcastCommand; - -togglePodcastCommand = Command.createAsync((item) async { - final feedUrl = item.feedUrl; - if (feedUrl == null) return; - - final isSubscribed = _podcastLibraryService.podcasts.contains(feedUrl); - - if (isSubscribed) { - await removePodcast(feedUrl: feedUrl); - } else { - await addPodcast(PodcastMetadata( - feedUrl: feedUrl, - name: item.collectionName, - imageUrl: item.bestArtworkUrl, - )); - } -}, initialValue: null); -``` - -**Benefits**: -- UI passes full `Item` object, not constructed `PodcastMetadata` -- Command handles state checking and metadata extraction -- Single toggle operation instead of separate add/remove buttons -- No async/await in UI button handlers - -**Updated**: `lib/podcasts/view/podcast_favorite_button.dart` -```dart -onPressed: () => di() - .togglePodcastCommand - .run(podcastItem), -``` - -#### RadioManager.toggleFavoriteStationCommand -```dart -late Command toggleFavoriteStationCommand; - -toggleFavoriteStationCommand = Command.createAsync( - (stationUuid) async { - final isFavorite = - _radioLibraryService.favoriteStations.contains(stationUuid); - - if (isFavorite) { - await removeFavoriteStation(stationUuid); - } else { - await addFavoriteStation(stationUuid); - } - }, - initialValue: null, -); -``` - -**Updated**: `lib/radio/view/radio_browser_station_star_button.dart` -```dart -onPressed: () => di() - .toggleFavoriteStationCommand - .run(media.id), -``` - -#### EpisodeMedia.deleteDownloadCommand -```dart -late final deleteDownloadCommand = _createDeleteDownloadCommand(); - -Command _createDeleteDownloadCommand() { - return Command.createAsyncNoParamNoResult(() async { - await di().deleteDownload(media: this); - downloadCommand.resetProgress(progress: 0.0); - }, errorFilter: const LocalAndGlobalErrorFilter()); -} -``` - -**Updated**: `lib/podcasts/view/download_button.dart` -```dart -if (isDownloaded) { - episode.deleteDownloadCommand.run(); -} -``` - -**Pattern**: Entity-level command (on `EpisodeMedia`) alongside `downloadCommand` - ---- - -## Key Files Changed - -### Core Changes -- **lib/player/data/episode_media.dart** - Added downloadCommand, deleteDownloadCommand, factory constructor -- **lib/podcasts/podcast_manager.dart** - Added activeDownloads ListNotifier, episode/description caching, checkForUpdatesCommand, togglePodcastCommand -- **lib/radio/radio_manager.dart** - Added toggleFavoriteStationCommand -- **lib/podcasts/podcast_service.dart** - Now stateless (removed caching, checkForUpdates, returns record with episodes + description) -- **lib/podcasts/download_service.dart** - Removed state, kept operations only -- **lib/app/home.dart** - Added Command.globalErrors handler -- **lib/register_dependencies.dart** - Updated service registrations (moved NotificationsService from PodcastService to PodcastManager) - -### UI Updates -- **lib/podcasts/view/download_button.dart** - Uses deleteDownloadCommand.run(), watches command progress/isRunning -- **lib/podcasts/view/podcast_favorite_button.dart** - Uses togglePodcastCommand.run() instead of direct add/remove calls -- **lib/radio/view/radio_browser_station_star_button.dart** - Uses toggleFavoriteStationCommand.run() instead of direct add/remove calls -- **lib/podcasts/view/recent_downloads_button.dart** - Watch activeDownloads -- **lib/podcasts/view/podcast_card.dart** - Uses fetchEpisodeMediaCommand with registerHandler for reactive loading dialog -- **lib/podcasts/view/podcast_page.dart** - Uses PodcastManager.getPodcastDescription -- **lib/podcasts/view/podcast_page_episode_list.dart** - Use episode.isDownloaded - -### Removed -- messageStream and downloadMessageStreamHandler (replaced by Command.globalErrors) -- DownloadService.isDownloaded() method (use episode.isDownloaded) -- All copyWithX() calls for setting local resource (automatic now) -- PodcastService caching (moved to PodcastManager): _episodeCache, _podcastDescriptionCache, getPodcastEpisodesFromCache(), getPodcastDescriptionFromCache() -- PodcastService.checkForUpdates() and _updateLock (converted to checkForUpdatesCommand) -- PodcastManager._updateLock field (replaced by Command.isRunning) -- NotificationsService dependency from PodcastService (moved to PodcastManager) - ---- - -## Dependencies - -- **command_it**: ^9.4.1 (progress tracking, cancellation, global errors) -- **listen_it**: (ListNotifier for activeDownloads) -- **dio**: (CancelToken for download cancellation) - ---- - -## Benefits - -1. **Simpler State Management**: Commands own their state, no separate registry -2. **Better UI Integration**: Watch command properties directly -3. **Automatic Resource Handling**: Episodes "just work" with local paths -4. **Single Lookup**: Only call getDownload() once during construction -5. **Type Safety**: All command properties are ValueListenable -6. **Cancellation**: Built-in cooperative cancellation support -7. **Error Handling**: Global error stream for user notifications -8. **Progress Tracking**: Real-time progress updates via handle.updateProgress() - ---- - -## Migration Notes - -**For Future Features**: -- To add download functionality to new media types, add downloadCommand to their class -- Use same pattern: command adds/removes from activeDownloads -- Set errorFilter to LocalAndGlobalErrorFilter for user notifications -- Initialize progress to 1.0 if already downloaded on creation diff --git a/CLAUDE.md b/CLAUDE.md 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/app/app.dart b/lib/app/app.dart index c933468..b91aeb1 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_it/flutter_it.dart'; import '../extensions/color_x.dart'; import '../l10n/app_localizations.dart'; import '../player/player_manager.dart'; +import 'router.dart'; class App extends StatelessWidget with WatchItMixin { const App({ @@ -28,7 +29,7 @@ class App extends StatelessWidget with WatchItMixin { final playerColor = watchValue( (PlayerManager s) => s.playerViewState.select((e) => e.color), ); - return MaterialApp( + return MaterialApp.router( debugShowCheckedModeBanner: false, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -47,7 +48,7 @@ class App extends StatelessWidget with WatchItMixin { darkTheme?.colorScheme.primary, ), ), - home: child, + routerConfig: router, ); } } diff --git a/lib/app/home.dart b/lib/app/home.dart index 32ae7f5..4fd6422 100644 --- a/lib/app/home.dart +++ b/lib/app/home.dart @@ -5,13 +5,13 @@ import 'package:yaru/yaru.dart'; import '../collection/view/collection_view.dart'; import '../common/view/ui_constants.dart'; 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/view/recent_downloads_button.dart'; import '../search/view/search_view.dart'; import '../settings/view/settings_dialog.dart'; +final _selectedHomeTabIndex = ValueNotifier(0); + class Home extends StatelessWidget with WatchItMixin { const Home({super.key}); @@ -29,53 +29,54 @@ class Home extends StatelessWidget with WatchItMixin { }, ); - final playerFullWindowMode = watchValue( - (PlayerManager m) => m.playerViewState.select((e) => e.fullMode), - ); - - if (playerFullWindowMode) return const PlayerFullView(); - - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: YaruWindowTitleBar( - border: BorderSide.none, - titleSpacing: 0, - title: SizedBox( - width: 450, - child: TabBar( - tabs: [ - Tab(text: context.l10n.search), - Tab(text: context.l10n.collection), - ], + return ValueListenableBuilder( + valueListenable: _selectedHomeTabIndex, + builder: (context, value, child) { + return DefaultTabController( + length: 2, + initialIndex: value, + child: Scaffold( + appBar: YaruWindowTitleBar( + border: BorderSide.none, + titleSpacing: 0, + title: SizedBox( + width: 450, + child: TabBar( + onTap: (index) => _selectedHomeTabIndex.value = index, + tabs: [ + Tab(text: context.l10n.search), + Tab(text: context.l10n.collection), + ], + ), + ), + actions: + [ + const RecentDownloadsButton(), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => const SettingsDialog(), + ), + icon: const Icon(Icons.settings), + ), + ] + .map( + (e) => Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: kSmallPadding, + ), + child: e, + ), + ), + ) + .toList(), ), + body: const TabBarView(children: [SearchView(), CollectionView()]), + bottomNavigationBar: const PlayerView(), ), - actions: - [ - const RecentDownloadsButton(), - IconButton( - onPressed: () => showDialog( - context: context, - builder: (context) => const SettingsDialog(), - ), - icon: const Icon(Icons.settings), - ), - ] - .map( - (e) => Flexible( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: kSmallPadding, - ), - child: e, - ), - ), - ) - .toList(), - ), - body: const TabBarView(children: [SearchView(), CollectionView()]), - bottomNavigationBar: const PlayerView(), - ), + ); + }, ); } } diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..6d2b169 --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,56 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:podcast_search/podcast_search.dart'; + +import '../player/data/station_media.dart'; +import '../player/view/player_full_view.dart'; +import '../podcasts/view/podcast_page.dart'; +import '../radio/view/station_page.dart'; +import 'home.dart'; + +final _rootNavigatorKey = GlobalKey(); +final _shellNavigatorKey = GlobalKey(); + +final router = GoRouter( + initialLocation: '/', + navigatorKey: _rootNavigatorKey, + routes: [ + ShellRoute( + navigatorKey: _shellNavigatorKey, + builder: (context, state, child) => child, + routes: [ + GoRoute( + parentNavigatorKey: _shellNavigatorKey, + path: '/', + pageBuilder: (_, _) => const NoTransitionPage(child: Home()), + ), + GoRoute( + parentNavigatorKey: _shellNavigatorKey, + path: '/player', + pageBuilder: (_, _) => + const NoTransitionPage(child: PlayerFullView()), + ), + GoRoute( + parentNavigatorKey: _shellNavigatorKey, + path: '/podcast/:feedUrl', + pageBuilder: (_, state) => NoTransitionPage( + child: PodcastPage( + feedUrl: state.pathParameters['feedUrl']!, + podcastItem: state.extra as Item?, + ), + ), + ), + GoRoute( + parentNavigatorKey: _shellNavigatorKey, + path: '/station/:uuid', + pageBuilder: (_, state) => NoTransitionPage( + child: StationPage( + uuid: state.pathParameters['uuid']!, + stationMedia: state.extra as StationMedia, + ), + ), + ), + ], + ), + ], +); diff --git a/lib/common/view/tap_able_text.dart b/lib/common/view/tap_able_text.dart new file mode 100644 index 0000000..7db420c --- /dev/null +++ b/lib/common/view/tap_able_text.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import '../../extensions/build_context_x.dart'; + +class TapAbleText extends StatelessWidget { + const TapAbleText({ + super.key, + this.onTap, + required this.text, + this.style, + this.maxLines, + this.overflow = TextOverflow.ellipsis, + this.wrapInFlexible = true, + }); + + final void Function()? onTap; + final String text; + final TextStyle? style; + final int? maxLines; + final TextOverflow overflow; + final bool wrapInFlexible; + + @override + Widget build(BuildContext context) { + final theme = context.theme; + + final inkWell = InkWell( + hoverColor: (style?.color ?? theme.colorScheme.primary).withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(4), + onTap: onTap == null ? null : () => onTap!(), + child: Text( + text, + style: style, + maxLines: maxLines ?? 1, + overflow: overflow, + ), + ); + + return wrapInFlexible + ? Row( + children: [Flexible(fit: FlexFit.loose, child: inkWell)], + ) + : inkWell; + } +} diff --git a/lib/common/view/ui_constants.dart b/lib/common/view/ui_constants.dart index 0345ee9..d0cc9b9 100644 --- a/lib/common/view/ui_constants.dart +++ b/lib/common/view/ui_constants.dart @@ -21,6 +21,8 @@ const kPlayerInfoWidth = 180.0; const kPlayerTrackHeight = 4.0; +const kAudioTrackWidth = 40.0; + const windowOptions = WindowOptions( size: Size(1024, 800), minimumSize: Size(400, 600), diff --git a/lib/online_art/online_art_model.dart b/lib/online_art/online_art_model.dart new file mode 100644 index 0000000..d761424 --- /dev/null +++ b/lib/online_art/online_art_model.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'online_art_service.dart'; + +class OnlineArtModel extends ChangeNotifier { + OnlineArtModel({required OnlineArtService onlineArtService}) + : _onlineArtService = onlineArtService { + _propertiesChangedSub ??= _onlineArtService.propertiesChanged.listen( + (_) => notifyListeners(), + ); + } + + final OnlineArtService _onlineArtService; + StreamSubscription? _propertiesChangedSub; + String? getCover(String icyTitle) => _onlineArtService.get(icyTitle); + + @override + Future dispose() async { + await _propertiesChangedSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/player/data/episode_media.dart b/lib/player/data/episode_media.dart index 7249fb2..100c895 100644 --- a/lib/player/data/episode_media.dart +++ b/lib/player/data/episode_media.dart @@ -1,54 +1,13 @@ 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 { - // Factory constructor that checks for persisted downloads - factory EpisodeMedia( - String resource, { - Map? extras, - Map? httpHeaders, - Duration? start, - Duration? end, - required Episode episode, - required String feedUrl, - int? bitRate, - String? albumArtUrl, - List genres = const [], - String? collectionName, - String? artist, - }) { - // Check if episode was previously downloaded (persisted in SharedPreferences) - final downloadPath = di().getDownload(episode.contentUrl); - - return EpisodeMedia._( - resource, // Always use original URL as resource - downloadPath: downloadPath, - extras: extras, - httpHeaders: httpHeaders, - start: start, - end: end, - episode: episode, - feedUrl: feedUrl, - bitRate: bitRate, - albumArtUrl: albumArtUrl, - genres: genres, - collectionName: collectionName, - artist: artist, - ); - } - - // Private constructor that receives pre-computed values - EpisodeMedia._( + EpisodeMedia( super.resource, { - String? downloadPath, super.extras, super.httpHeaders, super.start, @@ -65,13 +24,8 @@ class EpisodeMedia extends UniqueMedia { _albumArtUrl = albumArtUrl, _genres = genres, _collectionName = collectionName, - _artist = artist, - _downloadPath = downloadPath; + _artist = artist; - /// Path to downloaded file, or null if not downloaded. - /// Updated by downloadCommand (on success) and deleteDownloadCommand (clears it). - String? _downloadPath; - String? get downloadPath => _downloadPath; final Episode episode; final String _feedUrl; final int? _bitRate; @@ -165,72 +119,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 - bool get isDownloaded => _downloadPath != null; - - // Download command with progress and cancellation support - late final downloadCommand = (() { - final command = - Command.createAsyncNoParamNoResultWithProgress((handle) async { - // 1. Add to active downloads - di().registerActiveDownload(this); - - // 2. Create CancelToken - final cancelToken = CancelToken(); - - // 3. Listen to cancellation and forward to Dio - handle.isCanceled.listen((canceled, subscription) { - if (canceled) { - cancelToken.cancel(); - subscription.cancel(); - } - }); - - // 4. Download with progress updates - final path = await di().download( - episode: this, - cancelToken: cancelToken, - onProgress: (received, total) { - handle.updateProgress(received / total); - }, - ); - - // 5. Set download path on success - _downloadPath = path; - - // 6. Keep in active downloads so user can see completed downloads - // (will be removed when user deletes or starts new session) - }, errorFilter: const LocalAndGlobalErrorFilter()) - ..errors.listen((error, subscription) { - // Error handler: remove from active downloads - di().unregisterActiveDownload(this); - }); - - // Initialize progress to 1.0 if already downloaded - if (_downloadPath != null) { - command.resetProgress(progress: 1.0); - } - - return command; - })(); - - // Delete download command with optimistic update for progress UI - late final deleteDownloadCommand = - Command.createAsyncNoParamNoResult(() async { - // Optimistic: reset progress immediately for instant UI feedback - downloadCommand.resetProgress(progress: 0.0); - - // Delete async - await di().deleteDownload(media: this); - - // Clear downloadPath only after successful delete - _downloadPath = null; - }, errorFilter: const LocalAndGlobalErrorFilter()) - ..errors.listen((error, _) { - // Rollback progress on error - if (error != null) { - downloadCommand.resetProgress(progress: 1.0); - } - }); } diff --git a/lib/player/player_manager.dart b/lib/player/player_manager.dart index d0633cc..eef8ea5 100644 --- a/lib/player/player_manager.dart +++ b/lib/player/player_manager.dart @@ -7,7 +7,6 @@ import 'package:media_kit_video/media_kit_video.dart'; import '../common/logging.dart'; import '../extensions/color_x.dart'; -import 'data/episode_media.dart'; import 'data/unique_media.dart'; import 'view/player_view_state.dart'; @@ -235,16 +234,7 @@ class PlayerManager extends BaseAudioHandler with SeekHandler { }) async { if (mediaList.isEmpty) return; updateState(resetRemoteSource: true); - - // Use download path if available for episodes - final resolvedList = mediaList.map((media) { - if (media is EpisodeMedia && media.downloadPath != null) { - return media.copyWithX(resource: media.downloadPath!); - } - return media; - }).toList(); - - await _player.open(Playlist(resolvedList, index: index), play: play); + await _player.open(Playlist(mediaList, index: index), play: play); } Future addToPlaylist(UniqueMedia media) async => _player.add(media); diff --git a/lib/player/view/play_media_button.dart b/lib/player/view/play_media_button.dart new file mode 100644 index 0000000..225c487 --- /dev/null +++ b/lib/player/view/play_media_button.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; + +import '../../extensions/build_context_x.dart'; +import '../data/unique_media.dart'; +import '../player_manager.dart'; + +class PlayMediasButton extends StatelessWidget { + const PlayMediasButton({super.key, required this.medias}); + + final List medias; + + @override + Widget build(BuildContext context) => IconButton( + style: IconButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: context.theme.colorScheme.primaryContainer, + ), + onPressed: () { + final player = di(); + if (player.isPlaying && + medias.any((media) => media.id == player.currentMedia?.id)) { + player.pause(); + } else { + if (medias.any((media) => media.id == player.currentMedia?.id)) { + player.playOrPause(); + } else { + player.setPlaylist(medias); + } + } + }, + icon: _PlayPageIcon(medias), + ); +} + +class _PlayPageIcon extends StatelessWidget with WatchItMixin { + const _PlayPageIcon(this.medias); + + final List medias; + + @override + Widget build(BuildContext context) { + final isPlaying = + watchStream( + (PlayerManager p) => p.isPlayingStream, + initialValue: di().isPlaying, + preserveState: false, + allowStreamChange: true, + ).data ?? + false; + + final queueContainsCurrentMedia = medias.any( + (media) => media.id == di().currentMedia?.id, + ); + + return Icon( + isPlaying && queueContainsCurrentMedia ? Icons.pause : Icons.play_arrow, + ); + } +} diff --git a/lib/player/view/player_control_mixin.dart b/lib/player/view/player_control_mixin.dart index 988b996..91548fe 100644 --- a/lib/player/view/player_control_mixin.dart +++ b/lib/player/view/player_control_mixin.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; import '../player_manager.dart'; -import 'player_full_view.dart'; mixin PlayerControlMixin { Future togglePlayerFullMode(BuildContext context) async { - if (di().playerViewState.value.fullMode) { - di().updateState(fullMode: false); - Navigator.of(context).popUntil((e) => e.isFirst); + final playerManager = di(); + final isFullMode = playerManager.playerViewState.value.fullMode; + if (isFullMode) { + playerManager.updateState(fullMode: false); + if (context.canPop()) { + context.pop(); + } } else { - di().updateState(fullMode: true); - await showDialog( - fullscreenDialog: true, - context: context, - builder: (context) => const PlayerFullView(), - ); + playerManager.updateState(fullMode: true); + await context.push('/player'); } } } 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..857b274 --- /dev/null +++ b/lib/player/view/player_podcast_favorite_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; + +import '../../podcasts/data/podcast_metadata.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() => + di().togglePodcastSubscriptionCommand.run( + PodcastMetadata( + feedUrl: episodeMedia.feedUrl, + name: episodeMedia.collectionName, + imageUrl: episodeMedia.collectionArtUrl, + genreList: episodeMedia.genres, + artist: episodeMedia.artist, + description: episodeMedia.description, + ), + ); + + 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_queue.dart b/lib/player/view/player_queue.dart index 6f790f9..57346d0 100644 --- a/lib/player/view/player_queue.dart +++ b/lib/player/view/player_queue.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import '../../common/view/tap_able_text.dart'; import '../../common/view/theme.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; +import '../../podcasts/view/podcast_page.dart'; +import '../../radio/view/station_page.dart'; +import '../data/episode_media.dart'; +import '../data/station_media.dart'; import '../player_manager.dart'; class PlayerQueue extends StatefulWidget with WatchItStatefulWidgetMixin { @@ -80,9 +85,20 @@ class _PlayerQueueState extends State { child: ListTile( onTap: () => di().jump(index), leading: Text('${index + 1}'), - title: Text( - media.title?.unEscapeHtml ?? 'Unknown', + title: TapAbleText( + text: media.title?.unEscapeHtml ?? 'Unknown', maxLines: 2, + onTap: () => switch (media) { + StationMedia s => StationPage.go( + context, + media: s, + ), + EpisodeMedia e => PodcastPage.go( + context, + media: e, + ), + _ => null, + }, ), subtitle: Text( media.artist ?? 'Unknown', diff --git a/lib/player/view/player_track_info.dart b/lib/player/view/player_track_info.dart index 017e0f6..d87296c 100644 --- a/lib/player/view/player_track_info.dart +++ b/lib/player/view/player_track_info.dart @@ -1,13 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import '../../common/view/tap_able_text.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/duration_x.dart'; +import '../../podcasts/view/podcast_page.dart'; import '../../radio/view/radio_browser_station_star_button.dart'; +import '../../radio/view/station_page.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({ @@ -31,6 +36,7 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { (PlayerManager p) => p.currentMediaStream, initialValue: di().currentMedia, preserveState: false, + allowStreamChange: true, ).data; if (media == null) { @@ -44,15 +50,17 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { ); return InkWell( - onTap: () => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: CopyClipboardContent( - text: media is StationMedia && remoteTitle != null - ? remoteTitle - : '${media.artist ?? 'Unknown'} - ${media.title ?? 'Unknown'}', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: CopyClipboardContent( + text: media is StationMedia && remoteTitle != null + ? remoteTitle + : '${media.artist ?? 'Unknown'} - ${media.title ?? 'Unknown'}', + ), ), - ), - ), + ); + }, child: Row( spacing: kSmallPadding, mainAxisSize: MainAxisSize.min, @@ -62,11 +70,19 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: crossAxisAlignment, children: [ - Text( - (media is! StationMedia + TapAbleText( + text: + (media is! StationMedia ? media.collectionName : media.title) ?? 'Unknown', + onTap: () { + if (media is StationMedia) { + StationPage.go(context, media: media); + } else if (media is EpisodeMedia) { + PodcastPage.go(context, media: media); + } + }, maxLines: 1, style: (artistStyle ?? textTheme.labelSmall)?.copyWith( color: textColor, @@ -90,7 +106,10 @@ class PlayerTrackInfo extends StatelessWidget with WatchItMixin { ], ), ), - if (media is StationMedia) const RadioStationStarButton(), + if (media is StationMedia) + const RadioStationStarButton() + else if (media is EpisodeMedia) + PlayerPodcastFavoriteButton(episodeMedia: media), ], ), ); diff --git a/lib/podcasts/data/podcast_metadata.dart b/lib/podcasts/data/podcast_metadata.dart index 8e6fab2..2d4359f 100644 --- a/lib/podcasts/data/podcast_metadata.dart +++ b/lib/podcasts/data/podcast_metadata.dart @@ -6,6 +6,7 @@ class PodcastMetadata { this.imageUrl, this.name, this.artist, + this.description, this.genreList, }); @@ -13,9 +14,10 @@ class PodcastMetadata { final String? imageUrl; final String? name; final String? artist; + final String? description; final List? genreList; - factory PodcastMetadata.fromItem(Item item) { + factory PodcastMetadata.fromItem(Item item, {String? description}) { if (item.feedUrl == null) { throw ArgumentError('Item must have a valid, non null feedUrl!'); } @@ -24,7 +26,7 @@ class PodcastMetadata { name: item.collectionName, artist: item.artistName, imageUrl: item.bestArtworkUrl, - genreList: item.genre?.map((e) => e.name).toList() ?? [], + description: description, ); } } diff --git a/lib/podcasts/data/podcast_proxy.dart b/lib/podcasts/data/podcast_proxy.dart deleted file mode 100644 index e9e3102..0000000 --- a/lib/podcasts/data/podcast_proxy.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter_it/flutter_it.dart'; -import 'package:podcast_search/podcast_search.dart'; - -import '../../player/data/episode_media.dart'; -import '../../player/player_manager.dart'; -import '../podcast_service.dart'; - -/// Wraps Item (search result) and lazily loads Podcast + episodes. -/// Each podcast owns its own fetchEpisodesCommand. -class PodcastProxy { - PodcastProxy({ - required this.item, - PodcastService? podcastService, - PlayerManager? playerManager, - }) : _podcastService = podcastService ?? di(), - _playerManager = playerManager ?? di() { - fetchEpisodesCommand = Command.createAsyncNoParam>( - () async { - if (_episodes != null) return _episodes!; - - final result = await _podcastService.findEpisodes(item: item); - _podcast = result.podcast; - _episodes = result.episodes; - return _episodes!; - }, - initialValue: [], - ); - } - - final Item item; - final PodcastService _podcastService; - final PlayerManager _playerManager; - - Podcast? _podcast; - List? _episodes; - - late final Command> fetchEpisodesCommand; - - /// Fetches episodes if not cached, then starts playback. - late final playEpisodesCommand = Command.createAsyncNoResult(( - startIndex, - ) async { - // Fetch if not cached - if (_episodes == null) { - await fetchEpisodesCommand.runAsync(); - } - - if (_episodes != null && _episodes!.isNotEmpty) { - await _playerManager.setPlaylist(_episodes!, index: startIndex); - } - }); - - /// Clears cached episodes to force re-fetch on next command run. - void clearEpisodeCache() { - _episodes = null; - } - - // Getters - use Podcast if loaded, fall back to Item - String get feedUrl => item.feedUrl!; - String? get description => _podcast?.description; - String? get title => _podcast?.title ?? item.collectionName; - String? get image => _podcast?.image ?? item.bestArtworkUrl; - List get episodes => _episodes ?? []; -} diff --git a/lib/podcasts/download_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 ae57cb7..33f7b31 100644 --- a/lib/podcasts/podcast_library_service.dart +++ b/lib/podcasts/podcast_library_service.dart @@ -1,8 +1,6 @@ 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'; @@ -16,7 +14,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,17 +39,20 @@ class PodcastLibraryService { }).toSet(); } - List getFilteredPodcastItems(String? filterText) { + List getFilteredPodcastsItems(String? filterText) { final filteredFeedUrls = _getFilteredPodcasts(filterText); - final result = []; + final result = []; for (final feedUrl in filteredFeedUrls) { - final metadata = getPodcastItem(feedUrl); - result.add(metadata); + final metadata = getSubScribedPodcastMetadata(feedUrl); + if (metadata != null) { + result.add(metadata); + } } 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; @@ -96,45 +97,41 @@ class PodcastLibraryService { // Podcast Metadata // ------------------ - Future _addPodcastMetadata(PodcastMetadata metadata) async { - if (metadata.imageUrl != null) { + Future _addPodcastMetadata(PodcastMetadata item) async { + if (item.imageUrl != null) { addSubscribedPodcastImage( - feedUrl: metadata.feedUrl, - imageUrl: metadata.imageUrl!, + feedUrl: item.feedUrl, + imageUrl: item.imageUrl!, ); } - if (metadata.name != null) { - addSubscribedPodcastName(feedUrl: metadata.feedUrl, name: metadata.name!); + if (item.name != null) { + addSubscribedPodcastName(feedUrl: item.feedUrl, name: item.name!); } - if (metadata.artist != null) { - addSubscribedPodcastArtist( - feedUrl: metadata.feedUrl, - artist: metadata.artist!, - ); + if (item.artist != null) { + addSubscribedPodcastArtist(feedUrl: item.feedUrl, artist: item.artist!); } - if (metadata.genreList != null) { + if (item.genreList != null) { addSubscribedPodcastGenreList( - feedUrl: metadata.feedUrl, - genreList: metadata.genreList!, + feedUrl: item.feedUrl, + genreList: item.genreList!, ); } await addPodcastLastUpdated( - feedUrl: metadata.feedUrl, + feedUrl: item.feedUrl, timestamp: DateTime.now().podcastTimeStamp, ); } - Item getPodcastItem(String feedUrl) => Item( - feedUrl: feedUrl, - artworkUrl: getSubscribedPodcastImage(feedUrl), - collectionName: getSubscribedPodcastName(feedUrl), - artistName: getSubscribedPodcastArtist(feedUrl), - genre: - getSubScribedPodcastGenreList( - feedUrl, - )?.mapIndexed((i, e) => Genre(i, e)).toList() ?? - [], - ); + PodcastMetadata? getSubScribedPodcastMetadata(String feedUrl) => + isPodcastSubscribed(feedUrl) + ? PodcastMetadata( + feedUrl: feedUrl, + imageUrl: getSubscribedPodcastImage(feedUrl), + name: getSubscribedPodcastName(feedUrl), + artist: getSubscribedPodcastArtist(feedUrl), + genreList: getSubScribedPodcastGenreList(feedUrl), + ) + : null; // Image URL String? getSubscribedPodcastImage(String feedUrl) => diff --git a/lib/podcasts/podcast_manager.dart b/lib/podcasts/podcast_manager.dart index 8b02f91..c51314a 100644 --- a/lib/podcasts/podcast_manager.dart +++ b/lib/podcasts/podcast_manager.dart @@ -1,3 +1,6 @@ +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_it/flutter_it.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -5,12 +8,14 @@ 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 '../player/player_manager.dart'; import '../search/search_manager.dart'; import 'data/podcast_metadata.dart'; -import 'data/podcast_proxy.dart'; +import 'download_service.dart'; import 'podcast_library_service.dart'; import 'podcast_service.dart'; @@ -22,16 +27,41 @@ import 'podcast_service.dart'; class PodcastManager { PodcastManager({ required PodcastService podcastService, + required DownloadService downloadService, required SearchManager searchManager, required CollectionManager collectionManager, required PodcastLibraryService podcastLibraryService, required NotificationsService notificationsService, + required PlayerManager playerManager, }) : _podcastService = podcastService, + _downloadService = downloadService, _podcastLibraryService = podcastLibraryService, - _notificationsService = notificationsService { + _notificationsService = notificationsService, + _playerManager = playerManager, + _searchManager = searchManager, + _collectionManager = collectionManager { Command.globalExceptionHandler = (e, s) { printMessageInDebugMode(e.error, s); }; + + _initializeCommands(); + + getSubscribedPodcastsCommand.run(null); + + updateSearchCommand.run(null); + } + + final PodcastService _podcastService; + final PodcastLibraryService _podcastLibraryService; + final DownloadService _downloadService; + final NotificationsService _notificationsService; + final PlayerManager _playerManager; + final SearchManager _searchManager; + final CollectionManager _collectionManager; + + final showInfo = ValueNotifier(false); + + void _initializeCommands() { updateSearchCommand = Command.createAsync( (String? query) async => _podcastService.search( searchQuery: query, @@ -42,54 +72,45 @@ 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)); - getSubscribedPodcastsCommand = Command.createSync( - (filterText) => podcastLibraryService.getFilteredPodcastItems(filterText), - initialValue: [], - ); - - collectionManager.textChangedCommand.listen( + getSubscribedPodcastsCommand = Command.createSync((filterText) { + final items = _podcastLibraryService.getFilteredPodcastsItems(filterText); + + return items + .map( + (e) => Item( + feedUrl: e.feedUrl, + artworkUrl: _podcastLibraryService.getSubscribedPodcastImage( + e.feedUrl, + ), + collectionName: e.name, + artistName: e.artist, + genre: e.genreList?.mapIndexed((i, e) => Genre(i, e)).toList(), + ), + ) + .toList(); + }, initialValue: []); + + _collectionManager.textChangedCommand.listen( (filterText, sub) => getSubscribedPodcastsCommand.run(filterText), ); - checkForUpdatesCommand = - Command.createAsync< - ({String updateMessage, String Function(int) multiUpdateMessage}), - void - >((params) async { - final updatedProxies = []; - - // Check all subscribed podcasts for updates - for (final feedUrl in _podcastLibraryService.podcasts) { - final proxy = getOrCreateProxy(Item(feedUrl: feedUrl)); - final hasUpdate = await _checkForUpdate(proxy); - if (hasUpdate) { - updatedProxies.add(proxy); - } - } - - if (updatedProxies.isNotEmpty) { - final msg = updatedProxies.length == 1 - ? '${params.updateMessage} ${updatedProxies.first.title ?? ''}' - : params.multiUpdateMessage(updatedProxies.length); - await _notificationsService.notify(message: msg); - } - }, initialValue: null); - togglePodcastSubscriptionCommand = - Command.createUndoableNoResult( - (item, stack) async { - final feedUrl = item.feedUrl; - if (feedUrl == null) return; + Command.createUndoableNoResult< + PodcastMetadata, + ({bool wasAdd, PodcastMetadata metadata}) + >( + (metadata, stack) async { + final feedUrl = metadata.feedUrl; final currentList = getSubscribedPodcastsCommand.value; final isSubscribed = currentList.any((p) => p.feedUrl == feedUrl); // Store operation info for undo - stack.push((wasAdd: !isSubscribed, item: item)); + stack.push((wasAdd: !isSubscribed, metadata: metadata)); // Optimistic update: modify list directly if (isSubscribed) { @@ -97,14 +118,25 @@ class PodcastManager { .where((p) => p.feedUrl != feedUrl) .toList(); } else { - getSubscribedPodcastsCommand.value = [...currentList, item]; + getSubscribedPodcastsCommand.value = [ + ...currentList, + Item( + feedUrl: metadata.feedUrl, + artworkUrl: metadata.imageUrl, + collectionName: metadata.name, + artistName: metadata.artist, + genre: metadata.genreList + ?.mapIndexed((i, e) => Genre(i, e)) + .toList(), + ), + ]; } // Async persist if (isSubscribed) { - await removePodcast(item); + await removePodcast(feedUrl: metadata.feedUrl); } else { - await addPodcast(PodcastMetadata.fromItem(item)); + await addPodcast(metadata); } }, undo: (stack, reason) async { @@ -114,115 +146,262 @@ class PodcastManager { if (undoData.wasAdd) { // Was an add, so remove it getSubscribedPodcastsCommand.value = currentList - .where((p) => p.feedUrl != undoData.item.feedUrl) + .where((p) => p.feedUrl != undoData.metadata.feedUrl) .toList(); } else { // Was a remove, so add it back getSubscribedPodcastsCommand.value = [ ...currentList, - undoData.item, + Item( + feedUrl: undoData.metadata.feedUrl, + artworkUrl: undoData.metadata.imageUrl, + collectionName: undoData.metadata.name, + artistName: undoData.metadata.artist, + genre: undoData.metadata.genreList + ?.mapIndexed((i, e) => Genre(i, e)) + .toList(), + ), ]; } }, undoOnExecutionFailure: true, ); + } - getSubscribedPodcastsCommand.run(null); + // Map of feedUrl to fetch episodes command + final _fetchEpisodeMediaCommands = + >>{}; - updateSearchCommand.run(null); + Command> _getFetchEpisodesCommand(String feedUrl) { + return _fetchEpisodeMediaCommands.putIfAbsent( + feedUrl, + () => Command.createAsync>( + (feedUrl) async => findEpisodes(feedUrl: feedUrl), + initialValue: [], + ), + ); } - final PodcastService _podcastService; - final PodcastLibraryService _podcastLibraryService; - final NotificationsService _notificationsService; + Command> runFetchEpisodesCommand(String feedUrl) { + _getFetchEpisodesCommand(feedUrl).run(feedUrl); + return _getFetchEpisodesCommand(feedUrl); + } - // Track episodes currently downloading - final activeDownloads = ListNotifier(); + final Map> _fetchAndPlayCommands = {}; + Command getOrCreatePlayCommand(String feedUrl) => + _fetchAndPlayCommands.putIfAbsent( + feedUrl, + () => _createFetchEpisodesAndPlayCommand(feedUrl), + ); + + Command _createFetchEpisodesAndPlayCommand(String feedUrl) => + Command.createAsyncNoResult((startIndex) async { + final episodes = await _getFetchEpisodesCommand( + feedUrl, + ).runAsync(feedUrl); + + if (episodes.isNotEmpty) { + await _playerManager.setPlaylist( + episodes.map((e) { + if (_podcastLibraryService.getDownload(e.url) != null) { + return e.copyWithX( + resource: _podcastLibraryService.getDownload(e.url)!, + ); + } + return e; + }).toList(), + index: startIndex, + ); + } + }); - /// Registers an episode as actively downloading. - /// Called by EpisodeMedia.downloadCommand when download starts. - void registerActiveDownload(EpisodeMedia episode) { - activeDownloads.add(episode); + late Command updateSearchCommand; + late Command> getSubscribedPodcastsCommand; + late Command getPodcastMetadataCommand; + + final _metaDataCommands = + >{}; + Command getAndRunMetadataCommand( + GetMetadataCapsule capsule, + ) { + return _metaDataCommands.putIfAbsent( + capsule, + () => _createGetPodcastMetadataCommand(), + )..run(capsule); } - /// Unregisters an episode from active downloads. - /// Called by EpisodeMedia.downloadCommand on error. - void unregisterActiveDownload(EpisodeMedia episode) { - activeDownloads.remove(episode); - } + Command + _createGetPodcastMetadataCommand() => + Command.createAsync(( + capsule, + ) async { + if (capsule.item != null) { + return PodcastMetadata.fromItem(capsule.item!); + } else if (_podcastLibraryService.isPodcastSubscribed( + capsule.feedUrl, + )) { + return _podcastLibraryService.getSubScribedPodcastMetadata( + capsule.feedUrl, + ); + } else { + throw ArgumentError( + 'Cannot get metadata for unsubscribed podcast without item', + ); + } + }, initialValue: null); + + 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); + }); + + if (_podcastLibraryService.getDownload(media.url) != null) { + command.resetProgress(progress: 1.0); + } - // Proxy cache - each podcast owns its fetchEpisodesCommand - final _proxyCache = {}; + return command; + } - /// Gets or creates a PodcastProxy for the given Item. - /// The proxy owns the fetchEpisodesCommand. - PodcastProxy getOrCreateProxy(Item item) { - return _proxyCache.putIfAbsent( - item.feedUrl!, - () => PodcastProxy(item: item, podcastService: _podcastService), - ); + Future addPodcast(PodcastMetadata metadata) async { + await _podcastLibraryService.addPodcast(metadata); + getSubscribedPodcastsCommand.run(); } - /// Checks a single podcast for updates. Returns true if updated. - Future _checkForUpdate(PodcastProxy proxy) async { - final feedUrl = proxy.feedUrl; - final storedTimeStamp = _podcastLibraryService.getPodcastLastUpdated( - feedUrl, - ); - DateTime? feedLastUpdated; + Future removePodcast({required String feedUrl}) async { + await _podcastLibraryService.removePodcast(feedUrl); + getSubscribedPodcastsCommand.run(); + } - try { - feedLastUpdated = await Feed.feedLastUpdated(url: feedUrl); - } on Exception catch (e) { - printMessageInDebugMode(e); + late final Command togglePodcastSubscriptionCommand; + + final Map _podcastCache = {}; + Podcast? getPodcastFromCache(String? feedUrl) => _podcastCache[feedUrl]; + 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'), + ); } - printMessageInDebugMode('checking update for: ${proxy.title ?? feedUrl}'); - printMessageInDebugMode( - 'storedTimeStamp: ${storedTimeStamp ?? 'no timestamp'}', - ); - printMessageInDebugMode( - 'feedLastUpdated: ${feedLastUpdated?.podcastTimeStamp ?? 'no timestamp'}', - ); + final url = feedUrl ?? item!.feedUrl!; - if (feedLastUpdated == null) return false; - - await _podcastLibraryService.addPodcastLastUpdated( - feedUrl: feedUrl, - timestamp: feedLastUpdated.podcastTimeStamp, - ); - - if (storedTimeStamp != null && - !storedTimeStamp.isSamePodcastTimeStamp(feedLastUpdated)) { - // Clear cached episodes to force refresh - proxy.clearEpisodeCache(); - await proxy.fetchEpisodesCommand.runAsync(); + 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; + } + } - await _podcastLibraryService.addPodcastUpdate(feedUrl, feedLastUpdated); - return true; // Has update + if (podcast?.image != null) { + _podcastLibraryService.addSubscribedPodcastImage( + feedUrl: url, + imageUrl: podcast!.image!, + ); + } else if (item?.bestArtworkUrl != null) { + _podcastLibraryService.addSubscribedPodcastImage( + feedUrl: url, + imageUrl: item!.bestArtworkUrl!, + ); } - return false; + + return podcast?.toEpisodeMediaList(url, item) ?? []; } - late Command updateSearchCommand; - late Command> getSubscribedPodcastsCommand; - late Command< - ({String updateMessage, String Function(int) multiUpdateMessage}), - void - > - checkForUpdatesCommand; - late final Command togglePodcastSubscriptionCommand; + 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); + } + } - Future addPodcast(PodcastMetadata metadata) async { - await _podcastLibraryService.addPodcast(metadata); - getSubscribedPodcastsCommand.run(); + 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); + } } +} - Future removePodcast(Item item) async { - await _podcastLibraryService.removePodcast(item.feedUrl!); - getSubscribedPodcastsCommand.run(); - } +class GetMetadataCapsule { + GetMetadataCapsule({required this.feedUrl, this.item}); - String? getPodcastDescription(String? feedUrl) => - _proxyCache[feedUrl]?.description; + final String feedUrl; + final Item? item; } diff --git a/lib/podcasts/podcast_service.dart b/lib/podcasts/podcast_service.dart index 6bce91a..0726eac 100644 --- a/lib/podcasts/podcast_service.dart +++ b/lib/podcasts/podcast_service.dart @@ -4,22 +4,15 @@ import 'package:flutter/foundation.dart'; import 'package:podcast_search/podcast_search.dart'; import '../common/logging.dart'; -import '../extensions/podcast_x.dart'; import '../extensions/shared_preferences_x.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 SettingsService _settingsService; - final PodcastLibraryService _libraryService; - PodcastService({ - required SettingsService settingsService, - required PodcastLibraryService libraryService, - }) : _settingsService = settingsService, - _libraryService = libraryService { + PodcastService({required SettingsService settingsService}) + : _settingsService = settingsService { _search = Search( searchProvider: _settingsService.getBool(SPKeys.usePodcastIndex) == true && @@ -76,44 +69,16 @@ class PodcastService { } } - // Stateless operation - just fetches podcast and episodes, no caching - Future<({Podcast? podcast, List episodes})> findEpisodes({ - Item? item, - String? feedUrl, - }) async { + Future fetchPodcast({Item? item, String? feedUrl}) async { if (item == null && item?.feedUrl == null && feedUrl == null) { - printMessageInDebugMode('findEpisodes called without feedUrl or item'); - return (podcast: null, episodes: []); - } - - final url = feedUrl ?? item!.feedUrl!; - - // Save artwork if available - if (item?.bestArtworkUrl != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: item!.bestArtworkUrl!, + return Future.error( + ArgumentError('Either item or feedUrl must be provided'), ); } - Podcast? podcast; - try { - podcast = await compute(loadPodcast, url); - } catch (e) { - printMessageInDebugMode('Error loading podcast feed: $e'); - return (podcast: null, episodes: []); - } - - if (podcast?.image != null) { - _libraryService.addSubscribedPodcastImage( - feedUrl: url, - imageUrl: podcast!.image!, - ); - } - - final episodes = podcast?.toEpisodeMediaList(url, item) ?? []; + final url = feedUrl ?? item!.feedUrl!; - return (podcast: podcast, episodes: episodes); + return compute(loadPodcast, url); } } diff --git a/lib/podcasts/view/download_button.dart b/lib/podcasts/view/download_button.dart index b6cb9bb..96f8c9f 100644 --- a/lib/podcasts/view/download_button.dart +++ b/lib/podcasts/view/download_button.dart @@ -4,6 +4,7 @@ import 'package:flutter_it/flutter_it.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/episode_media.dart'; import '../data/podcast_metadata.dart'; +import '../download_service.dart'; import '../podcast_manager.dart'; class DownloadButton extends StatelessWidget { @@ -29,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, @@ -46,9 +48,11 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { ), onPressed: () { if (isDownloaded) { - episode.deleteDownloadCommand.run(); + di().deleteDownload(media: episode); + di().recentDownloads.remove(episode); + downloadCommand.resetProgress(); } else if (isRunning) { - episode.downloadCommand.cancel(); + downloadCommand.cancel(); } else { // Add podcast to library before downloading di().addPodcast( @@ -60,7 +64,7 @@ class _ProcessDownloadButton extends StatelessWidget with WatchItMixin { genreList: episode.genres, ), ); - episode.downloadCommand.run(); + downloadCommand.run(); } }, color: isDownloaded @@ -77,12 +81,13 @@ class _DownloadProgress extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) { - final progress = watch(episode.downloadCommand.progress).value; - final isRunning = watch(episode.downloadCommand.isRunning).value; + final progress = watch( + di().getDownloadCommand(episode).progress, + ).value; + final isRunning = watch( + di().getDownloadCommand(episode).isRunning, + ).value; - // Show indeterminate spinner when running but no progress yet - // Show determinate progress when we have progress data - // Hide when completed (progress == 1.0) or not running final showSpinner = isRunning || (progress > 0 && progress < 1.0); return SizedBox.square( diff --git a/lib/podcasts/view/podcast_card.dart b/lib/podcasts/view/podcast_card.dart index aecce5f..ba3a848 100644 --- a/lib/podcasts/view/podcast_card.dart +++ b/lib/podcasts/view/podcast_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:phoenix_theme/phoenix_theme.dart'; import 'package:podcast_search/podcast_search.dart'; @@ -7,6 +8,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 '../data/podcast_metadata.dart'; import '../podcast_manager.dart'; import 'podcast_card_play_button.dart'; import 'podcast_favorite_button.dart'; @@ -23,31 +25,38 @@ class PodcastCard extends StatefulWidget with WatchItStatefulWidgetMixin { class _PodcastCardState extends State { bool _hovered = false; + late Command command; @override - Widget build(BuildContext context) { - final proxy = createOnce( - () => di().getOrCreateProxy(widget.podcastItem), + void initState() { + super.initState(); + command = di().getOrCreatePlayCommand( + widget.podcastItem.feedUrl!, ); + } + @override + Widget build(BuildContext context) { registerHandler( - target: proxy.playEpisodesCommand.results, + target: di() + .getOrCreatePlayCommand(widget.podcastItem.feedUrl!) + .results, handler: (context, CommandResult? result, cancel) { if (result == null) return; - if (result.isRunning) { showDialog( context: context, barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), + builder: (_) => const PodcastLoadingDialog(), ); } else if (result.isSuccess) { - Navigator.of(context).pop(); + context.canPop() ? context.pop() : null; } else if (result.hasError) { - Navigator.of(context).pop(); + context.canPop() ? context.pop() : null; } }, ); + final theme = context.theme; final isLight = theme.colorScheme.isLight; const borderRadiusGeometry = BorderRadiusGeometry.only( @@ -58,11 +67,7 @@ class _PodcastCardState extends State { focusColor: theme.colorScheme.primary, borderRadius: BorderRadius.circular(12), onHover: (hovering) => setState(() => _hovered = hovering), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => PodcastPage(podcastItem: widget.podcastItem), - ), - ), + onTap: () => PodcastPage.go(context, item: widget.podcastItem), child: SizedBox( width: kGridViewDelegate.maxCrossAxisExtent, height: kGridViewDelegate.mainAxisExtent, @@ -118,7 +123,15 @@ class _PodcastCardState extends State { podcastItem: widget.podcastItem, ), PodcastFavoriteButton.floating( - podcastItem: widget.podcastItem, + metadata: PodcastMetadata( + feedUrl: widget.podcastItem.feedUrl!, + imageUrl: widget.podcastItem.bestArtworkUrl, + name: widget.podcastItem.collectionName, + artist: widget.podcastItem.artistName, + genreList: widget.podcastItem.genre + ?.map((e) => e.name) + .toList(), + ), ), ], ) @@ -140,3 +153,23 @@ class _PodcastCardState extends State { ); } } + +class PodcastLoadingDialog extends StatelessWidget { + const PodcastLoadingDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 100), + content: Center( + child: Column( + spacing: kMediumPadding, + children: [ + Text(context.l10n.loadingPodcastFeed), + const CircularProgressIndicator.adaptive(), + ], + ), + ), + ); + } +} diff --git a/lib/podcasts/view/podcast_card_play_button.dart b/lib/podcasts/view/podcast_card_play_button.dart index d3a9b3c..d88c2b2 100644 --- a/lib/podcasts/view/podcast_card_play_button.dart +++ b/lib/podcasts/view/podcast_card_play_button.dart @@ -4,20 +4,17 @@ import 'package:podcast_search/podcast_search.dart'; import '../podcast_manager.dart'; -class PodcastCardPlayButton extends StatelessWidget { +class PodcastCardPlayButton extends StatelessWidget with WatchItMixin { const PodcastCardPlayButton({super.key, required this.podcastItem}); final Item podcastItem; @override - Widget build(BuildContext context) { - return FloatingActionButton.small( - heroTag: 'podcastcardfap', - onPressed: () { - final proxy = di().getOrCreateProxy(podcastItem); - proxy.playEpisodesCommand(0); - }, - child: const Icon(Icons.play_arrow), - ); - } + Widget build(BuildContext context) => FloatingActionButton.small( + heroTag: 'podcastcardfap', + onPressed: () => di() + .getOrCreatePlayCommand(podcastItem.feedUrl!) + .run(0), + child: const Icon(Icons.play_arrow), + ); } diff --git a/lib/podcasts/view/podcast_favorite_button.dart b/lib/podcasts/view/podcast_favorite_button.dart index b62db8c..e73fe46 100644 --- a/lib/podcasts/view/podcast_favorite_button.dart +++ b/lib/podcasts/view/podcast_favorite_button.dart @@ -1,23 +1,23 @@ 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 { - const PodcastFavoriteButton({super.key, required this.podcastItem}) + const PodcastFavoriteButton({super.key, required this.metadata}) : _floating = false; - const PodcastFavoriteButton.floating({super.key, required this.podcastItem}) + const PodcastFavoriteButton.floating({super.key, required this.metadata}) : _floating = true; - final Item podcastItem; + final PodcastMetadata metadata; final bool _floating; @override Widget build(BuildContext context) { final isSubscribed = watchValue( (PodcastManager m) => m.getSubscribedPodcastsCommand.select( - (podcasts) => podcasts.any((p) => p.feedUrl == podcastItem.feedUrl), + (podcasts) => podcasts.any((p) => p.feedUrl == metadata.feedUrl), ), ); @@ -35,21 +35,18 @@ class PodcastFavoriteButton extends StatelessWidget with WatchItMixin { }, ); + void onPressed() => + di().togglePodcastSubscriptionCommand.run(metadata); final icon = Icon(isSubscribed ? Icons.favorite : Icons.favorite_border); if (_floating) { return FloatingActionButton.small( heroTag: 'favtag', - onPressed: () => di().togglePodcastSubscriptionCommand - .run(podcastItem), + onPressed: onPressed, child: icon, ); } - return IconButton( - onPressed: () => di().togglePodcastSubscriptionCommand - .run(podcastItem), - icon: icon, - ); + return IconButton(onPressed: onPressed, icon: icon); } } diff --git a/lib/podcasts/view/podcast_page.dart b/lib/podcasts/view/podcast_page.dart index 19e3957..5ccf8c8 100644 --- a/lib/podcasts/view/podcast_page.dart +++ b/lib/podcasts/view/podcast_page.dart @@ -2,6 +2,7 @@ import 'package:blur/blur.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:podcast_search/podcast_search.dart'; import 'package:yaru/widgets.dart'; @@ -12,200 +13,262 @@ import '../../common/view/sliver_sticky_panel.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../extensions/string_x.dart'; +import '../../player/data/episode_media.dart'; import '../../player/view/player_view.dart'; import '../data/podcast_genre.dart'; +import '../data/podcast_metadata.dart'; import '../podcast_manager.dart'; import 'podcast_favorite_button.dart'; import 'podcast_page_episode_list.dart'; import 'recent_downloads_button.dart'; -class PodcastPage extends StatefulWidget with WatchItStatefulWidgetMixin { - const PodcastPage({super.key, required this.podcastItem}); +class PodcastPage extends StatelessWidget with WatchItMixin { + const PodcastPage({super.key, required this.feedUrl, this.podcastItem}); - final Item podcastItem; + final String feedUrl; + final Item? podcastItem; - @override - State createState() => _PodcastPageState(); -} + static void go(BuildContext context, {EpisodeMedia? media, Item? item}) { + if (media == null && item == null) { + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(SnackBar(content: Text(context.l10n.noPodcastFound))); + return; + } -class _PodcastPageState extends State { - bool _showInfo = false; + final feedUrl = media?.feedUrl ?? item!.feedUrl!; + + context.go( + '/podcast/${Uri.encodeComponent(feedUrl)}', + extra: + item ?? + Item( + feedUrl: media!.feedUrl, + artworkUrl: media.artUrl, + collectionName: media.collectionName, + artistName: media.artist, + genre: media.genres.mapIndexed((i, e) => Genre(i, e)).toList(), + ), + ); + } @override - Widget build(BuildContext context) => Scaffold( - appBar: YaruWindowTitleBar( - leading: const Center(child: BackButton()), - title: Text( - widget.podcastItem.collectionName?.unEscapeHtml ?? context.l10n.podcast, - ), - actions: [ - const Flexible( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: kSmallPadding), - child: RecentDownloadsButton(), + Widget build(BuildContext context) { + final showInfo = watchValue((PodcastManager m) => m.showInfo); + + final results = watchValue( + (PodcastManager m) => m + .getAndRunMetadataCommand( + GetMetadataCapsule(feedUrl: feedUrl, item: podcastItem), + ) + .results, + ); + + final isRunning = results.isRunning; + final hasError = results.hasError; + final error = results.error; + + final name = results.data?.name; + final image = results.data?.imageUrl; + final artist = results.data?.artist; + final genres = results.data?.genreList; + + return Scaffold( + appBar: YaruWindowTitleBar( + leading: Center( + child: BackButton( + onPressed: () => context.canPop() ? context.pop() : context.go('/'), ), ), - ], - ), - bottomNavigationBar: const PlayerView(), - body: CustomScrollView( - slivers: [ - if (widget.podcastItem.bestArtworkUrl != null) - SliverToBoxAdapter( - child: Material( - color: Colors.transparent, - child: Stack( - alignment: Alignment.center, - children: [ - Blur( - blur: 20, - colorOpacity: 0.7, - blurColor: const Color.fromARGB(255, 48, 48, 48), - child: SafeNetworkImage( - height: 350, - width: double.infinity, - url: widget.podcastItem.bestArtworkUrl!, - fit: BoxFit.cover, - ), - ), - if (!_showInfo) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SafeNetworkImage( - height: 250, - width: 250, - url: widget.podcastItem.bestArtworkUrl!, - fit: BoxFit.fitHeight, - ), - ), - if (_showInfo) ...[ - Positioned( - bottom: kMediumPadding, - left: kMediumPadding, - top: 55, - right: kMediumPadding, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(8), - ), - child: SingleChildScrollView( - child: HtmlText( - wrapInFakeScroll: false, - color: Colors.white, - text: - di().getPodcastDescription( - widget.podcastItem.feedUrl, - ) ?? - '', + title: Text( + isRunning ? '...' : name?.unEscapeHtml ?? context.l10n.podcast, + ), + actions: [ + const Flexible( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: kSmallPadding), + child: RecentDownloadsButton(), + ), + ), + ], + ), + bottomNavigationBar: const PlayerView(), + body: hasError + ? Center( + child: Text(error?.toString() ?? context.l10n.genericErrorTitle), + ) + : CustomScrollView( + slivers: [ + if (image != null) + SliverToBoxAdapter( + child: Material( + color: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + Blur( + blur: 20, + colorOpacity: 0.7, + blurColor: const Color.fromARGB(255, 48, 48, 48), + child: SafeNetworkImage( + height: 350, + width: double.infinity, + url: image, + fit: BoxFit.cover, + ), ), - ), - ), - ), - Positioned( - top: kMediumPadding, - left: 50, - right: kMediumPadding, - child: SizedBox( - width: 400, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - children: - widget.podcastItem.genre - ?.map( - (e) => Padding( - padding: const EdgeInsets.only( - right: kSmallPadding, - ), - child: Container( - height: - context.theme.buttonTheme.height - - 2, - padding: const EdgeInsets.symmetric( - horizontal: kMediumPadding, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular( - 100, - ), - ), - child: Center( - child: Text( - PodcastGenre.values - .firstWhereOrNull( - (element) => - element.name - .toLowerCase() == - e.name - .toLowerCase(), - ) - ?.localize( - context.l10n, - ) ?? - e.name, - style: const TextStyle( - color: Colors.white, + if (!showInfo) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SafeNetworkImage( + height: 250, + width: 250, + url: image, + fit: BoxFit.fitHeight, + ), + ), + if (showInfo) ...[ + Positioned( + bottom: kMediumPadding, + left: kMediumPadding, + top: 55, + right: kMediumPadding, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: HtmlText( + wrapInFakeScroll: false, + color: Colors.white, + text: + di() + .getPodcastDescriptionFromCache( + feedUrl, + ) ?? + '', + ), + ), + ), + ), + Positioned( + top: kMediumPadding, + left: 50, + right: kMediumPadding, + child: SizedBox( + width: 400, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + children: + genres + ?.map( + (e) => Padding( + padding: const EdgeInsets.only( + right: kSmallPadding, + ), + child: Container( + height: + context + .theme + .buttonTheme + .height - + 2, + padding: + const EdgeInsets.symmetric( + horizontal: + kMediumPadding, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: + BorderRadius.circular( + 100, + ), + ), + child: Center( + child: Text( + PodcastGenre.values + .firstWhereOrNull( + (v) => + v.name + .toLowerCase() == + e.toLowerCase(), + ) + ?.localize( + context.l10n, + ) ?? + e, + style: const TextStyle( + color: Colors.white, + ), + ), + ), + ), ), - ), - ), - ), - ), - ) - .toList() ?? - [], + ) + .toList() ?? + [], + ), + ), + ), + ), + ], + Positioned( + top: kMediumPadding, + left: kMediumPadding, + child: IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: showInfo + ? context.colorScheme.primary + : Colors.black54, + ), + onPressed: () => + di().showInfo.value = + !showInfo, + icon: Icon( + Icons.info, + color: showInfo + ? context.colorScheme.onPrimary + : Colors.white, + ), + ), ), - ), + ], ), ), - ], - Positioned( - top: kMediumPadding, - left: kMediumPadding, - child: IconButton.filled( - style: IconButton.styleFrom( - backgroundColor: _showInfo - ? context.colorScheme.primary - : Colors.black54, - ), - onPressed: () => setState(() { - _showInfo = !_showInfo; - }), - icon: Icon( - Icons.info, - color: _showInfo - ? context.colorScheme.onPrimary - : Colors.white, + ), + SliverStickyPanel( + height: 80, + backgroundColor: context.theme.scaffoldBackgroundColor, + centerTitle: false, + controlPanel: ListTile( + contentPadding: EdgeInsets.zero, + leading: PodcastFavoriteButton( + metadata: PodcastMetadata( + feedUrl: feedUrl, + imageUrl: image, + name: name, + artist: artist, ), ), + trailing: const ShowOnlyDownloadsButton(singleButton: true), + title: Text( + name?.unEscapeHtml ?? context.l10n.podcast, + style: context.textTheme.bodySmall, + overflow: TextOverflow.visible, + maxLines: 3, + ), + subtitle: Text( + artist?.unEscapeHtml ?? context.l10n.podcast, + ), ), - ], - ), - ), - ), - SliverStickyPanel( - height: 80, - backgroundColor: context.theme.scaffoldBackgroundColor, - centerTitle: false, - controlPanel: ListTile( - contentPadding: EdgeInsets.zero, - leading: PodcastFavoriteButton(podcastItem: widget.podcastItem), - trailing: const ShowOnlyDownloadsButton(singleButton: true), - title: Text( - '${widget.podcastItem.artistName}', - style: context.textTheme.bodySmall, - overflow: TextOverflow.visible, - maxLines: 3, + ), + PodcastPageEpisodeList(feedUrl: feedUrl), + ], ), - subtitle: Text( - widget.podcastItem.collectionName ?? context.l10n.podcast, - ), - ), - ), - PodcastPageEpisodeList(podcastItem: widget.podcastItem), - ], - ), - ); + ); + } } diff --git a/lib/podcasts/view/podcast_page_episode_list.dart b/lib/podcasts/view/podcast_page_episode_list.dart index 3f14b4e..8ef521a 100644 --- a/lib/podcasts/view/podcast_page_episode_list.dart +++ b/lib/podcasts/view/podcast_page_episode_list.dart @@ -1,39 +1,55 @@ 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 '../../player/player_manager.dart'; +import '../podcast_library_service.dart'; import '../podcast_manager.dart'; import 'episode_tile.dart'; class PodcastPageEpisodeList extends StatelessWidget with WatchItMixin { - const PodcastPageEpisodeList({super.key, required this.podcastItem}); + const PodcastPageEpisodeList({super.key, required this.feedUrl}); - final Item podcastItem; + final String feedUrl; @override Widget build(BuildContext context) { - final proxy = di().getOrCreateProxy(podcastItem); - - callOnceAfterThisBuild((_) => proxy.fetchEpisodesCommand.run()); - final downloadsOnly = watchValue( (CollectionManager m) => m.showOnlyDownloadsNotifier, ); - return watch(proxy.fetchEpisodesCommand.results).value.toWidget( + return watchValue( + (PodcastManager m) => m.runFetchEpisodesCommand(feedUrl).results, + ).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, itemBuilder: (context, index) => EpisodeTile( episode: episodes.elementAt(index), - podcastImage: podcastItem.bestArtworkUrl, - setPlaylist: () => - di().setPlaylist(episodes, index: index), + podcastImage: episodes.elementAt(index).albumArtUrl, + setPlaylist: () => di().setPlaylist( + episodes.map((e) { + if (di().getDownload(e.url) != null) { + return e.copyWithX( + resource: di().getDownload(e.url)!, + ); + } + return e; + }).toList(), + index: index, + ), ), ); }, diff --git a/lib/podcasts/view/recent_downloads_button.dart b/lib/podcasts/view/recent_downloads_button.dart index 5ab1862..982e60f 100644 --- a/lib/podcasts/view/recent_downloads_button.dart +++ b/lib/podcasts/view/recent_downloads_button.dart @@ -42,12 +42,13 @@ class _RecentDownloadsButtonState extends State @override Widget build(BuildContext context) { final theme = context.theme; - final activeDownloads = watch(di().activeDownloads).value; + final activeDownloads = watchValue((PodcastManager m) => m.activeDownloads); + final hasActiveDownloads = activeDownloads.isNotEmpty; - final hasAnyDownloads = activeDownloads.isNotEmpty; - final hasInProgressDownloads = activeDownloads.any((e) => !e.isDownloaded); + final recentDownloads = watchValue((PodcastManager m) => m.recentDownloads); + final hasRecentDownloads = recentDownloads.isNotEmpty; - if (hasInProgressDownloads) { + if (hasActiveDownloads) { if (!_controller.isAnimating) { _controller.repeat(reverse: true); } @@ -59,9 +60,9 @@ class _RecentDownloadsButtonState extends State return AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: hasAnyDownloads ? 1.0 : 0.0, + opacity: hasActiveDownloads || hasRecentDownloads ? 1.0 : 0.0, child: IconButton( - icon: hasInProgressDownloads + icon: hasActiveDownloads ? FadeTransition( opacity: _animation, child: Icon( @@ -71,9 +72,7 @@ class _RecentDownloadsButtonState extends State ) : Icon( Icons.download_for_offline, - color: hasAnyDownloads - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, + color: theme.colorScheme.onSurface, ), onPressed: () => showDialog( context: context, @@ -92,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]); } }, @@ -105,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/radio/radio_service.dart b/lib/radio/radio_service.dart index e5e3aff..f5b23a3 100644 --- a/lib/radio/radio_service.dart +++ b/lib/radio/radio_service.dart @@ -336,6 +336,7 @@ class RadioService { required MpvMetaData mpvMetaData, }) { _radioHistory.putIfAbsent(icyTitle, () => mpvMetaData); + _propertiesChangedController.add(true); } int getRadioHistoryLength({String? filter}) => @@ -356,8 +357,8 @@ class RadioService { return radioHistory.entries.where( (e) => filter == null ? true - : e.value.icyName.contains(filter) || - filter.contains(e.value.icyName), + : e.value.icyName.toLowerCase().contains(filter) || + filter.contains(e.value.icyName.toLowerCase()), ); } } diff --git a/lib/radio/view/radio_browser.dart b/lib/radio/view/radio_browser.dart index 2b3b1da..7d687f2 100644 --- a/lib/radio/view/radio_browser.dart +++ b/lib/radio/view/radio_browser.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; +import '../../common/view/tap_able_text.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/station_media.dart'; @@ -40,7 +42,11 @@ class RadioBrowserTile extends StatelessWidget with WatchItMixin { @override Widget build(BuildContext context) => ListTile( - title: Text(media.title), + title: TapAbleText( + text: media.title, + onTap: () => + context.go('/station/${Uri.encodeComponent(media.id)}', extra: media), + ), selectedColor: context.theme.colorScheme.primary, selected: watchStream( diff --git a/lib/radio/view/radio_favorites_list.dart b/lib/radio/view/radio_favorites_list.dart index fa44913..e899d51 100644 --- a/lib/radio/view/radio_favorites_list.dart +++ b/lib/radio/view/radio_favorites_list.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; +import '../../common/view/tap_able_text.dart'; import '../../common/view/ui_constants.dart'; import '../../extensions/build_context_x.dart'; import '../../player/data/station_media.dart'; @@ -63,7 +65,13 @@ class _RadioFavoriteListTile extends StatelessWidget with WatchItMixin { false; return ListTile( - title: Text(media.title), + title: TapAbleText( + text: media.title, + onTap: () => context.go( + '/station/${Uri.encodeComponent(media.id)}', + extra: media, + ), + ), subtitle: Text(media.genres.take(5).join(', ')), minLeadingWidth: kDefaultTileLeadingDimension, leading: RemoteMediaListTileImage(media: media), diff --git a/lib/radio/view/radio_history_tile.dart b/lib/radio/view/radio_history_tile.dart new file mode 100644 index 0000000..0d1c3a9 --- /dev/null +++ b/lib/radio/view/radio_history_tile.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; + +import '../../common/view/safe_network_image.dart'; +import '../../common/view/tap_able_text.dart'; +import '../../common/view/ui_constants.dart'; +import '../../extensions/build_context_x.dart'; +import '../../online_art/online_art_model.dart'; +import '../../search/copy_to_clipboard_content.dart'; +import '../radio_service.dart'; + +enum _RadioHistoryTileVariant { regular, simple } + +class RadioHistoryTile extends StatelessWidget with WatchItMixin { + const RadioHistoryTile({ + super.key, + required this.icyTitle, + required this.selected, + this.allowNavigation = true, + }) : _variant = _RadioHistoryTileVariant.regular; + + const RadioHistoryTile.simple({ + super.key, + required this.icyTitle, + required this.selected, + this.allowNavigation = false, + }) : _variant = _RadioHistoryTileVariant.simple; + + final _RadioHistoryTileVariant _variant; + final String icyTitle; + final bool selected; + final bool allowNavigation; + + @override + Widget build(BuildContext context) { + final icyName = watchStream( + (RadioService m) => + m.propertiesChanged.map((e) => m.getMetadata(icyTitle)?.icyName), + initialValue: di().getMetadata(icyTitle)?.icyName, + preserveState: false, + allowStreamChange: false, + ).data; + + return switch (_variant) { + _RadioHistoryTileVariant.simple => _SimpleRadioHistoryTile( + key: ValueKey(icyTitle), + icyTitle: icyTitle, + selected: selected, + ), + _RadioHistoryTileVariant.regular => ListTile( + key: ValueKey(icyTitle), + selected: selected, + selectedColor: context.theme.colorScheme.primary, + contentPadding: const EdgeInsets.symmetric(horizontal: kBigPadding), + leading: RadioHistoryTileImage( + key: ValueKey(icyTitle), + icyTitle: icyTitle, + ), + title: TapAbleText( + overflow: TextOverflow.visible, + maxLines: 10, + text: icyTitle, + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: CopyClipboardContent(text: icyTitle)), + ), + ), + subtitle: TapAbleText(text: icyName ?? context.l10n.station), + ), + }; + } +} + +class _SimpleRadioHistoryTile extends StatelessWidget { + const _SimpleRadioHistoryTile({ + super.key, + required this.icyTitle, + required this.selected, + }); + + final String icyTitle; + final bool selected; + + @override + Widget build(BuildContext context) => ListTile( + selected: selected, + selectedColor: context.theme.colorScheme.onSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: kBigPadding), + leading: Visibility(visible: selected, child: const Text('>')), + trailing: Visibility(visible: selected, child: const Text('<')), + title: TapAbleText( + overflow: TextOverflow.visible, + maxLines: 10, + text: icyTitle, + onTap: () => ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: CopyClipboardContent(text: icyTitle))), + ), + subtitle: Text( + di().getMetadata(icyTitle)?.icyName ?? context.l10n.station, + ), + ); +} + +class RadioHistoryTileImage extends StatelessWidget with WatchItMixin { + const RadioHistoryTileImage({ + super.key, + required this.icyTitle, + this.height = kAudioTrackWidth, + this.width = kAudioTrackWidth, + this.fit, + }); + + final String? icyTitle; + + final double height, width; + final BoxFit? fit; + + @override + Widget build(BuildContext context) { + final bR = BorderRadius.circular(4); + final imageUrl = watchPropertyValue( + (OnlineArtModel m) => m.getCover(icyTitle!), + ); + + return Tooltip( + message: context.l10n.metadata, + child: ClipRRect( + borderRadius: bR, + child: InkWell( + borderRadius: bR, + + child: SizedBox( + height: height, + width: width, + child: SafeNetworkImage( + url: imageUrl, + fallBackIcon: const Icon(Icons.radio), + filterQuality: FilterQuality.medium, + fit: fit ?? BoxFit.fitHeight, + ), + ), + ), + ), + ); + } +} diff --git a/lib/radio/view/sliver_radio_history_list.dart b/lib/radio/view/sliver_radio_history_list.dart new file mode 100644 index 0000000..7cff80c --- /dev/null +++ b/lib/radio/view/sliver_radio_history_list.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; + +import '../../common/view/no_search_result_page.dart'; +import '../../extensions/build_context_x.dart'; +import '../radio_service.dart'; +import 'radio_history_tile.dart'; + +class SliverRadioHistoryList extends StatelessWidget with WatchItMixin { + const SliverRadioHistoryList({ + super.key, + this.filter, + this.emptyMessage, + this.padding, + this.emptyIcon, + this.allowNavigation = true, + }); + + final String? filter; + final Widget? emptyMessage; + final Widget? emptyIcon; + final EdgeInsetsGeometry? padding; + final bool allowNavigation; + + @override + Widget build(BuildContext context) { + final length = + watchStream( + (RadioService m) => m.propertiesChanged.map( + (_) => + di().filteredRadioHistory(filter: filter).length, + ), + initialValue: di() + .filteredRadioHistory(filter: filter) + .length, + preserveState: false, + allowStreamChange: true, + ).data ?? + 0; + + final current = watchStream( + (RadioService m) => m.propertiesChanged.map((_) => m.mpvMetaData), + initialValue: di().mpvMetaData, + allowStreamChange: true, + preserveState: false, + ).data; + + if (length == 0) { + return SliverToBoxAdapter( + child: NoSearchResultPage( + message: emptyMessage ?? Text(context.l10n.emptyHearingHistory), + ), + ); + } + + return SliverPadding( + padding: padding ?? EdgeInsets.zero, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final reversedIndex = length - index - 1; + final e = di() + .filteredRadioHistory(filter: filter) + .elementAt(reversedIndex); + return RadioHistoryTile( + icyTitle: e.key, + selected: + current?.icyTitle != null && + current?.icyTitle == e.value.icyTitle, + allowNavigation: allowNavigation, + ); + }, childCount: length), + ), + ); + } +} diff --git a/lib/radio/view/station_page.dart b/lib/radio/view/station_page.dart new file mode 100644 index 0000000..e0e5db2 --- /dev/null +++ b/lib/radio/view/station_page.dart @@ -0,0 +1,120 @@ +import 'package:blur/blur.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_it/flutter_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:yaru/yaru.dart'; + +import '../../common/view/safe_network_image.dart'; +import '../../common/view/sliver_sticky_panel.dart'; +import '../../common/view/ui_constants.dart'; +import '../../extensions/build_context_x.dart'; +import '../../player/data/station_media.dart'; +import '../../player/view/play_media_button.dart'; +import '../../player/view/player_view.dart'; +import '../../search/copy_to_clipboard_content.dart'; +import '../radio_service.dart'; +import 'radio_browser_station_star_button.dart'; +import 'sliver_radio_history_list.dart'; + +class StationPage extends StatelessWidget { + const StationPage({ + super.key, + required this.uuid, + required this.stationMedia, + }); + + final String uuid; + final StationMedia stationMedia; + + static void go(BuildContext context, {required StationMedia media}) => + context.go('/station/${Uri.encodeComponent(media.id)}', extra: media); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: YaruWindowTitleBar( + border: BorderSide.none, + leading: Center( + child: BackButton( + onPressed: () => context.canPop() ? context.pop() : context.go('/'), + ), + ), + title: Text(stationMedia.title), + ), + bottomNavigationBar: const PlayerView(), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + if (stationMedia.artUrl != null) + Blur( + blur: 20, + colorOpacity: 0.7, + blurColor: const Color.fromARGB(255, 48, 48, 48), + child: SafeNetworkImage( + height: 350, + width: double.infinity, + url: stationMedia.artUrl, + fit: BoxFit.cover, + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SafeNetworkImage( + height: 250, + width: 250, + url: stationMedia.artUrl, + fit: BoxFit.fitHeight, + ), + ), + ], + ), + ), + const SizedBox(height: kBigPadding), + Text( + stationMedia.title, + style: context.theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + SliverStickyPanel( + controlPanel: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: kSmallPadding, + children: [ + RadioBrowserStationStarButton(media: stationMedia), + PlayMediasButton(medias: [stationMedia]), + IconButton( + onPressed: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: CopyClipboardContent( + text: di().getRadioHistoryList( + filter: stationMedia.title, + ), + ), + ), + ), + icon: const Icon(Icons.copy), + ), + ], + ), + ), + SliverRadioHistoryList( + filter: stationMedia.title.toLowerCase(), + emptyMessage: const Text(''), + emptyIcon: const Icon(Icons.radio), + padding: const EdgeInsets.all(kBigPadding), + allowNavigation: false, + ), + ], + ), + ); +} diff --git a/lib/register_dependencies.dart b/lib/register_dependencies.dart index b5f0907..f5e1e47 100644 --- a/lib/register_dependencies.dart +++ b/lib/register_dependencies.dart @@ -14,6 +14,7 @@ import 'collection/collection_manager.dart'; import 'common/extenal_path_service.dart'; import 'common/platforms.dart'; import 'notifications/notifications_service.dart'; +import 'online_art/online_art_model.dart'; import 'online_art/online_art_service.dart'; import 'player/player_manager.dart'; import 'podcasts/download_service.dart'; @@ -50,6 +51,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 +71,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( @@ -80,23 +96,13 @@ void registerDependencies() { // 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( - () => PodcastService( - libraryService: di(), - settingsService: di(), - ), + () => PodcastService(settingsService: di()), dependsOn: [PodcastLibraryService, SettingsService], ) ..registerSingleton(SearchManager()) @@ -106,9 +112,11 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), podcastLibraryService: di(), + downloadService: di(), notificationsService: di(), + playerManager: di(), ), - dependsOn: [PodcastService], + dependsOn: [PodcastService, PlayerManager], ) ..registerLazySingleton( () => const ExternalPathService(), @@ -120,12 +128,6 @@ void registerDependencies() { ), dependsOn: [SettingsService], ) - ..registerLazySingleton( - () => DownloadService( - libraryService: di(), - dio: di(), - ), - ) ..registerSingletonWithDependencies( () => RadioLibraryService(sharedPreferences: di()), dependsOn: [SharedPreferences], @@ -148,5 +150,8 @@ void registerDependencies() { searchManager: di(), collectionManager: di(), ), + ) + ..registerLazySingleton( + () => OnlineArtModel(onlineArtService: di()), ); } diff --git a/pubspec.lock b/pubspec.lock index d8f588f..7c96d26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -470,6 +470,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104 + url: "https://pub.dev" + source: hosted + version: "17.0.0" gsettings: dependency: transitive description: @@ -709,10 +717,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 +1219,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: diff --git a/pubspec.yaml b/pubspec.yaml index 26a788c..7b5d160 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,7 @@ dependencies: flutter_tabler_icons: ^1.43.0 flutter_widget_from_html_core: ^0.17.0 future_loading_dialog: ^0.3.0 + go_router: ^17.0.0 handy_window: ^0.4.0 html: ^0.15.6 intl: ^0.20.2