Compare commits
@@ -4,10 +4,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- master
|
||||||
- 'claude/**'
|
- 'claude/**'
|
||||||
|
- 'feature/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -35,6 +38,12 @@ jobs:
|
|||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Generate code (Drift database)
|
||||||
|
run: dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
|
- name: Generate localizations
|
||||||
|
run: flutter gen-l10n
|
||||||
|
|
||||||
- name: Generate launcher icons
|
- name: Generate launcher icons
|
||||||
run: flutter pub run flutter_launcher_icons
|
run: flutter pub run flutter_launcher_icons
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
|
|||||||
- **Playlist Support** - Browse and play your playlists
|
- **Playlist Support** - Browse and play your playlists
|
||||||
- **Search** - Find music across your entire library
|
- **Search** - Find music across your entire library
|
||||||
|
|
||||||
|
### Audiobooks
|
||||||
|
- **Audiobooks Tab** - Browse your audiobook library with grid/list view options
|
||||||
|
- **Series Support** - View audiobooks organized by series with collage cover art
|
||||||
|
- **Author Browsing** - Browse audiobooks by author
|
||||||
|
- **Chapter Navigation** - Jump between chapters with timestamp display
|
||||||
|
- **Progress Tracking** - Track your listening progress across sessions
|
||||||
|
- **Continue Listening** - Pick up where you left off
|
||||||
|
- **Mark as Finished/Unplayed** - Manage your reading progress
|
||||||
|
|
||||||
### Smart Features
|
### Smart Features
|
||||||
|
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||||
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
- **Auto-Reconnect** - Automatically reconnects when connection is lost
|
||||||
- **Caching** - Fast navigation with cached content
|
- **Offline Browsing** - Browse your cached library even when disconnected
|
||||||
- **Hero Animations** - Smooth transitions between screens
|
- **Hero Animations** - Smooth transitions between screens
|
||||||
|
|
||||||
### Theming
|
### Theming
|
||||||
@@ -57,15 +67,14 @@ This application was built with AI-assisted development using **Claude Code** an
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/screenshots/1.png" alt="Connection Screen" width="250">
|
<img src="assets/screenshots/1.png?v=2" alt="Connection Screen" width="150">
|
||||||
<img src="assets/screenshots/2.png" alt="Home Screen" width="250">
|
<img src="assets/screenshots/2.png?v=2" alt="Home Screen" width="150">
|
||||||
<img src="assets/screenshots/3.png" alt="Album Details" width="250">
|
<img src="assets/screenshots/3.png?v=2" alt="Album Details" width="150">
|
||||||
</div>
|
<img src="assets/screenshots/4.png?v=2" alt="Now Playing" width="150">
|
||||||
|
<img src="assets/screenshots/5.png?v=2" alt="Queue" width="150">
|
||||||
<div align="center">
|
<img src="assets/screenshots/6.png?v=2" alt="Settings" width="150">
|
||||||
<img src="assets/screenshots/4.png" alt="Now Playing" width="250">
|
<img src="assets/screenshots/7.png?v=2" alt="Audiobooks" width="150">
|
||||||
<img src="assets/screenshots/5.png" alt="Device Selection" width="250">
|
<img src="assets/screenshots/8.png?v=2" alt="Audiobook Player" width="150">
|
||||||
<img src="assets/screenshots/6.png" alt="Settings" width="250">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
@@ -94,11 +103,10 @@ Ensemble supports multiple authentication methods:
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Music Assistant server (v2.7.0 or later recommended)
|
- Music Assistant server (v2.7.0 beta 20 or later)
|
||||||
- Network connectivity to your Music Assistant server
|
- Network connectivity to your Music Assistant server
|
||||||
- Android device (Android 5.0+)
|
- Android device (Android 5.0+)
|
||||||
|
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||||
> **Note:** Local device playback (streaming to your phone) is not currently supported on Music Assistant beta 20+. This is due to MA replacing the built-in player with Sendspin. Support will be added once Sendspin is more stable. Remote control of other players works fine.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,255 @@
|
|||||||
|
# Audiobooks Integration - Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add audiobook support to Ensemble with automatic user profile separation based on Music Assistant authentication. Includes local database for performance caching and per-user data isolation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Database Foundation
|
||||||
|
|
||||||
|
**Goal:** Add persistent local storage layer using Drift (SQLite)
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Add Drift package dependencies (`drift`, `drift_flutter`, `path_provider`)
|
||||||
|
- [ ] Create database class with schema version management
|
||||||
|
- [ ] Create `profiles` table:
|
||||||
|
```dart
|
||||||
|
class Profiles extends Table {
|
||||||
|
TextColumn get username => text()(); // MA username (primary key)
|
||||||
|
TextColumn get displayName => text().nullable()();
|
||||||
|
TextColumn get source => text()(); // 'ma_auth' or 'manual'
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Create `recently_played` table:
|
||||||
|
```dart
|
||||||
|
class RecentlyPlayed extends Table {
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
TextColumn get profileUsername => text().references(Profiles, #username)();
|
||||||
|
TextColumn get mediaId => text()();
|
||||||
|
TextColumn get mediaType => text()(); // 'track', 'album', 'artist', 'audiobook'
|
||||||
|
TextColumn get name => text()();
|
||||||
|
TextColumn get artist => text().nullable()();
|
||||||
|
TextColumn get imageUrl => text().nullable()();
|
||||||
|
DateTimeColumn get playedAt => dateTime()();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Create `library_cache` table for albums/artists/audiobooks
|
||||||
|
- [ ] Generate Drift code (`dart run build_runner build`)
|
||||||
|
- [ ] Create `DatabaseService` singleton with initialization
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Database initializes on app startup
|
||||||
|
- Schema migrations work correctly
|
||||||
|
- CRUD operations functional for all tables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Profile System
|
||||||
|
|
||||||
|
**Goal:** Auto-create and manage user profiles based on MA authentication
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Create `ProfileService` class
|
||||||
|
- [ ] On MA login success: check if profile exists for username
|
||||||
|
- If not, create new profile with `displayName` from MA user info
|
||||||
|
- Set as active profile
|
||||||
|
- [ ] On manual name entry (no-auth): create/select profile by entered name
|
||||||
|
- [ ] Add `getCurrentProfile()` method
|
||||||
|
- [ ] Migrate existing `ownerName` to profile on first launch post-update
|
||||||
|
- [ ] Add profile indicator to settings screen (show current profile name)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Profile auto-created on first login
|
||||||
|
- Correct profile selected on subsequent logins
|
||||||
|
- Existing users migrated seamlessly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Recently Played Migration
|
||||||
|
|
||||||
|
**Goal:** Move recently played from memory cache to database, scoped by profile
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Update `CacheService` to use database for recently played
|
||||||
|
- [ ] Scope all recently played queries by `profileUsername`
|
||||||
|
- [ ] Update home screen to load recently played from database
|
||||||
|
- [ ] Add `addRecentlyPlayed()` method that records profile + media info
|
||||||
|
- [ ] Limit recently played to last 50 items per profile
|
||||||
|
- [ ] Remove old memory-based recently played logic
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Recently played persists across app restarts
|
||||||
|
- Each profile sees only their own recently played
|
||||||
|
- No cross-contamination between profiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Library Caching
|
||||||
|
|
||||||
|
**Goal:** Cache library data locally for instant UI and reduced API calls
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Create `SyncService` for background library synchronization
|
||||||
|
- [ ] On app launch: load from cache immediately, sync in background
|
||||||
|
- [ ] Store albums, artists in `library_cache` table
|
||||||
|
- [ ] Add `lastSynced` timestamp to track freshness
|
||||||
|
- [ ] Implement delta sync (only fetch changed items if MA supports it)
|
||||||
|
- [ ] Add pull-to-refresh that forces full sync
|
||||||
|
- [ ] Show subtle sync indicator when background sync in progress
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Home screen loads instantly from cache
|
||||||
|
- Library tabs load instantly
|
||||||
|
- Background sync updates data without blocking UI
|
||||||
|
- Stale data refreshed within reasonable timeframe
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Scrollable Library Tabs
|
||||||
|
|
||||||
|
**Goal:** Make library tab row scrollable to accommodate more tabs
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Convert `TabBar` to scrollable variant (`isScrollable: true`)
|
||||||
|
- [ ] Adjust tab styling for scrollable layout
|
||||||
|
- [ ] Ensure indicator line spans correctly
|
||||||
|
- [ ] Test with varying number of tabs
|
||||||
|
- [ ] Add "Books" tab placeholder (disabled until Phase 6)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Tabs scroll horizontally when they exceed screen width
|
||||||
|
- Current tab indicator works correctly
|
||||||
|
- Smooth scrolling experience
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Audiobooks Tab - Browse by Author
|
||||||
|
|
||||||
|
**Goal:** Add Books tab with author → books hierarchy
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Add MA API method to fetch audiobooks: `getAudiobooks()`
|
||||||
|
- [ ] Add MA API method to fetch audiobook authors
|
||||||
|
- [ ] Create `AudiobookAuthor` model
|
||||||
|
- [ ] Create `Audiobook` model with fields:
|
||||||
|
- `id`, `name`, `authors`, `narrators`, `description`
|
||||||
|
- `duration`, `imageUrl`, `chapters`, `resumePosition`, `fullyPlayed`
|
||||||
|
- [ ] Create `BooksScreen` with author list view
|
||||||
|
- [ ] Tapping author expands to show their books
|
||||||
|
- [ ] Add to library tab bar
|
||||||
|
- [ ] Cache audiobooks in local database
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Books tab shows list of authors
|
||||||
|
- Authors expandable to show their audiobooks
|
||||||
|
- Audiobook covers and titles display correctly
|
||||||
|
- Data cached locally for fast subsequent loads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Audiobook Detail Screen
|
||||||
|
|
||||||
|
**Goal:** Full audiobook detail view with metadata and chapters
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Create `AudiobookDetailScreen`
|
||||||
|
- [ ] Display: cover art, title, author(s), narrator(s)
|
||||||
|
- [ ] Display: description (expandable if long)
|
||||||
|
- [ ] Display: total duration, progress indicator
|
||||||
|
- [ ] Chapter list with:
|
||||||
|
- Chapter title
|
||||||
|
- Duration
|
||||||
|
- Played indicator (based on resume position)
|
||||||
|
- [ ] Tapping chapter starts playback from that chapter
|
||||||
|
- [ ] "Resume" button that continues from `resume_position_ms`
|
||||||
|
- [ ] "Start Over" option
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- All metadata displays correctly
|
||||||
|
- Chapters show with accurate durations
|
||||||
|
- Resume position reflected in UI
|
||||||
|
- Can start playback from any chapter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Audiobook Playback Integration
|
||||||
|
|
||||||
|
**Goal:** Proper audiobook playback with seek and resume
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Implement `playAudiobook(bookId, startPosition?)` method
|
||||||
|
- [ ] Use `queueCommandSeek()` to resume at correct position
|
||||||
|
- [ ] Update now playing UI to show:
|
||||||
|
- Book title instead of track
|
||||||
|
- Current chapter name
|
||||||
|
- Chapter progress (not just total progress)
|
||||||
|
- [ ] Add skip forward/back buttons (30 sec for audiobooks?)
|
||||||
|
- [ ] Add chapter skip buttons (next/previous chapter)
|
||||||
|
- [ ] Record to recently played when audiobook starts (with 'audiobook' type)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Audiobooks resume from correct position
|
||||||
|
- Chapter information shows in now playing
|
||||||
|
- Skip controls work correctly
|
||||||
|
- Recently played records audiobook plays
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Polish & Edge Cases
|
||||||
|
|
||||||
|
**Goal:** Handle edge cases and improve UX
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
- [ ] Handle audiobooks with no chapters gracefully
|
||||||
|
- [ ] Handle very long audiobooks (20+ hours)
|
||||||
|
- [ ] Add "Mark as Finished" option
|
||||||
|
- [ ] Add "Mark as Unplayed" option
|
||||||
|
- [ ] Ensure offline browsing works for cached audiobooks
|
||||||
|
- [ ] Test with various audiobook lengths and chapter counts
|
||||||
|
- [ ] Performance testing with large audiobook libraries
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- No crashes on edge cases
|
||||||
|
- Smooth performance
|
||||||
|
- All user actions have appropriate feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (Out of Scope)
|
||||||
|
|
||||||
|
- Sleep timer
|
||||||
|
- Playback speed control
|
||||||
|
- Series support (requires MA provider update)
|
||||||
|
- Bookmarks
|
||||||
|
- Listening statistics
|
||||||
|
- Download for offline playback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### Database Choice: Drift
|
||||||
|
- SQLite wrapper with type-safe queries
|
||||||
|
- Good for relational data (profiles → recently_played)
|
||||||
|
- Mature, well-documented
|
||||||
|
- Supports migrations
|
||||||
|
|
||||||
|
### MA API Endpoints Used
|
||||||
|
- `music/audiobooks` - List audiobooks
|
||||||
|
- `music/audiobook/{id}` - Get audiobook details
|
||||||
|
- `player_queues/play_media` - Start playback
|
||||||
|
- `player_queues/seek` - Seek to position
|
||||||
|
- `music/in_progress_items` - Get items with resume positions
|
||||||
|
|
||||||
|
### Profile Data Flow
|
||||||
|
```
|
||||||
|
MA Login → Get user_info → Check/Create Profile → Set Active → Load Profile Data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recently Played Flow
|
||||||
|
```
|
||||||
|
Play Media → Check Active Profile → Insert RecentlyPlayed Row → Update UI
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
|
synthetic-package: false
|
||||||
|
output-class: S
|
||||||
@@ -13,4 +13,16 @@ class HeroTags {
|
|||||||
|
|
||||||
/// Artist image hero tag prefix
|
/// Artist image hero tag prefix
|
||||||
static const String artistImage = 'artist_image_';
|
static const String artistImage = 'artist_image_';
|
||||||
|
|
||||||
|
/// Audiobook cover hero tag prefix
|
||||||
|
static const String audiobookCover = 'audiobook_cover_';
|
||||||
|
|
||||||
|
/// Audiobook title hero tag prefix
|
||||||
|
static const String audiobookTitle = 'audiobook_title_';
|
||||||
|
|
||||||
|
/// Audiobook author name hero tag prefix (for navigating to author screen)
|
||||||
|
static const String audiobookAuthorName = 'audiobook_author_name_';
|
||||||
|
|
||||||
|
/// Audiobook author image hero tag prefix
|
||||||
|
static const String authorImage = 'author_image_';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
|
|
||||||
|
part 'database.g.dart';
|
||||||
|
|
||||||
|
/// User profiles - auto-created from MA login or manual entry
|
||||||
|
class Profiles extends Table {
|
||||||
|
/// MA username or manually entered name (primary key)
|
||||||
|
TextColumn get username => text()();
|
||||||
|
|
||||||
|
/// Display name from MA or same as username
|
||||||
|
TextColumn get displayName => text().nullable()();
|
||||||
|
|
||||||
|
/// How the profile was created: 'ma_auth' or 'manual'
|
||||||
|
TextColumn get source => text().withDefault(const Constant('ma_auth'))();
|
||||||
|
|
||||||
|
/// When the profile was created
|
||||||
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
/// Whether this is the currently active profile
|
||||||
|
BoolColumn get isActive => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {username};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recently played items - scoped by profile
|
||||||
|
class RecentlyPlayed extends Table {
|
||||||
|
/// Auto-incrementing ID
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// Profile this belongs to
|
||||||
|
TextColumn get profileUsername => text().references(Profiles, #username)();
|
||||||
|
|
||||||
|
/// Media item ID from Music Assistant
|
||||||
|
TextColumn get mediaId => text()();
|
||||||
|
|
||||||
|
/// Type: 'track', 'album', 'artist', 'playlist', 'audiobook'
|
||||||
|
TextColumn get mediaType => text()();
|
||||||
|
|
||||||
|
/// Display name of the item
|
||||||
|
TextColumn get name => text()();
|
||||||
|
|
||||||
|
/// Artist/author name (for display)
|
||||||
|
TextColumn get artistName => text().nullable()();
|
||||||
|
|
||||||
|
/// Image URL for the item
|
||||||
|
TextColumn get imageUrl => text().nullable()();
|
||||||
|
|
||||||
|
/// Additional metadata as JSON (e.g., album name for tracks)
|
||||||
|
TextColumn get metadata => text().nullable()();
|
||||||
|
|
||||||
|
/// When this was played
|
||||||
|
DateTimeColumn get playedAt => dateTime()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached library items for fast startup
|
||||||
|
class LibraryCache extends Table {
|
||||||
|
/// Composite key: provider + item_id
|
||||||
|
TextColumn get cacheKey => text()();
|
||||||
|
|
||||||
|
/// Type: 'album', 'artist', 'track', 'playlist', 'audiobook', 'audiobook_author'
|
||||||
|
TextColumn get itemType => text()();
|
||||||
|
|
||||||
|
/// The item ID from Music Assistant
|
||||||
|
TextColumn get itemId => text()();
|
||||||
|
|
||||||
|
/// Serialized item data as JSON
|
||||||
|
TextColumn get data => text()();
|
||||||
|
|
||||||
|
/// When this was last synced from MA
|
||||||
|
DateTimeColumn get lastSynced => dateTime()();
|
||||||
|
|
||||||
|
/// Whether this item was deleted on the server
|
||||||
|
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {cacheKey};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync metadata - tracks last sync times per data type
|
||||||
|
class SyncMetadata extends Table {
|
||||||
|
/// What was synced: 'albums', 'artists', 'audiobooks', etc.
|
||||||
|
TextColumn get syncType => text()();
|
||||||
|
|
||||||
|
/// When the last successful sync completed
|
||||||
|
DateTimeColumn get lastSyncedAt => dateTime()();
|
||||||
|
|
||||||
|
/// Number of items synced
|
||||||
|
IntColumn get itemCount => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {syncType};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted playback state - survives app restarts and disconnects
|
||||||
|
class PlaybackState extends Table {
|
||||||
|
/// Always 'current' - single row table
|
||||||
|
TextColumn get id => text().withDefault(const Constant('current'))();
|
||||||
|
|
||||||
|
/// Selected player ID
|
||||||
|
TextColumn get playerId => text().nullable()();
|
||||||
|
|
||||||
|
/// Selected player name (for display if player unavailable)
|
||||||
|
TextColumn get playerName => text().nullable()();
|
||||||
|
|
||||||
|
/// Current track as JSON
|
||||||
|
TextColumn get currentTrackJson => text().nullable()();
|
||||||
|
|
||||||
|
/// Current position in seconds
|
||||||
|
RealColumn get positionSeconds => real().withDefault(const Constant(0.0))();
|
||||||
|
|
||||||
|
/// Whether playback was active
|
||||||
|
BoolColumn get isPlaying => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
/// When this state was saved
|
||||||
|
DateTimeColumn get savedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached player list - for instant display on app resume
|
||||||
|
class CachedPlayers extends Table {
|
||||||
|
/// Player ID from Music Assistant
|
||||||
|
TextColumn get playerId => text()();
|
||||||
|
|
||||||
|
/// Player data as JSON
|
||||||
|
TextColumn get playerJson => text()();
|
||||||
|
|
||||||
|
/// Current track for this player as JSON (for mini player display)
|
||||||
|
TextColumn get currentTrackJson => text().nullable()();
|
||||||
|
|
||||||
|
/// When this was last updated
|
||||||
|
DateTimeColumn get lastUpdated => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {playerId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached queue items for selected player
|
||||||
|
class CachedQueue extends Table {
|
||||||
|
/// Auto-incrementing ID for ordering
|
||||||
|
IntColumn get id => integer().autoIncrement()();
|
||||||
|
|
||||||
|
/// Player ID this queue belongs to
|
||||||
|
TextColumn get playerId => text()();
|
||||||
|
|
||||||
|
/// Queue item as JSON
|
||||||
|
TextColumn get itemJson => text()();
|
||||||
|
|
||||||
|
/// Position in queue
|
||||||
|
IntColumn get position => integer()();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached home row data for instant display on startup
|
||||||
|
class HomeRowCache extends Table {
|
||||||
|
/// Row type: 'recent_albums', 'discover_artists', 'discover_albums'
|
||||||
|
TextColumn get rowType => text()();
|
||||||
|
|
||||||
|
/// Serialized list of items as JSON array
|
||||||
|
TextColumn get itemsJson => text()();
|
||||||
|
|
||||||
|
/// When this was last updated
|
||||||
|
DateTimeColumn get lastUpdated => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {rowType};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata, PlaybackState, CachedPlayers, CachedQueue, HomeRowCache])
|
||||||
|
class AppDatabase extends _$AppDatabase {
|
||||||
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MigrationStrategy get migration {
|
||||||
|
return MigrationStrategy(
|
||||||
|
onCreate: (Migrator m) async {
|
||||||
|
await m.createAll();
|
||||||
|
},
|
||||||
|
onUpgrade: (Migrator m, int from, int to) async {
|
||||||
|
// Migration from v1 to v2: Add playback state, cached players, cached queue tables
|
||||||
|
if (from < 2) {
|
||||||
|
await m.createTable(playbackState);
|
||||||
|
await m.createTable(cachedPlayers);
|
||||||
|
await m.createTable(cachedQueue);
|
||||||
|
}
|
||||||
|
// Migration from v2 to v3: Add home row cache table
|
||||||
|
if (from < 3) {
|
||||||
|
await m.createTable(homeRowCache);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static QueryExecutor _openConnection() {
|
||||||
|
return driftDatabase(name: 'ensemble_db');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Profile Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get the currently active profile
|
||||||
|
Future<Profile?> getActiveProfile() async {
|
||||||
|
return (select(profiles)..where((p) => p.isActive.equals(true)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a profile by username
|
||||||
|
Future<Profile?> getProfile(String username) async {
|
||||||
|
return (select(profiles)..where((p) => p.username.equals(username)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update a profile and set it as active
|
||||||
|
Future<Profile> setActiveProfile({
|
||||||
|
required String username,
|
||||||
|
String? displayName,
|
||||||
|
required String source,
|
||||||
|
}) async {
|
||||||
|
return transaction(() async {
|
||||||
|
// Deactivate all profiles
|
||||||
|
await (update(profiles)..where((p) => p.isActive.equals(true)))
|
||||||
|
.write(const ProfilesCompanion(isActive: Value(false)));
|
||||||
|
|
||||||
|
// Check if profile exists
|
||||||
|
final existing = await getProfile(username);
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
// Update and activate existing profile
|
||||||
|
await (update(profiles)..where((p) => p.username.equals(username)))
|
||||||
|
.write(ProfilesCompanion(
|
||||||
|
displayName: Value(displayName ?? existing.displayName),
|
||||||
|
isActive: const Value(true),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Create new profile
|
||||||
|
await into(profiles).insert(ProfilesCompanion.insert(
|
||||||
|
username: username,
|
||||||
|
displayName: Value(displayName),
|
||||||
|
source: Value(source),
|
||||||
|
isActive: const Value(true),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await getProfile(username))!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all profiles
|
||||||
|
Future<List<Profile>> getAllProfiles() async {
|
||||||
|
return select(profiles).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Recently Played Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Add an item to recently played for the active profile
|
||||||
|
Future<void> addRecentlyPlayed({
|
||||||
|
required String mediaId,
|
||||||
|
required String mediaType,
|
||||||
|
required String name,
|
||||||
|
String? artistName,
|
||||||
|
String? imageUrl,
|
||||||
|
String? metadata,
|
||||||
|
}) async {
|
||||||
|
final profile = await getActiveProfile();
|
||||||
|
if (profile == null) return;
|
||||||
|
|
||||||
|
await transaction(() async {
|
||||||
|
// Remove existing entry for this item (to move it to top)
|
||||||
|
await (delete(recentlyPlayed)
|
||||||
|
..where((r) => r.profileUsername.equals(profile.username))
|
||||||
|
..where((r) => r.mediaId.equals(mediaId))
|
||||||
|
..where((r) => r.mediaType.equals(mediaType)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
// Add new entry
|
||||||
|
await into(recentlyPlayed).insert(RecentlyPlayedCompanion.insert(
|
||||||
|
profileUsername: profile.username,
|
||||||
|
mediaId: mediaId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
name: name,
|
||||||
|
artistName: Value(artistName),
|
||||||
|
imageUrl: Value(imageUrl),
|
||||||
|
metadata: Value(metadata),
|
||||||
|
playedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Keep only last 50 items per profile
|
||||||
|
final allItems = await (select(recentlyPlayed)
|
||||||
|
..where((r) => r.profileUsername.equals(profile.username))
|
||||||
|
..orderBy([(r) => OrderingTerm.desc(r.playedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (allItems.length > 50) {
|
||||||
|
final toDelete = allItems.sublist(50);
|
||||||
|
for (final item in toDelete) {
|
||||||
|
await (delete(recentlyPlayed)..where((r) => r.id.equals(item.id))).go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recently played items for the active profile
|
||||||
|
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) async {
|
||||||
|
final profile = await getActiveProfile();
|
||||||
|
if (profile == null) return [];
|
||||||
|
|
||||||
|
return (select(recentlyPlayed)
|
||||||
|
..where((r) => r.profileUsername.equals(profile.username))
|
||||||
|
..orderBy([(r) => OrderingTerm.desc(r.playedAt)])
|
||||||
|
..limit(limit))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear recently played for the active profile
|
||||||
|
Future<void> clearRecentlyPlayed() async {
|
||||||
|
final profile = await getActiveProfile();
|
||||||
|
if (profile == null) return;
|
||||||
|
|
||||||
|
await (delete(recentlyPlayed)
|
||||||
|
..where((r) => r.profileUsername.equals(profile.username)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Library Cache Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Cache a library item
|
||||||
|
Future<void> cacheItem({
|
||||||
|
required String itemType,
|
||||||
|
required String itemId,
|
||||||
|
required String data,
|
||||||
|
}) async {
|
||||||
|
final cacheKey = '${itemType}_$itemId';
|
||||||
|
|
||||||
|
await into(libraryCache).insertOnConflictUpdate(LibraryCacheCompanion.insert(
|
||||||
|
cacheKey: cacheKey,
|
||||||
|
itemType: itemType,
|
||||||
|
itemId: itemId,
|
||||||
|
data: data,
|
||||||
|
lastSynced: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached items by type
|
||||||
|
Future<List<LibraryCacheData>> getCachedItems(String itemType) async {
|
||||||
|
return (select(libraryCache)
|
||||||
|
..where((c) => c.itemType.equals(itemType))
|
||||||
|
..where((c) => c.isDeleted.equals(false)))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single cached item
|
||||||
|
Future<LibraryCacheData?> getCachedItem(String itemType, String itemId) async {
|
||||||
|
final cacheKey = '${itemType}_$itemId';
|
||||||
|
return (select(libraryCache)..where((c) => c.cacheKey.equals(cacheKey)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark items as deleted (soft delete)
|
||||||
|
Future<void> markItemsDeleted(String itemType, List<String> itemIds) async {
|
||||||
|
for (final itemId in itemIds) {
|
||||||
|
final cacheKey = '${itemType}_$itemId';
|
||||||
|
await (update(libraryCache)..where((c) => c.cacheKey.equals(cacheKey)))
|
||||||
|
.write(const LibraryCacheCompanion(isDeleted: Value(true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached items of a type
|
||||||
|
Future<void> clearCache(String itemType) async {
|
||||||
|
await (delete(libraryCache)..where((c) => c.itemType.equals(itemType))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear entire cache
|
||||||
|
Future<void> clearAllCache() async {
|
||||||
|
await delete(libraryCache).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Sync Metadata Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Update sync metadata for a type
|
||||||
|
Future<void> updateSyncMetadata(String syncType, int itemCount) async {
|
||||||
|
await into(syncMetadata).insertOnConflictUpdate(SyncMetadataCompanion.insert(
|
||||||
|
syncType: syncType,
|
||||||
|
lastSyncedAt: DateTime.now(),
|
||||||
|
itemCount: Value(itemCount),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get last sync time for a type
|
||||||
|
Future<DateTime?> getLastSyncTime(String syncType) async {
|
||||||
|
final meta = await (select(syncMetadata)
|
||||||
|
..where((s) => s.syncType.equals(syncType)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
return meta?.lastSyncedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if sync is needed (older than duration)
|
||||||
|
Future<bool> needsSync(String syncType, Duration maxAge) async {
|
||||||
|
final lastSync = await getLastSyncTime(syncType);
|
||||||
|
if (lastSync == null) return true;
|
||||||
|
return DateTime.now().difference(lastSync) > maxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Playback State Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save current playback state
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
String? playerId,
|
||||||
|
String? playerName,
|
||||||
|
String? currentTrackJson,
|
||||||
|
double positionSeconds = 0.0,
|
||||||
|
bool isPlaying = false,
|
||||||
|
}) async {
|
||||||
|
await into(playbackState).insertOnConflictUpdate(PlaybackStateCompanion.insert(
|
||||||
|
id: const Value('current'),
|
||||||
|
playerId: Value(playerId),
|
||||||
|
playerName: Value(playerName),
|
||||||
|
currentTrackJson: Value(currentTrackJson),
|
||||||
|
positionSeconds: Value(positionSeconds),
|
||||||
|
isPlaying: Value(isPlaying),
|
||||||
|
savedAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get saved playback state
|
||||||
|
Future<PlaybackStateData?> getPlaybackState() async {
|
||||||
|
return (select(playbackState)..where((p) => p.id.equals('current')))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear playback state
|
||||||
|
Future<void> clearPlaybackState() async {
|
||||||
|
await delete(playbackState).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cached Players Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Cache a player with its current track
|
||||||
|
Future<void> cachePlayer({
|
||||||
|
required String playerId,
|
||||||
|
required String playerJson,
|
||||||
|
String? currentTrackJson,
|
||||||
|
}) async {
|
||||||
|
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
|
||||||
|
playerId: playerId,
|
||||||
|
playerJson: playerJson,
|
||||||
|
currentTrackJson: Value(currentTrackJson),
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache multiple players at once
|
||||||
|
Future<void> cachePlayers(List<Map<String, dynamic>> players) async {
|
||||||
|
await transaction(() async {
|
||||||
|
for (final player in players) {
|
||||||
|
await into(cachedPlayers).insertOnConflictUpdate(CachedPlayersCompanion.insert(
|
||||||
|
playerId: player['playerId'] as String,
|
||||||
|
playerJson: player['playerJson'] as String,
|
||||||
|
currentTrackJson: Value(player['currentTrackJson'] as String?),
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all cached players
|
||||||
|
Future<List<CachedPlayer>> getCachedPlayers() async {
|
||||||
|
return (select(cachedPlayers)
|
||||||
|
..orderBy([(p) => OrderingTerm.desc(p.lastUpdated)]))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific cached player
|
||||||
|
Future<CachedPlayer?> getCachedPlayer(String playerId) async {
|
||||||
|
return (select(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update cached track for a player
|
||||||
|
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) async {
|
||||||
|
await (update(cachedPlayers)..where((p) => p.playerId.equals(playerId)))
|
||||||
|
.write(CachedPlayersCompanion(
|
||||||
|
currentTrackJson: Value(trackJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached players
|
||||||
|
Future<void> clearCachedPlayers() async {
|
||||||
|
await delete(cachedPlayers).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Cached Queue Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save queue for a player
|
||||||
|
Future<void> saveQueue(String playerId, List<String> itemJsonList) async {
|
||||||
|
await transaction(() async {
|
||||||
|
// Clear existing queue for this player
|
||||||
|
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
|
||||||
|
|
||||||
|
// Insert new queue items
|
||||||
|
for (var i = 0; i < itemJsonList.length; i++) {
|
||||||
|
await into(cachedQueue).insert(CachedQueueCompanion.insert(
|
||||||
|
playerId: playerId,
|
||||||
|
itemJson: itemJsonList[i],
|
||||||
|
position: i,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached queue for a player
|
||||||
|
Future<List<CachedQueueData>> getCachedQueue(String playerId) async {
|
||||||
|
return (select(cachedQueue)
|
||||||
|
..where((q) => q.playerId.equals(playerId))
|
||||||
|
..orderBy([(q) => OrderingTerm.asc(q.position)]))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queue for a player
|
||||||
|
Future<void> clearCachedQueue(String playerId) async {
|
||||||
|
await (delete(cachedQueue)..where((q) => q.playerId.equals(playerId))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached queues
|
||||||
|
Future<void> clearAllCachedQueues() async {
|
||||||
|
await delete(cachedQueue).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Home Row Cache Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save home row data
|
||||||
|
Future<void> saveHomeRowCache(String rowType, String itemsJson) async {
|
||||||
|
await into(homeRowCache).insertOnConflictUpdate(
|
||||||
|
HomeRowCacheCompanion(
|
||||||
|
rowType: Value(rowType),
|
||||||
|
itemsJson: Value(itemsJson),
|
||||||
|
lastUpdated: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached home row data
|
||||||
|
Future<HomeRowCacheData?> getHomeRowCache(String rowType) async {
|
||||||
|
return (select(homeRowCache)..where((h) => h.rowType.equals(rowType)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all home row cache
|
||||||
|
Future<void> clearHomeRowCache() async {
|
||||||
|
await delete(homeRowCache).go();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "de",
|
||||||
|
|
||||||
|
"appTitle": "Ensemble",
|
||||||
|
"connecting": "Verbinde...",
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"disconnected": "Getrennt",
|
||||||
|
|
||||||
|
"serverAddress": "Serveradresse",
|
||||||
|
"serverAddressHint": "z.B. music.example.com oder 192.168.1.100",
|
||||||
|
"yourName": "Dein Name",
|
||||||
|
"yourFirstName": "Dein Vorname",
|
||||||
|
"portOptional": "Port (Optional)",
|
||||||
|
"portHint": "z.B. 8095 oder leer lassen",
|
||||||
|
"portDescription": "Leer lassen bei Reverse Proxy oder Standardports. 8095 eingeben bei Direktverbindung.",
|
||||||
|
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"detectAndConnect": "Erkennen & Verbinden",
|
||||||
|
"connect": "Verbinden",
|
||||||
|
"disconnect": "Trennen",
|
||||||
|
|
||||||
|
"authServerUrlOptional": "Auth-Server URL (Optional)",
|
||||||
|
"authServerUrlHint": "z.B. auth.example.com (wenn abweichend vom Server)",
|
||||||
|
"authServerUrlDescription": "Leer lassen, wenn Authentifizierung auf demselben Server",
|
||||||
|
"detectedAuthType": "Erkannt: {authType}",
|
||||||
|
"serverRequiresAuth": "Dieser Server erfordert Authentifizierung",
|
||||||
|
|
||||||
|
"tailscaleVpnConnection": "Tailscale VPN-Verbindung",
|
||||||
|
"unencryptedConnection": "Unverschlusselte Verbindung",
|
||||||
|
"usingHttpOverTailscale": "HTTP uber Tailscale (verschlusselter Tunnel)",
|
||||||
|
"httpsFailedUsingHttp": "HTTPS fehlgeschlagen, verwende HTTP",
|
||||||
|
"httpNotEncrypted": "HTTP-Verbindung - Daten sind nicht verschlusselt",
|
||||||
|
|
||||||
|
"pleaseEnterServerAddress": "Bitte gib deine Music Assistant Serveradresse ein",
|
||||||
|
"pleaseEnterName": "Bitte gib deinen Namen ein",
|
||||||
|
"pleaseEnterValidPort": "Bitte gib eine gultige Portnummer ein (1-65535)",
|
||||||
|
"pleaseEnterCredentials": "Bitte gib Benutzername und Passwort ein",
|
||||||
|
"authFailed": "Authentifizierung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
|
||||||
|
"maLoginFailed": "Music Assistant Anmeldung fehlgeschlagen. Bitte uberprufe deine Anmeldedaten.",
|
||||||
|
"connectionFailed": "Verbindung zum Server fehlgeschlagen. Bitte uberprufe die Adresse und versuche es erneut.",
|
||||||
|
"detectingAuth": "Erkenne Authentifizierung...",
|
||||||
|
"cannotDetermineAuth": "Authentifizierungsanforderungen konnten nicht ermittelt werden. Bitte uberprufe die Server-URL.",
|
||||||
|
|
||||||
|
"noAuthentication": "Keine Authentifizierung",
|
||||||
|
"httpBasicAuth": "HTTP Basic Auth",
|
||||||
|
"authelia": "Authelia",
|
||||||
|
"musicAssistantLogin": "Music Assistant Anmeldung",
|
||||||
|
|
||||||
|
"pressBackToMinimize": "Erneut drucken zum Minimieren",
|
||||||
|
|
||||||
|
"recentlyPlayed": "Zuletzt gespielt",
|
||||||
|
"discoverArtists": "Kunstler entdecken",
|
||||||
|
"discoverAlbums": "Alben entdecken",
|
||||||
|
"continueListening": "Weiterhoren",
|
||||||
|
"discoverAudiobooks": "Horbücher entdecken",
|
||||||
|
"discoverSeries": "Serien entdecken",
|
||||||
|
"favoriteAlbums": "Lieblingsalben",
|
||||||
|
"favoriteArtists": "Lieblingskünstler",
|
||||||
|
"favoriteTracks": "Lieblingssongs",
|
||||||
|
|
||||||
|
"searchMusic": "Musik suchen...",
|
||||||
|
"searchForContent": "Nach Künstlern, Alben oder Songs suchen",
|
||||||
|
"retry": "Wiederholen",
|
||||||
|
"all": "Alle",
|
||||||
|
"artists": "Künstler",
|
||||||
|
"albums": "Alben",
|
||||||
|
"tracks": "Songs",
|
||||||
|
"playlists": "Wiedergabelisten",
|
||||||
|
"audiobooks": "Hörbücher",
|
||||||
|
"searchFailed": "Suche fehlgeschlagen. Bitte überprüfe deine Verbindung.",
|
||||||
|
|
||||||
|
"queue": "Warteschlange",
|
||||||
|
"playerQueue": "{playerName} Warteschlange",
|
||||||
|
"noPlayerSelected": "Kein Player ausgewählt",
|
||||||
|
"undo": "Rückgängig",
|
||||||
|
"removedItem": "{itemName} entfernt",
|
||||||
|
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"debugLogs": "Debug-Protokolle",
|
||||||
|
"themeMode": "Design",
|
||||||
|
"light": "Hell",
|
||||||
|
"dark": "Dunkel",
|
||||||
|
"system": "System",
|
||||||
|
"pullToRefresh": "Ziehe zum Aktualisieren der Bibliothek",
|
||||||
|
"viewDebugLogs": "Debug-Protokolle anzeigen",
|
||||||
|
|
||||||
|
"viewAllPlayers": "Alle Player anzeigen",
|
||||||
|
"copyLogs": "Protokolle kopieren",
|
||||||
|
"clearLogs": "Protokolle löschen",
|
||||||
|
"copyList": "Liste kopieren",
|
||||||
|
"close": "Schließen",
|
||||||
|
"allPlayersCount": "Alle Player ({count})",
|
||||||
|
"errorLoadingPlayers": "Fehler beim Laden der Player: {error}",
|
||||||
|
"logsCopied": "Protokolle in Zwischenablage kopiert",
|
||||||
|
"logsCleared": "Protokolle gelöscht",
|
||||||
|
"playerListCopied": "Playerliste in Zwischenablage kopiert!",
|
||||||
|
"noLogsYet": "Noch keine Protokolle",
|
||||||
|
"infoPlus": "Info+",
|
||||||
|
"warnings": "Warnungen",
|
||||||
|
"errors": "Fehler",
|
||||||
|
"showingEntries": "Zeige {filtered} von {total} Einträgen",
|
||||||
|
"thisDevice": "Dieses Gerät",
|
||||||
|
"ghostPlayer": "Geist-Player (Duplikat)",
|
||||||
|
"unavailableCorrupt": "Nicht verfügbar/Beschädigt",
|
||||||
|
"playerId": "ID: {id}",
|
||||||
|
"playerInfo": "Verfügbar: {available} | Anbieter: {provider}",
|
||||||
|
"shareBugReport": "Fehlerbericht teilen",
|
||||||
|
"moreOptions": "Weitere Optionen",
|
||||||
|
|
||||||
|
"failedToUpdateFavorite": "Favorit konnte nicht aktualisiert werden: {error}",
|
||||||
|
"noPlayersAvailable": "Keine Player verfügbar",
|
||||||
|
"albumAddedToQueue": "Album zur Warteschlange hinzugefügt",
|
||||||
|
"tracksAddedToQueue": "Songs zur Warteschlange hinzugefügt",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"noPlayersFound": "Keine Player gefunden",
|
||||||
|
|
||||||
|
"startingRadio": "Starte {name} Radio",
|
||||||
|
"failedToStartRadio": "Radio konnte nicht gestartet werden: {error}",
|
||||||
|
"startingRadioOnPlayer": "Starte {name} Radio auf {playerName}",
|
||||||
|
"addedRadioToQueue": "{name} Radio zur Warteschlange hinzugefügt",
|
||||||
|
"failedToAddToQueue": "Konnte nicht zur Warteschlange hinzufügen: {error}",
|
||||||
|
"radio": "Radio",
|
||||||
|
"inLibrary": "In Bibliothek",
|
||||||
|
"noAlbumsFound": "Keine Alben gefunden",
|
||||||
|
|
||||||
|
"playing": "Spiele {name}",
|
||||||
|
"playingTrack": "Spiele Song",
|
||||||
|
"nowPlaying": "Wird gespielt",
|
||||||
|
|
||||||
|
"markedAsFinished": "{name} als beendet markiert",
|
||||||
|
"failedToMarkFinished": "Konnte nicht als beendet markieren: {error}",
|
||||||
|
"markedAsUnplayed": "{name} als ungespielt markiert",
|
||||||
|
"failedToMarkUnplayed": "Konnte nicht als ungespielt markieren: {error}",
|
||||||
|
"failedToPlay": "Abspielen fehlgeschlagen: {error}",
|
||||||
|
"markAsFinished": "Als beendet markieren",
|
||||||
|
"markAsUnplayed": "Als ungespielt markieren",
|
||||||
|
"byAuthor": "Von {author}",
|
||||||
|
|
||||||
|
"audiobookCount": "{count} Hörbuch/Hörbücher",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"bookCount": "{count} Buch/Bücher",
|
||||||
|
"books": "Bücher",
|
||||||
|
|
||||||
|
"noFavoriteAudiobooks": "Keine Lieblingshörbücher",
|
||||||
|
"tapHeartAudiobook": "Tippe auf das Herz bei einem Hörbuch, um es zu Favoriten hinzuzufügen",
|
||||||
|
"noAudiobooks": "Keine Hörbücher",
|
||||||
|
"addAudiobooksHint": "Füge Hörbücher zu deiner Bibliothek hinzu, um sie hier zu sehen",
|
||||||
|
|
||||||
|
"noFavoriteArtists": "Keine Lieblingskünstler",
|
||||||
|
"tapHeartArtist": "Tippe auf das Herz bei einem Künstler, um ihn zu Favoriten hinzuzufügen",
|
||||||
|
"noFavoriteAlbums": "Keine Lieblingsalben",
|
||||||
|
"tapHeartAlbum": "Tippe auf das Herz bei einem Album, um es zu Favoriten hinzuzufügen",
|
||||||
|
"noFavoritePlaylists": "Keine Lieblings-Wiedergabelisten",
|
||||||
|
"tapHeartPlaylist": "Tippe auf das Herz bei einer Wiedergabeliste, um sie zu Favoriten hinzuzufügen",
|
||||||
|
"noFavoriteTracks": "Keine Lieblingssongs",
|
||||||
|
"longPressTrackHint": "Halte einen Song gedrückt und tippe auf das Herz, um ihn zu Favoriten hinzuzufügen",
|
||||||
|
"loadSeries": "Serien laden",
|
||||||
|
|
||||||
|
"notConnected": "Nicht mit Music Assistant verbunden",
|
||||||
|
"notConnectedTitle": "Nicht verbunden",
|
||||||
|
"connectHint": "Verbinde dich mit deinem Music Assistant Server, um Musik zu hören",
|
||||||
|
"configureServer": "Server konfigurieren",
|
||||||
|
|
||||||
|
"noArtistsFound": "Keine Künstler gefunden",
|
||||||
|
"noTracksFound": "Keine Songs gefunden",
|
||||||
|
"noPlaylistsFound": "Keine Wiedergabelisten gefunden",
|
||||||
|
"queueIsEmpty": "Warteschlange ist leer",
|
||||||
|
"noResultsFound": "Keine Ergebnisse gefunden",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
|
||||||
|
"debugConsole": "Debug-Konsole",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"noLogsToCopy": "Keine Protokolle zum Kopieren",
|
||||||
|
"noDebugLogsYet": "Noch keine Debug-Protokolle. Versuche Authentifizierung zu erkennen.",
|
||||||
|
"showDebug": "Debug anzeigen",
|
||||||
|
"hideDebug": "Debug ausblenden",
|
||||||
|
|
||||||
|
"chapters": "Kapitel",
|
||||||
|
"noChapters": "Keine Kapitel",
|
||||||
|
"noChapterInfo": "Dieses Hörbuch hat keine Kapitelinformationen",
|
||||||
|
|
||||||
|
"errorSeeking": "Fehler beim Spulen: {error}",
|
||||||
|
"error": "Fehler: {error}",
|
||||||
|
|
||||||
|
"home": "Start",
|
||||||
|
"library": "Bibliothek",
|
||||||
|
|
||||||
|
"homeScreen": "Startbildschirm",
|
||||||
|
"metadataApis": "Metadaten-APIs",
|
||||||
|
"theme": "Design",
|
||||||
|
"materialYou": "Material You",
|
||||||
|
"adaptiveTheme": "Adaptives Design",
|
||||||
|
|
||||||
|
"language": "Sprache",
|
||||||
|
"english": "Englisch",
|
||||||
|
"german": "Deutsch",
|
||||||
|
"spanish": "Spanisch",
|
||||||
|
|
||||||
|
"noTracksInPlaylist": "Keine Songs in Wiedergabeliste",
|
||||||
|
"sortAlphabetically": "Alphabetisch sortieren",
|
||||||
|
"sortByYear": "Nach Jahr sortieren",
|
||||||
|
"sortBySeriesOrder": "Nach Serienreihenfolge sortieren",
|
||||||
|
"listView": "Listenansicht",
|
||||||
|
"gridView": "Rasteransicht",
|
||||||
|
"noBooksInSeries": "Keine Bücher in dieser Serie gefunden",
|
||||||
|
"artist": "Künstler",
|
||||||
|
"showRecentlyPlayedAlbums": "Kürzlich gespielte Alben anzeigen",
|
||||||
|
"showRandomArtists": "Zufällige Künstler zum Entdecken anzeigen",
|
||||||
|
"showRandomAlbums": "Zufällige Alben zum Entdecken anzeigen",
|
||||||
|
"showAudiobooksInProgress": "Hörbücher in Bearbeitung anzeigen",
|
||||||
|
"showRandomAudiobooks": "Zufällige Hörbücher zum Entdecken anzeigen",
|
||||||
|
"showRandomSeries": "Zufällige Hörbuchserien zum Entdecken anzeigen",
|
||||||
|
"showFavoriteAlbums": "Lieblingsalben anzeigen",
|
||||||
|
"showFavoriteArtists": "Lieblingskünstler anzeigen",
|
||||||
|
"showFavoriteTracks": "Lieblingssongs anzeigen",
|
||||||
|
"extractColorsFromArtwork": "Farben aus Album- und Künstlercover extrahieren",
|
||||||
|
"chooseHomeScreenRows": "Wähle welche Zeilen auf dem Startbildschirm angezeigt werden",
|
||||||
|
"addedToFavorites": "Zu Favoriten hinzugefügt",
|
||||||
|
"removedFromFavorites": "Aus Favoriten entfernt",
|
||||||
|
"unknown": "Unbekannt",
|
||||||
|
"noUpcomingTracks": "Keine weiteren Songs",
|
||||||
|
"showAll": "Alle anzeigen",
|
||||||
|
"showFavoritesOnly": "Nur Favoriten anzeigen",
|
||||||
|
"changeView": "Ansicht ändern",
|
||||||
|
"authors": "Autoren",
|
||||||
|
"series": "Serien",
|
||||||
|
"shows": "Sendungen",
|
||||||
|
"podcasts": "Podcasts",
|
||||||
|
"podcastSupportComingSoon": "Podcast-Unterstützung kommt bald",
|
||||||
|
"playlist": "Wiedergabeliste",
|
||||||
|
"connectionError": "Verbindungsfehler",
|
||||||
|
"twoColumnGrid": "2-Spalten-Raster",
|
||||||
|
"threeColumnGrid": "3-Spalten-Raster",
|
||||||
|
"fromProviders": "Von Anbietern",
|
||||||
|
|
||||||
|
"resume": "Fortsetzen",
|
||||||
|
"about": "Über",
|
||||||
|
"inProgress": "In Bearbeitung",
|
||||||
|
"narratedBy": "Gesprochen von {narrators}",
|
||||||
|
"unknownNarrator": "Unbekannter Sprecher",
|
||||||
|
"unknownAuthor": "Unbekannter Autor",
|
||||||
|
"loadingChapters": "Lade Kapitel...",
|
||||||
|
"noChapterInfoAvailable": "Keine Kapitelinformationen verfügbar",
|
||||||
|
"percentComplete": "{percent}% abgeschlossen",
|
||||||
|
"theAudioDbApiKey": "TheAudioDB API-Schlüssel",
|
||||||
|
"theAudioDbApiKeyHint": "\"2\" für kostenlosen Zugang oder Premium-Schlüssel",
|
||||||
|
"audiobookLibraries": "Hörbuch-Bibliotheken",
|
||||||
|
"chooseAudiobookLibraries": "Wähle welche Audiobookshelf-Bibliotheken einbezogen werden",
|
||||||
|
"unknownLibrary": "Unbekannte Bibliothek",
|
||||||
|
"noSeriesFound": "Keine Serien gefunden",
|
||||||
|
"ensembleBugReport": "Ensemble Fehlerbericht",
|
||||||
|
"byOwner": "Von {owner}",
|
||||||
|
"noSeriesAvailable": "Keine Serien in deiner Hörbuch-Bibliothek verfügbar.\nZiehe zum Aktualisieren.",
|
||||||
|
"pullToLoadSeries": "Ziehe nach unten um Serien\nvon Music Assistant zu laden",
|
||||||
|
|
||||||
|
"search": "Suche",
|
||||||
|
"metadataApisDescription": "Künstlerbilder werden automatisch von Deezer abgerufen. Füge unten API-Schlüssel für Künstlerbiografien und Albenbeschreibungen hinzu.",
|
||||||
|
"lastFmApiKey": "Last.fm API-Schlüssel",
|
||||||
|
"lastFmApiKeyHint": "Kostenlosen Schlüssel auf last.fm/api holen",
|
||||||
|
"swipeToSwitchDevice": "Wischen um Gerät zu wechseln",
|
||||||
|
"chapterNumber": "Kapitel {number}",
|
||||||
|
"pcmAudio": "PCM-Audio",
|
||||||
|
"playOn": "Abspielen auf...",
|
||||||
|
"addAlbumToQueueOn": "Album zur Warteschlange hinzufügen auf...",
|
||||||
|
"addToQueueOn": "Zur Warteschlange hinzufügen auf...",
|
||||||
|
"startRadioOn": "{name} Radio starten auf...",
|
||||||
|
"book": "Buch",
|
||||||
|
"audiobookSingular": "Hörbuch",
|
||||||
|
"trackCount": "{count} {count, plural, =1{Song} other{Songs}}",
|
||||||
|
"materialYouDescription": "Systemfarben verwenden (Android 12+)",
|
||||||
|
"accentColor": "Akzentfarbe",
|
||||||
|
"players": "Geräte",
|
||||||
|
"preferLocalPlayer": "Lokales Gerät bevorzugen",
|
||||||
|
"preferLocalPlayerDescription": "Dieses Gerät immer zuerst auswählen, wenn verfügbar",
|
||||||
|
"smartSortPlayers": "Intelligente Sortierung",
|
||||||
|
"smartSortPlayersDescription": "Nach Status sortieren (spielt, an, aus) statt alphabetisch",
|
||||||
|
"playerStateUnavailable": "Nicht verfügbar",
|
||||||
|
"playerStateOff": "Aus",
|
||||||
|
"playerStateIdle": "Inaktiv",
|
||||||
|
"playerSelected": "Ausgewählt",
|
||||||
|
"actionQueuedForSync": "Wird synchronisiert wenn online",
|
||||||
|
"pendingOfflineActions": "{count} ausstehende Synchronisierung",
|
||||||
|
"hintsAndTips": "Tipps & Hinweise",
|
||||||
|
"showHints": "Hinweise anzeigen",
|
||||||
|
"showHintsDescription": "Hilfreiche Tipps zum Entdecken von Funktionen anzeigen",
|
||||||
|
"pullToSelectPlayers": "Ziehen um Geräte auszuwählen",
|
||||||
|
"holdToSync": "Gerät gedrückt halten zum Synchronisieren"
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "es",
|
||||||
|
|
||||||
|
"appTitle": "Ensemble",
|
||||||
|
"connecting": "Conectando...",
|
||||||
|
"connected": "Conectado",
|
||||||
|
"disconnected": "Desconectado",
|
||||||
|
"serverAddress": "Dirección del servidor",
|
||||||
|
"serverAddressHint": "ej., music.example.com o 192.168.1.100",
|
||||||
|
"yourName": "Tu nombre",
|
||||||
|
"yourFirstName": "Tu nombre de pila",
|
||||||
|
"portOptional": "Puerto (Opcional)",
|
||||||
|
"portHint": "ej., 8095 o dejar en blanco",
|
||||||
|
"portDescription": "Dejar en blanco para proxy inverso o puertos estándar. Introduce 8095 para conexión directa.",
|
||||||
|
"username": "Usuario",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"detectAndConnect": "Detectar y conectar",
|
||||||
|
"connect": "Conectar",
|
||||||
|
"disconnect": "Desconectar",
|
||||||
|
"authServerUrlOptional": "URL del servidor de autenticación (Opcional)",
|
||||||
|
"authServerUrlHint": "ej., auth.example.com (si es diferente del servidor)",
|
||||||
|
"authServerUrlDescription": "Dejar vacío si la autenticación está en el mismo servidor",
|
||||||
|
"detectedAuthType": "Detectado: {authType}",
|
||||||
|
"@detectedAuthType": {
|
||||||
|
"placeholders": {
|
||||||
|
"authType": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serverRequiresAuth": "Este servidor requiere autenticación",
|
||||||
|
"tailscaleVpnConnection": "Conexión VPN Tailscale",
|
||||||
|
"unencryptedConnection": "Conexión sin cifrar",
|
||||||
|
"usingHttpOverTailscale": "Usando HTTP sobre Tailscale (túnel cifrado)",
|
||||||
|
"httpsFailedUsingHttp": "HTTPS falló, usando HTTP como alternativa",
|
||||||
|
"httpNotEncrypted": "Conexión HTTP - los datos no están cifrados",
|
||||||
|
"pleaseEnterServerAddress": "Por favor, introduce la dirección de tu servidor Music Assistant",
|
||||||
|
"pleaseEnterName": "Por favor, introduce tu nombre",
|
||||||
|
"pleaseEnterValidPort": "Por favor, introduce un número de puerto válido (1-65535)",
|
||||||
|
"pleaseEnterCredentials": "Por favor, introduce usuario y contraseña",
|
||||||
|
"authFailed": "Autenticación fallida. Por favor, verifica tus credenciales.",
|
||||||
|
"maLoginFailed": "Inicio de sesión en Music Assistant fallido. Por favor, verifica tus credenciales.",
|
||||||
|
"connectionFailed": "No se pudo conectar al servidor. Por favor, verifica la dirección e inténtalo de nuevo.",
|
||||||
|
"detectingAuth": "Detectando autenticación...",
|
||||||
|
"cannotDetermineAuth": "No se pueden determinar los requisitos de autenticación. Por favor, verifica la URL del servidor.",
|
||||||
|
"noAuthentication": "Sin autenticación",
|
||||||
|
"httpBasicAuth": "Autenticación HTTP Basic",
|
||||||
|
"authelia": "Authelia",
|
||||||
|
"musicAssistantLogin": "Inicio de sesión de Music Assistant",
|
||||||
|
"pressBackToMinimize": "Presiona atrás de nuevo para minimizar",
|
||||||
|
"recentlyPlayed": "Reproducido recientemente",
|
||||||
|
"discoverArtists": "Descubrir artistas",
|
||||||
|
"discoverAlbums": "Descubrir álbumes",
|
||||||
|
"continueListening": "Continuar escuchando",
|
||||||
|
"discoverAudiobooks": "Descubrir audiolibros",
|
||||||
|
"discoverSeries": "Descubrir series",
|
||||||
|
"favoriteAlbums": "Álbumes favoritos",
|
||||||
|
"favoriteArtists": "Artistas favoritos",
|
||||||
|
"favoriteTracks": "Canciones favoritas",
|
||||||
|
"searchMusic": "Buscar música...",
|
||||||
|
"searchForContent": "Buscar artistas, álbumes o canciones",
|
||||||
|
"retry": "Reintentar",
|
||||||
|
"all": "Todo",
|
||||||
|
"artists": "Artistas",
|
||||||
|
"albums": "Álbumes",
|
||||||
|
"tracks": "Canciones",
|
||||||
|
"playlists": "Listas de reproducción",
|
||||||
|
"audiobooks": "Audiolibros",
|
||||||
|
"searchFailed": "Búsqueda fallida. Por favor, verifica tu conexión.",
|
||||||
|
"queue": "Cola",
|
||||||
|
"playerQueue": "Cola de {playerName}",
|
||||||
|
"@playerQueue": {
|
||||||
|
"placeholders": {
|
||||||
|
"playerName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noPlayerSelected": "Ningún reproductor seleccionado",
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"removedItem": "Eliminado {itemName}",
|
||||||
|
"@removedItem": {
|
||||||
|
"placeholders": {
|
||||||
|
"itemName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": "Ajustes",
|
||||||
|
"debugLogs": "Registros de depuración",
|
||||||
|
"themeMode": "Modo de tema",
|
||||||
|
"light": "Claro",
|
||||||
|
"dark": "Oscuro",
|
||||||
|
"system": "Sistema",
|
||||||
|
"pullToRefresh": "Desliza hacia abajo para actualizar la biblioteca y aplicar los cambios",
|
||||||
|
"viewDebugLogs": "Ver registros de depuración",
|
||||||
|
"viewAllPlayers": "Ver todos los reproductores",
|
||||||
|
"copyLogs": "Copiar registros",
|
||||||
|
"clearLogs": "Borrar registros",
|
||||||
|
"copyList": "Copiar lista",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"allPlayersCount": "Todos los reproductores ({count})",
|
||||||
|
"@allPlayersCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errorLoadingPlayers": "Error al cargar reproductores: {error}",
|
||||||
|
"@errorLoadingPlayers": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logsCopied": "Registros copiados al portapapeles",
|
||||||
|
"logsCleared": "Registros borrados",
|
||||||
|
"playerListCopied": "Lista de reproductores copiada al portapapeles",
|
||||||
|
"noLogsYet": "Aún no hay registros",
|
||||||
|
"infoPlus": "Info+",
|
||||||
|
"warnings": "Advertencias",
|
||||||
|
"errors": "Errores",
|
||||||
|
"showingEntries": "Mostrando {filtered} de {total} entradas",
|
||||||
|
"@showingEntries": {
|
||||||
|
"placeholders": {
|
||||||
|
"filtered": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thisDevice": "Este dispositivo",
|
||||||
|
"ghostPlayer": "Reproductor fantasma (duplicado)",
|
||||||
|
"unavailableCorrupt": "No disponible/Corrupto",
|
||||||
|
"playerId": "ID: {id}",
|
||||||
|
"@playerId": {
|
||||||
|
"placeholders": {
|
||||||
|
"id": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playerInfo": "Disponible: {available} | Proveedor: {provider}",
|
||||||
|
"@playerInfo": {
|
||||||
|
"placeholders": {
|
||||||
|
"available": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shareBugReport": "Compartir informe de errores",
|
||||||
|
"moreOptions": "Más opciones",
|
||||||
|
"failedToUpdateFavorite": "Error al actualizar favorito: {error}",
|
||||||
|
"@failedToUpdateFavorite": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noPlayersAvailable": "No hay reproductores disponibles",
|
||||||
|
"albumAddedToQueue": "Álbum añadido a la cola",
|
||||||
|
"tracksAddedToQueue": "Canciones añadidas a la cola",
|
||||||
|
"play": "Reproducir",
|
||||||
|
"noPlayersFound": "No se encontraron reproductores",
|
||||||
|
"startingRadio": "Iniciando radio de {name}",
|
||||||
|
"@startingRadio": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedToStartRadio": "Error al iniciar radio: {error}",
|
||||||
|
"@failedToStartRadio": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"startingRadioOnPlayer": "Iniciando radio de {name} en {playerName}",
|
||||||
|
"@startingRadioOnPlayer": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"playerName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"addedRadioToQueue": "Radio de {name} añadida a la cola",
|
||||||
|
"@addedRadioToQueue": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedToAddToQueue": "Error al añadir a la cola: {error}",
|
||||||
|
"@failedToAddToQueue": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"radio": "Radio",
|
||||||
|
"inLibrary": "En la biblioteca",
|
||||||
|
"noAlbumsFound": "No se encontraron álbumes",
|
||||||
|
"playing": "Reproduciendo {name}",
|
||||||
|
"@playing": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playingTrack": "Reproduciendo canción",
|
||||||
|
"nowPlaying": "Reproduciendo ahora",
|
||||||
|
"markedAsFinished": "{name} marcado como terminado",
|
||||||
|
"@markedAsFinished": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedToMarkFinished": "Error al marcar como terminado: {error}",
|
||||||
|
"@failedToMarkFinished": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markedAsUnplayed": "{name} marcado como no reproducido",
|
||||||
|
"@markedAsUnplayed": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedToMarkUnplayed": "Error al marcar como no reproducido: {error}",
|
||||||
|
"@failedToMarkUnplayed": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"failedToPlay": "Error al reproducir: {error}",
|
||||||
|
"@failedToPlay": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"markAsFinished": "Marcar como terminado",
|
||||||
|
"markAsUnplayed": "Marcar como no reproducido",
|
||||||
|
"byAuthor": "Por {author}",
|
||||||
|
"@byAuthor": {
|
||||||
|
"placeholders": {
|
||||||
|
"author": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audiobookCount": "{count} audiolibro(s)",
|
||||||
|
"@audiobookCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"bookCount": "{count} libro(s)",
|
||||||
|
"@bookCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"books": "Libros",
|
||||||
|
"noFavoriteAudiobooks": "No hay audiolibros favoritos",
|
||||||
|
"tapHeartAudiobook": "Toca el corazón en un audiolibro para añadirlo a favoritos",
|
||||||
|
"noAudiobooks": "No hay audiolibros",
|
||||||
|
"addAudiobooksHint": "Añade audiolibros a tu biblioteca para verlos aquí",
|
||||||
|
"noFavoriteArtists": "No hay artistas favoritos",
|
||||||
|
"tapHeartArtist": "Toca el corazón en un artista para añadirlo a favoritos",
|
||||||
|
"noFavoriteAlbums": "No hay álbumes favoritos",
|
||||||
|
"tapHeartAlbum": "Toca el corazón en un álbum para añadirlo a favoritos",
|
||||||
|
"noFavoritePlaylists": "No hay listas de reproducción favoritas",
|
||||||
|
"tapHeartPlaylist": "Toca el corazón en una lista para añadirla a favoritos",
|
||||||
|
"noFavoriteTracks": "No hay canciones favoritas",
|
||||||
|
"longPressTrackHint": "Mantén presionada una canción y toca el corazón para añadirla a favoritos",
|
||||||
|
"loadSeries": "Cargar series",
|
||||||
|
"notConnected": "No conectado a Music Assistant",
|
||||||
|
"notConnectedTitle": "No conectado",
|
||||||
|
"connectHint": "Conéctate a tu servidor Music Assistant para empezar a escuchar",
|
||||||
|
"configureServer": "Configurar servidor",
|
||||||
|
"noArtistsFound": "No se encontraron artistas",
|
||||||
|
"noTracksFound": "No se encontraron canciones",
|
||||||
|
"noPlaylistsFound": "No se encontraron listas de reproducción",
|
||||||
|
"queueIsEmpty": "La cola está vacía",
|
||||||
|
"noResultsFound": "No se encontraron resultados",
|
||||||
|
"refresh": "Actualizar",
|
||||||
|
"debugConsole": "Consola de depuración",
|
||||||
|
"copy": "Copiar",
|
||||||
|
"clear": "Borrar",
|
||||||
|
"noLogsToCopy": "No hay registros para copiar",
|
||||||
|
"noDebugLogsYet": "Aún no hay registros de depuración. Intenta detectar autenticación.",
|
||||||
|
"showDebug": "Mostrar depuración",
|
||||||
|
"hideDebug": "Ocultar depuración",
|
||||||
|
"chapters": "Capítulos",
|
||||||
|
"noChapters": "Sin capítulos",
|
||||||
|
"noChapterInfo": "Este audiolibro no tiene información de capítulos",
|
||||||
|
"errorSeeking": "Error al buscar: {error}",
|
||||||
|
"@errorSeeking": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": "Error: {error}",
|
||||||
|
"@error": {
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home": "Inicio",
|
||||||
|
"library": "Biblioteca",
|
||||||
|
"homeScreen": "Pantalla de inicio",
|
||||||
|
"metadataApis": "APIs de metadatos",
|
||||||
|
"theme": "Tema",
|
||||||
|
"materialYou": "Material You",
|
||||||
|
"adaptiveTheme": "Tema adaptativo",
|
||||||
|
"language": "Idioma",
|
||||||
|
"english": "Inglés",
|
||||||
|
"german": "Alemán",
|
||||||
|
"spanish": "Español",
|
||||||
|
"noTracksInPlaylist": "No hay canciones en la lista",
|
||||||
|
"sortAlphabetically": "Ordenar alfabéticamente",
|
||||||
|
"sortByYear": "Ordenar por año",
|
||||||
|
"sortBySeriesOrder": "Ordenar por orden de serie",
|
||||||
|
"listView": "Vista de lista",
|
||||||
|
"gridView": "Vista de cuadrícula",
|
||||||
|
"noBooksInSeries": "No se encontraron libros en esta serie",
|
||||||
|
"artist": "Artista",
|
||||||
|
"showRecentlyPlayedAlbums": "Mostrar álbumes reproducidos recientemente",
|
||||||
|
"showRandomArtists": "Mostrar artistas aleatorios para descubrir",
|
||||||
|
"showRandomAlbums": "Mostrar álbumes aleatorios para descubrir",
|
||||||
|
"showAudiobooksInProgress": "Mostrar audiolibros en progreso",
|
||||||
|
"showRandomAudiobooks": "Mostrar audiolibros aleatorios para descubrir",
|
||||||
|
"showRandomSeries": "Mostrar series de audiolibros aleatorias para descubrir",
|
||||||
|
"showFavoriteAlbums": "Mostrar una fila de tus álbumes favoritos",
|
||||||
|
"showFavoriteArtists": "Mostrar una fila de tus artistas favoritos",
|
||||||
|
"showFavoriteTracks": "Mostrar una fila de tus canciones favoritas",
|
||||||
|
"extractColorsFromArtwork": "Extraer colores de las portadas de álbumes y artistas",
|
||||||
|
"chooseHomeScreenRows": "Elige qué filas mostrar en la pantalla de inicio",
|
||||||
|
"addedToFavorites": "Añadido a favoritos",
|
||||||
|
"removedFromFavorites": "Eliminado de favoritos",
|
||||||
|
"unknown": "Desconocido",
|
||||||
|
"noUpcomingTracks": "No hay canciones próximas",
|
||||||
|
"showAll": "Mostrar todo",
|
||||||
|
"showFavoritesOnly": "Mostrar solo favoritos",
|
||||||
|
"changeView": "Cambiar vista",
|
||||||
|
"authors": "Autores",
|
||||||
|
"series": "Series",
|
||||||
|
"shows": "Programas",
|
||||||
|
"podcasts": "Podcasts",
|
||||||
|
"podcastSupportComingSoon": "Soporte de podcasts próximamente",
|
||||||
|
"playlist": "Lista de reproducción",
|
||||||
|
"connectionError": "Error de conexión",
|
||||||
|
"twoColumnGrid": "Cuadrícula de 2 columnas",
|
||||||
|
"threeColumnGrid": "Cuadrícula de 3 columnas",
|
||||||
|
"fromProviders": "De proveedores",
|
||||||
|
"resume": "Continuar",
|
||||||
|
"about": "Acerca de",
|
||||||
|
"inProgress": "En progreso",
|
||||||
|
"narratedBy": "Narrado por {narrators}",
|
||||||
|
"@narratedBy": {
|
||||||
|
"placeholders": {
|
||||||
|
"narrators": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unknownNarrator": "Narrador desconocido",
|
||||||
|
"unknownAuthor": "Autor desconocido",
|
||||||
|
"loadingChapters": "Cargando capítulos...",
|
||||||
|
"noChapterInfoAvailable": "No hay información de capítulos disponible",
|
||||||
|
"percentComplete": "{percent}% completado",
|
||||||
|
"@percentComplete": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"theAudioDbApiKey": "Clave API de TheAudioDB",
|
||||||
|
"theAudioDbApiKeyHint": "Usa \"2\" para nivel gratuito o clave premium",
|
||||||
|
"audiobookLibraries": "Bibliotecas de audiolibros",
|
||||||
|
"chooseAudiobookLibraries": "Elige qué bibliotecas de Audiobookshelf incluir",
|
||||||
|
"unknownLibrary": "Biblioteca desconocida",
|
||||||
|
"noSeriesFound": "No se encontraron series",
|
||||||
|
"ensembleBugReport": "Informe de errores de Ensemble",
|
||||||
|
"byOwner": "Por {owner}",
|
||||||
|
"@byOwner": {
|
||||||
|
"placeholders": {
|
||||||
|
"owner": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noSeriesAvailable": "No hay series disponibles en tu biblioteca de audiolibros.\nDesliza para actualizar.",
|
||||||
|
"pullToLoadSeries": "Desliza hacia abajo para cargar series\ndesde Music Assistant",
|
||||||
|
"search": "Buscar",
|
||||||
|
"metadataApisDescription": "Las imágenes de artistas se obtienen automáticamente de Deezer. Añade claves API a continuación para biografías de artistas y descripciones de álbumes.",
|
||||||
|
"lastFmApiKey": "Clave API de Last.fm",
|
||||||
|
"lastFmApiKeyHint": "Obtén una clave gratuita en last.fm/api",
|
||||||
|
"swipeToSwitchDevice": "Desliza para cambiar dispositivo",
|
||||||
|
"chapterNumber": "Capítulo {number}",
|
||||||
|
"@chapterNumber": {
|
||||||
|
"placeholders": {
|
||||||
|
"number": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pcmAudio": "Audio PCM",
|
||||||
|
"playOn": "Reproducir en...",
|
||||||
|
"addAlbumToQueueOn": "Añadir álbum a la cola en...",
|
||||||
|
"addToQueueOn": "Añadir a la cola en...",
|
||||||
|
"startRadioOn": "Iniciar radio de {name} en...",
|
||||||
|
"@startRadioOn": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"book": "libro",
|
||||||
|
"audiobookSingular": "audiolibro",
|
||||||
|
"trackCount": "{count} {count, plural, =1{canción} other{canciones}}",
|
||||||
|
"@trackCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"materialYouDescription": "Usar colores del sistema (Android 12+)",
|
||||||
|
"accentColor": "Color de acento",
|
||||||
|
"players": "Dispositivos",
|
||||||
|
"preferLocalPlayer": "Preferir dispositivo local",
|
||||||
|
"preferLocalPlayerDescription": "Seleccionar siempre este dispositivo primero cuando esté disponible",
|
||||||
|
"smartSortPlayers": "Orden inteligente",
|
||||||
|
"smartSortPlayersDescription": "Ordenar por estado (reproduciendo, encendido, apagado) en lugar de alfabéticamente",
|
||||||
|
"playerStateUnavailable": "No disponible",
|
||||||
|
"playerStateOff": "Apagado",
|
||||||
|
"playerStateIdle": "Inactivo",
|
||||||
|
"playerSelected": "Seleccionado",
|
||||||
|
"actionQueuedForSync": "Se sincronizará cuando esté en línea",
|
||||||
|
"pendingOfflineActions": "{count} sincronización pendiente",
|
||||||
|
"hintsAndTips": "Consejos y Sugerencias",
|
||||||
|
"showHints": "Mostrar Sugerencias",
|
||||||
|
"showHintsDescription": "Mostrar consejos útiles para descubrir funciones",
|
||||||
|
"pullToSelectPlayers": "Desliza para seleccionar dispositivos",
|
||||||
|
"holdToSync": "Mantén presionado un dispositivo para sincronizar"
|
||||||
|
}
|
||||||
@@ -3,11 +3,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'l10n/app_localizations.dart';
|
||||||
|
import 'providers/locale_provider.dart';
|
||||||
import 'providers/music_assistant_provider.dart';
|
import 'providers/music_assistant_provider.dart';
|
||||||
import 'providers/navigation_provider.dart';
|
import 'providers/navigation_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'services/settings_service.dart';
|
import 'services/settings_service.dart';
|
||||||
|
import 'services/database_service.dart';
|
||||||
|
import 'services/profile_service.dart';
|
||||||
|
import 'services/sync_service.dart';
|
||||||
import 'services/audio/massiv_audio_handler.dart';
|
import 'services/audio/massiv_audio_handler.dart';
|
||||||
import 'services/auth/auth_manager.dart';
|
import 'services/auth/auth_manager.dart';
|
||||||
import 'services/debug_logger.dart';
|
import 'services/debug_logger.dart';
|
||||||
@@ -27,6 +33,17 @@ final _logger = DebugLogger();
|
|||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize local database
|
||||||
|
await DatabaseService.instance.initialize();
|
||||||
|
_logger.log('💾 Database initialized');
|
||||||
|
|
||||||
|
// Migrate existing ownerName to profile (one-time for existing users)
|
||||||
|
await ProfileService.instance.migrateFromOwnerName();
|
||||||
|
|
||||||
|
// Load library from cache for instant startup
|
||||||
|
await SyncService.instance.loadFromCache();
|
||||||
|
_logger.log('📦 Library cache loaded');
|
||||||
|
|
||||||
// Create auth manager for streaming headers
|
// Create auth manager for streaming headers
|
||||||
final authManager = AuthManager();
|
final authManager = AuthManager();
|
||||||
|
|
||||||
@@ -73,6 +90,7 @@ class MusicAssistantApp extends StatefulWidget {
|
|||||||
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindingObserver {
|
||||||
late MusicAssistantProvider _musicProvider;
|
late MusicAssistantProvider _musicProvider;
|
||||||
late ThemeProvider _themeProvider;
|
late ThemeProvider _themeProvider;
|
||||||
|
late LocaleProvider _localeProvider;
|
||||||
final _hardwareVolumeService = HardwareVolumeService();
|
final _hardwareVolumeService = HardwareVolumeService();
|
||||||
StreamSubscription? _volumeUpSub;
|
StreamSubscription? _volumeUpSub;
|
||||||
StreamSubscription? _volumeDownSub;
|
StreamSubscription? _volumeDownSub;
|
||||||
@@ -87,6 +105,7 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
super.initState();
|
super.initState();
|
||||||
_musicProvider = MusicAssistantProvider();
|
_musicProvider = MusicAssistantProvider();
|
||||||
_themeProvider = ThemeProvider();
|
_themeProvider = ThemeProvider();
|
||||||
|
_localeProvider = LocaleProvider();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_initHardwareVolumeControl();
|
_initHardwareVolumeControl();
|
||||||
// Listen to player selection changes to toggle volume interception
|
// Listen to player selection changes to toggle volume interception
|
||||||
@@ -153,11 +172,20 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
@override
|
@override
|
||||||
Future<bool> didPopRoute() async {
|
Future<bool> didPopRoute() async {
|
||||||
// Intercept back button at app level - runs BEFORE Navigator processes it
|
// Intercept back button at app level - runs BEFORE Navigator processes it
|
||||||
// If player is expanded, collapse it and consume the back gesture
|
// Priority order: device list > expanded player > normal navigation
|
||||||
|
|
||||||
|
// Device list has highest priority - dismiss it first
|
||||||
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||||
|
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||||
|
return true; // We handled it, don't let Navigator process it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check expanded player
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
return true; // We handled it, don't let Navigator process it
|
return true; // We handled it, don't let Navigator process it
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let Navigator handle the back gesture normally
|
// Let Navigator handle the back gesture normally
|
||||||
return super.didPopRoute();
|
return super.didPopRoute();
|
||||||
}
|
}
|
||||||
@@ -183,9 +211,10 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: _musicProvider),
|
ChangeNotifierProvider.value(value: _musicProvider),
|
||||||
ChangeNotifierProvider.value(value: _themeProvider),
|
ChangeNotifierProvider.value(value: _themeProvider),
|
||||||
|
ChangeNotifierProvider.value(value: _localeProvider),
|
||||||
],
|
],
|
||||||
child: Consumer<ThemeProvider>(
|
child: Consumer2<ThemeProvider, LocaleProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, localeProvider, _) {
|
||||||
return FutureBuilder<(ColorScheme, ColorScheme)?>(
|
return FutureBuilder<(ColorScheme, ColorScheme)?>(
|
||||||
future: themeProvider.useMaterialTheme
|
future: themeProvider.useMaterialTheme
|
||||||
? SystemThemeHelper.getSystemColorSchemes()
|
? SystemThemeHelper.getSystemColorSchemes()
|
||||||
@@ -201,9 +230,9 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
lightColorScheme = light;
|
lightColorScheme = light;
|
||||||
darkColorScheme = dark;
|
darkColorScheme = dark;
|
||||||
} else {
|
} else {
|
||||||
// Use brand color schemes
|
// Use custom color from theme provider
|
||||||
lightColorScheme = brandLightColorScheme;
|
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||||
darkColorScheme = brandDarkColorScheme;
|
darkColorScheme = generateDarkColorScheme(themeProvider.customColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SystemUIWrapper(
|
return SystemUIWrapper(
|
||||||
@@ -214,6 +243,15 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
|||||||
navigatorKey: navigationProvider.navigatorKey,
|
navigatorKey: navigationProvider.navigatorKey,
|
||||||
title: 'Ensemble',
|
title: 'Ensemble',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
// Localization
|
||||||
|
localizationsDelegates: const [
|
||||||
|
S.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: S.supportedLocales,
|
||||||
|
locale: localeProvider.locale,
|
||||||
themeMode: themeProvider.themeMode,
|
themeMode: themeProvider.themeMode,
|
||||||
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
theme: AppTheme.lightTheme(colorScheme: lightColorScheme),
|
||||||
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
darkTheme: AppTheme.darkTheme(colorScheme: darkColorScheme),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ enum MediaType {
|
|||||||
track,
|
track,
|
||||||
playlist,
|
playlist,
|
||||||
radio,
|
radio,
|
||||||
|
audiobook,
|
||||||
|
chapter,
|
||||||
|
podcast,
|
||||||
|
podcastEpisode,
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProviderMapping {
|
class ProviderMapping {
|
||||||
@@ -106,7 +110,8 @@ class MediaItem {
|
|||||||
'media_type': mediaType.name,
|
'media_type': mediaType.name,
|
||||||
if (sortName != null) 'sort_name': sortName,
|
if (sortName != null) 'sort_name': sortName,
|
||||||
if (uri != null) 'uri': uri,
|
if (uri != null) 'uri': uri,
|
||||||
if (providerMappings != null) 'provider_mappings': providerMappings,
|
if (providerMappings != null)
|
||||||
|
'provider_mappings': providerMappings!.map((m) => m.toJson()).toList(),
|
||||||
if (metadata != null) 'metadata': metadata,
|
if (metadata != null) 'metadata': metadata,
|
||||||
if (favorite != null) 'favorite': favorite,
|
if (favorite != null) 'favorite': favorite,
|
||||||
if (position != null) 'position': position,
|
if (position != null) 'position': position,
|
||||||
@@ -140,11 +145,15 @@ class Artist extends MediaItem {
|
|||||||
favorite: item.favorite,
|
favorite: item.favorite,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => super.toJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
class Album extends MediaItem {
|
class Album extends MediaItem {
|
||||||
final List<Artist>? artists;
|
final List<Artist>? artists;
|
||||||
final String? albumType;
|
final String? albumType;
|
||||||
|
final int? year;
|
||||||
|
|
||||||
Album({
|
Album({
|
||||||
required super.itemId,
|
required super.itemId,
|
||||||
@@ -152,6 +161,7 @@ class Album extends MediaItem {
|
|||||||
required super.name,
|
required super.name,
|
||||||
this.artists,
|
this.artists,
|
||||||
this.albumType,
|
this.albumType,
|
||||||
|
this.year,
|
||||||
super.sortName,
|
super.sortName,
|
||||||
super.uri,
|
super.uri,
|
||||||
super.providerMappings,
|
super.providerMappings,
|
||||||
@@ -161,6 +171,15 @@ class Album extends MediaItem {
|
|||||||
|
|
||||||
factory Album.fromJson(Map<String, dynamic> json) {
|
factory Album.fromJson(Map<String, dynamic> json) {
|
||||||
final item = MediaItem.fromJson(json);
|
final item = MediaItem.fromJson(json);
|
||||||
|
// Parse year - can be int or null
|
||||||
|
int? year;
|
||||||
|
final yearValue = json['year'];
|
||||||
|
if (yearValue is int) {
|
||||||
|
year = yearValue;
|
||||||
|
} else if (yearValue is String) {
|
||||||
|
year = int.tryParse(yearValue);
|
||||||
|
}
|
||||||
|
|
||||||
return Album(
|
return Album(
|
||||||
itemId: item.itemId,
|
itemId: item.itemId,
|
||||||
provider: item.provider,
|
provider: item.provider,
|
||||||
@@ -169,6 +188,7 @@ class Album extends MediaItem {
|
|||||||
?.map((e) => Artist.fromJson(e as Map<String, dynamic>))
|
?.map((e) => Artist.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
albumType: json['album_type'] as String?,
|
albumType: json['album_type'] as String?,
|
||||||
|
year: year,
|
||||||
sortName: item.sortName,
|
sortName: item.sortName,
|
||||||
uri: item.uri,
|
uri: item.uri,
|
||||||
providerMappings: item.providerMappings,
|
providerMappings: item.providerMappings,
|
||||||
@@ -179,6 +199,28 @@ class Album extends MediaItem {
|
|||||||
|
|
||||||
String get artistsString =>
|
String get artistsString =>
|
||||||
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
||||||
|
|
||||||
|
/// Get album name with year appended (e.g., "Album Name (2023)")
|
||||||
|
String get nameWithYear {
|
||||||
|
return year != null ? '$name ($year)' : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this album is in the user's library
|
||||||
|
bool get inLibrary {
|
||||||
|
if (provider == 'library') return true;
|
||||||
|
return providerMappings?.any((m) => m.providerInstance == 'library') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = super.toJson();
|
||||||
|
if (artists != null) {
|
||||||
|
json['artists'] = artists!.map((a) => a.toJson()).toList();
|
||||||
|
}
|
||||||
|
if (albumType != null) json['album_type'] = albumType;
|
||||||
|
if (year != null) json['year'] = year;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Track extends MediaItem {
|
class Track extends MediaItem {
|
||||||
@@ -224,6 +266,16 @@ class Track extends MediaItem {
|
|||||||
|
|
||||||
String get artistsString =>
|
String get artistsString =>
|
||||||
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
artists?.map((a) => a.name).join(', ') ?? 'Unknown Artist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = super.toJson();
|
||||||
|
if (artists != null) {
|
||||||
|
json['artists'] = artists!.map((a) => a.toJson()).toList();
|
||||||
|
}
|
||||||
|
if (album != null) json['album'] = album!.toJson();
|
||||||
|
return json;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Playlist extends MediaItem {
|
class Playlist extends MediaItem {
|
||||||
@@ -262,3 +314,363 @@ class Playlist extends MediaItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Chapter {
|
||||||
|
final int chapterNumber;
|
||||||
|
final int positionMs;
|
||||||
|
final String title;
|
||||||
|
final Duration? duration;
|
||||||
|
|
||||||
|
Chapter({
|
||||||
|
required this.chapterNumber,
|
||||||
|
required this.positionMs,
|
||||||
|
required this.title,
|
||||||
|
this.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Chapter.fromJson(Map<String, dynamic> json) {
|
||||||
|
// MA API returns chapters with format:
|
||||||
|
// {position: 1, name: "Opening Credits", start: 0.0, end: 17.6}
|
||||||
|
// We need to handle both this format and potential others
|
||||||
|
|
||||||
|
// Get chapter number (MA uses 'position' for chapter number)
|
||||||
|
int chapterNum = 0;
|
||||||
|
if (json['position'] is int) {
|
||||||
|
chapterNum = json['position'] as int;
|
||||||
|
} else if (json['chapter_number'] is int) {
|
||||||
|
chapterNum = json['chapter_number'] as int;
|
||||||
|
} else if (json['chapter_id'] is int) {
|
||||||
|
chapterNum = json['chapter_id'] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get position in milliseconds (MA uses 'start' in seconds)
|
||||||
|
int posMs = 0;
|
||||||
|
if (json['start'] != null) {
|
||||||
|
// MA provides start time in seconds (can be double)
|
||||||
|
posMs = ((json['start'] as num) * 1000).toInt();
|
||||||
|
} else if (json['position_ms'] != null) {
|
||||||
|
posMs = json['position_ms'] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get title (MA uses 'name')
|
||||||
|
String title = json['name'] as String? ??
|
||||||
|
json['title'] as String? ??
|
||||||
|
'Chapter $chapterNum';
|
||||||
|
|
||||||
|
// Calculate duration from start/end if available
|
||||||
|
Duration? dur;
|
||||||
|
if (json['start'] != null && json['end'] != null) {
|
||||||
|
final startSec = (json['start'] as num).toDouble();
|
||||||
|
final endSec = (json['end'] as num).toDouble();
|
||||||
|
dur = Duration(milliseconds: ((endSec - startSec) * 1000).toInt());
|
||||||
|
} else if (json['duration'] != null) {
|
||||||
|
dur = Duration(seconds: (json['duration'] as num).toInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Chapter(
|
||||||
|
chapterNumber: chapterNum,
|
||||||
|
positionMs: posMs,
|
||||||
|
title: title,
|
||||||
|
duration: dur,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'chapter_number': chapterNumber,
|
||||||
|
'position_ms': positionMs,
|
||||||
|
'title': title,
|
||||||
|
if (duration != null) 'duration': duration!.inSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Audiobook extends MediaItem {
|
||||||
|
final List<Artist>? authors;
|
||||||
|
final List<Artist>? narrators;
|
||||||
|
final String? publisher;
|
||||||
|
final String? description;
|
||||||
|
final int? year;
|
||||||
|
final List<Chapter>? chapters;
|
||||||
|
final int? resumePositionMs;
|
||||||
|
final bool? fullyPlayed;
|
||||||
|
/// Browse order from API - used as fallback for series sequencing
|
||||||
|
final int? browseOrder;
|
||||||
|
|
||||||
|
Audiobook({
|
||||||
|
required super.itemId,
|
||||||
|
required super.provider,
|
||||||
|
required super.name,
|
||||||
|
this.authors,
|
||||||
|
this.narrators,
|
||||||
|
this.publisher,
|
||||||
|
this.description,
|
||||||
|
this.year,
|
||||||
|
this.chapters,
|
||||||
|
this.resumePositionMs,
|
||||||
|
this.fullyPlayed,
|
||||||
|
this.browseOrder,
|
||||||
|
super.sortName,
|
||||||
|
super.uri,
|
||||||
|
super.providerMappings,
|
||||||
|
super.metadata,
|
||||||
|
super.favorite,
|
||||||
|
super.duration,
|
||||||
|
}) : super(mediaType: MediaType.audiobook);
|
||||||
|
|
||||||
|
factory Audiobook.fromJson(Map<String, dynamic> json) {
|
||||||
|
final item = MediaItem.fromJson(json);
|
||||||
|
|
||||||
|
// Parse year
|
||||||
|
int? year;
|
||||||
|
final yearValue = json['year'];
|
||||||
|
if (yearValue is int) {
|
||||||
|
year = yearValue;
|
||||||
|
} else if (yearValue is String) {
|
||||||
|
year = int.tryParse(yearValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to parse authors/narrators that can be either strings or Artist objects
|
||||||
|
List<Artist>? parseArtistList(dynamic data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
if (data is! List) return null;
|
||||||
|
|
||||||
|
return data.map((e) {
|
||||||
|
if (e is String) {
|
||||||
|
// MA returns just the name as a string
|
||||||
|
return Artist(
|
||||||
|
itemId: '',
|
||||||
|
provider: 'library',
|
||||||
|
name: e,
|
||||||
|
);
|
||||||
|
} else if (e is Map<String, dynamic>) {
|
||||||
|
// Full Artist object
|
||||||
|
return Artist.fromJson(e);
|
||||||
|
}
|
||||||
|
return Artist(itemId: '', provider: 'library', name: 'Unknown');
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse chapters - check top level first, then metadata
|
||||||
|
List<Chapter>? chapters;
|
||||||
|
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
dynamic chaptersData = json['chapters'];
|
||||||
|
if (chaptersData == null && metadata != null) {
|
||||||
|
chaptersData = metadata['chapters'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chaptersData is List && chaptersData.isNotEmpty) {
|
||||||
|
chapters = chaptersData
|
||||||
|
.map((e) => Chapter.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse description - check top level first, then metadata
|
||||||
|
String? description = json['description'] as String?;
|
||||||
|
if (description == null && metadata != null) {
|
||||||
|
description = metadata['description'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Audiobook(
|
||||||
|
itemId: item.itemId,
|
||||||
|
provider: item.provider,
|
||||||
|
name: item.name,
|
||||||
|
authors: parseArtistList(json['authors']),
|
||||||
|
narrators: parseArtistList(json['narrators']),
|
||||||
|
publisher: json['publisher'] as String?,
|
||||||
|
description: description,
|
||||||
|
year: year,
|
||||||
|
chapters: chapters,
|
||||||
|
resumePositionMs: json['resume_position_ms'] as int?,
|
||||||
|
fullyPlayed: json['fully_played'] as bool?,
|
||||||
|
browseOrder: json['_browse_order'] as int?,
|
||||||
|
sortName: item.sortName,
|
||||||
|
uri: item.uri,
|
||||||
|
providerMappings: item.providerMappings,
|
||||||
|
metadata: item.metadata,
|
||||||
|
favorite: item.favorite,
|
||||||
|
duration: item.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get authorsString =>
|
||||||
|
authors?.map((a) => a.name).join(', ') ?? 'Unknown Author';
|
||||||
|
|
||||||
|
String get narratorsString =>
|
||||||
|
narrators?.map((n) => n.name).join(', ') ?? 'Unknown Narrator';
|
||||||
|
|
||||||
|
/// Get progress as a percentage (0.0 to 1.0)
|
||||||
|
double get progress {
|
||||||
|
if (fullyPlayed == true) return 1.0;
|
||||||
|
if (resumePositionMs == null || duration == null) return 0.0;
|
||||||
|
final totalMs = duration!.inMilliseconds;
|
||||||
|
if (totalMs == 0) return 0.0;
|
||||||
|
return (resumePositionMs! / totalMs).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get series sequence number from metadata (for sorting within a series)
|
||||||
|
/// Tries various metadata fields commonly used by audiobook providers
|
||||||
|
double? get seriesSequence {
|
||||||
|
// First try the parent's position field (used by MA browse results)
|
||||||
|
if (position != null) return position!.toDouble();
|
||||||
|
|
||||||
|
// Try extracting number from sort_name (e.g., "01 - Book Title" or "Book 1")
|
||||||
|
if (sortName != null && sortName!.isNotEmpty) {
|
||||||
|
// Try to extract leading number
|
||||||
|
final match = RegExp(r'^(\d+)').firstMatch(sortName!);
|
||||||
|
if (match != null) {
|
||||||
|
return double.tryParse(match.group(1)!);
|
||||||
|
}
|
||||||
|
// Try to extract "Book N" pattern
|
||||||
|
final bookMatch = RegExp(r'[Bb]ook\s*(\d+)').firstMatch(sortName!);
|
||||||
|
if (bookMatch != null) {
|
||||||
|
return double.tryParse(bookMatch.group(1)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try extracting from the name itself
|
||||||
|
final nameMatch = RegExp(r'^(\d+)').firstMatch(name);
|
||||||
|
if (nameMatch != null) {
|
||||||
|
return double.tryParse(nameMatch.group(1)!);
|
||||||
|
}
|
||||||
|
final bookNameMatch = RegExp(r'[Bb]ook\s*(\d+)').firstMatch(name);
|
||||||
|
if (bookNameMatch != null) {
|
||||||
|
return double.tryParse(bookNameMatch.group(1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata == null) return null;
|
||||||
|
|
||||||
|
// Try common metadata fields for series sequence
|
||||||
|
final seq = metadata!['sequence'] ??
|
||||||
|
metadata!['series_sequence'] ??
|
||||||
|
metadata!['series_number'] ??
|
||||||
|
metadata!['position'] ??
|
||||||
|
metadata!['sort_order'] ??
|
||||||
|
metadata!['order'];
|
||||||
|
|
||||||
|
if (seq is num) return seq.toDouble();
|
||||||
|
if (seq is String) return double.tryParse(seq);
|
||||||
|
|
||||||
|
// Check if there's a series object with sequence
|
||||||
|
final series = metadata!['series'];
|
||||||
|
if (series is Map) {
|
||||||
|
final seriesSeq = series['sequence'] ?? series['number'] ?? series['order'];
|
||||||
|
if (seriesSeq is num) return seriesSeq.toDouble();
|
||||||
|
if (seriesSeq is String) return double.tryParse(seriesSeq);
|
||||||
|
}
|
||||||
|
if (series is List && series.isNotEmpty) {
|
||||||
|
final first = series.first;
|
||||||
|
if (first is Map) {
|
||||||
|
final seriesSeq = first['sequence'] ?? first['number'] ?? first['order'];
|
||||||
|
if (seriesSeq is num) return seriesSeq.toDouble();
|
||||||
|
if (seriesSeq is String) return double.tryParse(seriesSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use browse order from API (preserves MA/Audiobookshelf order)
|
||||||
|
if (browseOrder != null) return browseOrder!.toDouble();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = super.toJson();
|
||||||
|
if (authors != null) {
|
||||||
|
json['authors'] = authors!.map((a) => a.toJson()).toList();
|
||||||
|
}
|
||||||
|
if (narrators != null) {
|
||||||
|
json['narrators'] = narrators!.map((n) => n.toJson()).toList();
|
||||||
|
}
|
||||||
|
if (publisher != null) json['publisher'] = publisher;
|
||||||
|
if (description != null) json['description'] = description;
|
||||||
|
if (year != null) json['year'] = year;
|
||||||
|
if (chapters != null) {
|
||||||
|
json['chapters'] = chapters!.map((c) => c.toJson()).toList();
|
||||||
|
}
|
||||||
|
if (resumePositionMs != null) json['resume_position_ms'] = resumePositionMs;
|
||||||
|
if (fullyPlayed != null) json['fully_played'] = fullyPlayed;
|
||||||
|
if (browseOrder != null) json['_browse_order'] = browseOrder;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a browseable folder in Music Assistant (providers, series, authors, etc.)
|
||||||
|
class BrowseFolder {
|
||||||
|
final String path;
|
||||||
|
final String name;
|
||||||
|
final String? label;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final bool canExpand;
|
||||||
|
final List<MediaItem>? items;
|
||||||
|
|
||||||
|
BrowseFolder({
|
||||||
|
required this.path,
|
||||||
|
required this.name,
|
||||||
|
this.label,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.canExpand = true,
|
||||||
|
this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BrowseFolder.fromJson(Map<String, dynamic> json) {
|
||||||
|
// MA returns either a BrowseFolder or a MediaItem
|
||||||
|
// BrowseFolder has 'path' and 'name', MediaItem has 'item_id' and 'media_type'
|
||||||
|
return BrowseFolder(
|
||||||
|
path: json['path'] as String? ?? json['uri'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? json['label'] as String? ?? 'Unknown',
|
||||||
|
label: json['label'] as String?,
|
||||||
|
thumbnailUrl: _extractThumbnail(json),
|
||||||
|
canExpand: json['can_expand'] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _extractThumbnail(Map<String, dynamic> json) {
|
||||||
|
// Check for image in metadata
|
||||||
|
final metadata = json['metadata'] as Map<String, dynamic>?;
|
||||||
|
if (metadata != null) {
|
||||||
|
final images = metadata['images'] as List<dynamic>?;
|
||||||
|
if (images != null && images.isNotEmpty) {
|
||||||
|
final first = images.first as Map<String, dynamic>;
|
||||||
|
return first['path'] as String?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for direct thumbnail
|
||||||
|
return json['thumbnail'] as String? ?? json['image'] as String?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents an audiobook series
|
||||||
|
class AudiobookSeries {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final List<Audiobook>? books;
|
||||||
|
final int? bookCount;
|
||||||
|
|
||||||
|
AudiobookSeries({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.books,
|
||||||
|
this.bookCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AudiobookSeries.fromBrowseFolder(BrowseFolder folder) {
|
||||||
|
return AudiobookSeries(
|
||||||
|
id: folder.path,
|
||||||
|
name: folder.name,
|
||||||
|
thumbnailUrl: folder.thumbnailUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AudiobookSeries.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AudiobookSeries(
|
||||||
|
id: json['path'] as String? ?? json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? json['label'] as String? ?? 'Unknown Series',
|
||||||
|
thumbnailUrl: BrowseFolder._extractThumbnail(json),
|
||||||
|
bookCount: json['book_count'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Player {
|
|||||||
final bool? volumeMuted;
|
final bool? volumeMuted;
|
||||||
final double? elapsedTime; // Seconds elapsed in current track
|
final double? elapsedTime; // Seconds elapsed in current track
|
||||||
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
final double? elapsedTimeLastUpdated; // Unix timestamp when elapsed_time was last updated
|
||||||
|
final List<String>? groupMembers; // List of player IDs in sync group (includes self)
|
||||||
|
final String? syncedTo; // Player ID this player is synced to (null if leader or not synced)
|
||||||
|
|
||||||
Player({
|
Player({
|
||||||
required this.playerId,
|
required this.playerId,
|
||||||
@@ -25,6 +27,8 @@ class Player {
|
|||||||
this.volumeMuted,
|
this.volumeMuted,
|
||||||
this.elapsedTime,
|
this.elapsedTime,
|
||||||
this.elapsedTimeLastUpdated,
|
this.elapsedTimeLastUpdated,
|
||||||
|
this.groupMembers,
|
||||||
|
this.syncedTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Create a copy of this Player with some fields replaced
|
/// Create a copy of this Player with some fields replaced
|
||||||
@@ -40,6 +44,8 @@ class Player {
|
|||||||
bool? volumeMuted,
|
bool? volumeMuted,
|
||||||
double? elapsedTime,
|
double? elapsedTime,
|
||||||
double? elapsedTimeLastUpdated,
|
double? elapsedTimeLastUpdated,
|
||||||
|
List<String>? groupMembers,
|
||||||
|
String? syncedTo,
|
||||||
}) {
|
}) {
|
||||||
return Player(
|
return Player(
|
||||||
playerId: playerId ?? this.playerId,
|
playerId: playerId ?? this.playerId,
|
||||||
@@ -53,6 +59,8 @@ class Player {
|
|||||||
volumeMuted: volumeMuted ?? this.volumeMuted,
|
volumeMuted: volumeMuted ?? this.volumeMuted,
|
||||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||||
|
groupMembers: groupMembers ?? this.groupMembers,
|
||||||
|
syncedTo: syncedTo ?? this.syncedTo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +69,12 @@ class Player {
|
|||||||
bool get isMuted => volumeMuted ?? false;
|
bool get isMuted => volumeMuted ?? false;
|
||||||
int get volume => volumeLevel ?? 0;
|
int get volume => volumeLevel ?? 0;
|
||||||
|
|
||||||
|
// Group properties
|
||||||
|
// A player is grouped if it's a leader with members OR a child synced to another
|
||||||
|
bool get isGrouped => (groupMembers != null && groupMembers!.length > 1) || syncedTo != null;
|
||||||
|
bool get isGroupLeader => groupMembers != null && groupMembers!.length > 1 && syncedTo == null;
|
||||||
|
bool get isGroupChild => syncedTo != null;
|
||||||
|
|
||||||
// Track when this Player object was created (for local interpolation fallback)
|
// Track when this Player object was created (for local interpolation fallback)
|
||||||
static final Map<String, double> _playerCreationTimes = {};
|
static final Map<String, double> _playerCreationTimes = {};
|
||||||
|
|
||||||
@@ -164,6 +178,13 @@ class Player {
|
|||||||
elapsedTime ??= topLevelElapsedTime;
|
elapsedTime ??= topLevelElapsedTime;
|
||||||
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
elapsedTimeLastUpdated ??= topLevelLastUpdated;
|
||||||
|
|
||||||
|
// Parse group members - MA returns this as a list of player IDs
|
||||||
|
final groupMembersList = json['group_members'] as List<dynamic>?;
|
||||||
|
final groupMembers = groupMembersList?.map((e) => e.toString()).toList();
|
||||||
|
|
||||||
|
// Parse synced_to - the player ID this player is synced to
|
||||||
|
final syncedTo = json['synced_to'] as String?;
|
||||||
|
|
||||||
return Player(
|
return Player(
|
||||||
playerId: json['player_id'] as String,
|
playerId: json['player_id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
@@ -176,6 +197,8 @@ class Player {
|
|||||||
volumeMuted: json['volume_muted'] as bool?,
|
volumeMuted: json['volume_muted'] as bool?,
|
||||||
elapsedTime: elapsedTime,
|
elapsedTime: elapsedTime,
|
||||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
elapsedTimeLastUpdated: elapsedTimeLastUpdated,
|
||||||
|
groupMembers: groupMembers,
|
||||||
|
syncedTo: syncedTo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +215,8 @@ class Player {
|
|||||||
if (volumeMuted != null) 'volume_muted': volumeMuted,
|
if (volumeMuted != null) 'volume_muted': volumeMuted,
|
||||||
if (elapsedTime != null) 'elapsed_time': elapsedTime,
|
if (elapsedTime != null) 'elapsed_time': elapsedTime,
|
||||||
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
if (elapsedTimeLastUpdated != null) 'elapsed_time_last_updated': elapsedTimeLastUpdated,
|
||||||
|
if (groupMembers != null) 'group_members': groupMembers,
|
||||||
|
if (syncedTo != null) 'synced_to': syncedTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
import '../services/profile_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../services/error_handler.dart';
|
import '../services/error_handler.dart';
|
||||||
import '../services/auth/auth_manager.dart';
|
import '../services/auth/auth_manager.dart';
|
||||||
@@ -109,7 +111,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch user profile from MA and set owner name
|
/// Fetch user profile from MA and set owner name + create/activate local profile
|
||||||
Future<void> _fetchAndSetUserProfileName() async {
|
Future<void> _fetchAndSetUserProfileName() async {
|
||||||
if (_api == null) return;
|
if (_api == null) return;
|
||||||
|
|
||||||
@@ -132,6 +134,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (profileName != null && profileName.isNotEmpty) {
|
if (profileName != null && profileName.isNotEmpty) {
|
||||||
await SettingsService.setOwnerName(profileName);
|
await SettingsService.setOwnerName(profileName);
|
||||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||||
|
|
||||||
|
// Create/activate profile in local database
|
||||||
|
if (DatabaseService.instance.isInitialized && username != null) {
|
||||||
|
await ProfileService.instance.onMaAuthenticated(
|
||||||
|
username: username,
|
||||||
|
displayName: displayName,
|
||||||
|
);
|
||||||
|
_logger.log('✅ Local profile activated: $username');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_logger.log('⚠️ No valid name in profile');
|
_logger.log('⚠️ No valid name in profile');
|
||||||
}
|
}
|
||||||
@@ -178,7 +189,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
_logger.log('✅ MA authentication successful');
|
_logger.log('✅ MA authentication successful');
|
||||||
onAuthenticated?.call();
|
onAuthenticated?.call();
|
||||||
} else if (state == MAConnectionState.disconnected) {
|
} else if (state == MAConnectionState.disconnected) {
|
||||||
_cacheService.clearAll();
|
// DON'T clear caches on disconnect - keep showing cached data
|
||||||
|
// This allows instant UI display when app resumes or reconnects
|
||||||
|
// Caches will be refreshed when connection is restored
|
||||||
onDisconnected?.call();
|
onDisconnected?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -204,6 +217,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
await _api?.disconnect();
|
await _api?.disconnect();
|
||||||
_connectionState = MAConnectionState.disconnected;
|
_connectionState = MAConnectionState.disconnected;
|
||||||
|
// DON'T clear caches on disconnect - keep showing cached data for instant reconnect
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all caches and state (for logout or server change)
|
||||||
|
void clearCachesOnLogout() {
|
||||||
_cacheService.clearAll();
|
_cacheService.clearAll();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
|
||||||
|
class LocaleProvider extends ChangeNotifier {
|
||||||
|
Locale? _locale; // null = system default
|
||||||
|
|
||||||
|
LocaleProvider() {
|
||||||
|
_loadLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
Locale? get locale => _locale;
|
||||||
|
|
||||||
|
Future<void> _loadLocale() async {
|
||||||
|
final savedLocale = await SettingsService.getLocale();
|
||||||
|
if (savedLocale != null) {
|
||||||
|
_locale = Locale(savedLocale);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLocale(Locale? locale) async {
|
||||||
|
_locale = locale;
|
||||||
|
await SettingsService.saveLocale(locale?.languageCode);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the display name for a locale
|
||||||
|
static String getDisplayName(String? languageCode, BuildContext context) {
|
||||||
|
switch (languageCode) {
|
||||||
|
case 'en':
|
||||||
|
return 'English';
|
||||||
|
case 'de':
|
||||||
|
return 'Deutsch';
|
||||||
|
case null:
|
||||||
|
return 'System';
|
||||||
|
default:
|
||||||
|
return languageCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -506,7 +506,15 @@ class PlayerProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameLower == 'this device' || player.playerId.startsWith('ma_')) {
|
// Filter out MA Web UI's built-in player (provider is 'builtin_player' and starts with 'ma_')
|
||||||
|
// Note: We check BOTH conditions to avoid filtering snapcast/other players that may have 'ma_' prefix
|
||||||
|
if (player.provider == 'builtin_player' && player.playerId.startsWith('ma_')) {
|
||||||
|
filteredCount++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also filter "This Device" named players without proper provider
|
||||||
|
if (nameLower == 'this device') {
|
||||||
filteredCount++;
|
filteredCount++;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,22 @@ import '../theme/palette_helper.dart';
|
|||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/recently_played_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
|
||||||
class AlbumDetailsScreen extends StatefulWidget {
|
class AlbumDetailsScreen extends StatefulWidget {
|
||||||
final Album album;
|
final Album album;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
|
/// Initial image URL from the source (e.g., AlbumCard) for seamless hero animation
|
||||||
|
final String? initialImageUrl;
|
||||||
|
|
||||||
const AlbumDetailsScreen({
|
const AlbumDetailsScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.album,
|
required this.album,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
|
this.initialImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,6 +40,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
int? _expandedTrackIndex;
|
int? _expandedTrackIndex;
|
||||||
bool _isDescriptionExpanded = false;
|
bool _isDescriptionExpanded = false;
|
||||||
String? _albumDescription;
|
String? _albumDescription;
|
||||||
|
Album? _freshAlbum; // Full album data with image metadata
|
||||||
|
|
||||||
|
/// Get the best album data available (fresh with images, or widget.album as fallback)
|
||||||
|
Album get _displayAlbum => _freshAlbum ?? widget.album;
|
||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@@ -44,29 +53,31 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
_isFavorite = widget.album.favorite ?? false;
|
_isFavorite = widget.album.favorite ?? false;
|
||||||
_loadTracks();
|
_loadTracks();
|
||||||
_loadAlbumDescription();
|
_loadAlbumDescription();
|
||||||
_refreshFavoriteStatus();
|
|
||||||
|
|
||||||
// CRITICAL FIX: Defer color extraction until after the transition completes
|
// CRITICAL FIX: Defer both fresh data loading AND color extraction until
|
||||||
// This prevents expensive palette extraction during hero animation
|
// after the hero animation completes. This prevents:
|
||||||
// Wait for the first frame to be painted, then extract colors
|
// 1. setState with new image URL during animation → grey icon flash
|
||||||
|
// 2. Expensive palette extraction blocking animation frames
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Add small delay to ensure transition is complete
|
// 350ms matches FadeSlidePageRoute duration (300ms) + buffer
|
||||||
Future.delayed(const Duration(milliseconds: 350), () {
|
Future.delayed(const Duration(milliseconds: 350), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
_loadFreshAlbumData();
|
||||||
_extractColors();
|
_extractColors();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshFavoriteStatus() async {
|
/// Load fresh album data from API to get full metadata including images
|
||||||
|
Future<void> _loadFreshAlbumData() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
if (maProvider.api == null) return;
|
||||||
|
|
||||||
// Need a URI to fetch fresh album data
|
// Need a URI to fetch fresh album data
|
||||||
final albumUri = widget.album.uri;
|
final albumUri = widget.album.uri;
|
||||||
if (albumUri == null || albumUri.isEmpty) {
|
if (albumUri == null || albumUri.isEmpty) {
|
||||||
_logger.log('Cannot refresh favorite status: album has no URI');
|
_logger.log('Cannot load fresh album: album has no URI');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +85,20 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
final freshAlbum = await maProvider.api!.getAlbumByUri(albumUri);
|
||||||
if (freshAlbum != null && mounted) {
|
if (freshAlbum != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_freshAlbum = freshAlbum;
|
||||||
_isFavorite = freshAlbum.favorite ?? false;
|
_isFavorite = freshAlbum.favorite ?? false;
|
||||||
});
|
});
|
||||||
|
// Re-extract colors now that we have fresh album with images
|
||||||
|
_extractColors();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error refreshing favorite status: $e');
|
_logger.log('Error loading fresh album data: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _extractColors() async {
|
Future<void> _extractColors() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final imageUrl = maProvider.getImageUrl(widget.album, size: 512);
|
final imageUrl = maProvider.getImageUrl(_displayAlbum, size: 512);
|
||||||
|
|
||||||
if (imageUrl == null) return;
|
if (imageUrl == null) return;
|
||||||
|
|
||||||
@@ -106,10 +120,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
// For adding: use the actual provider and itemId from provider_mappings
|
// For adding: use the actual provider and itemId from provider_mappings
|
||||||
@@ -130,17 +144,18 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
await maProvider.api!.addToFavorites('album', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'album',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// For removing: need the library_item_id (numeric)
|
// For removing: need the library_item_id (numeric)
|
||||||
// When provider is "library", itemId is the library ID as a string
|
|
||||||
// Otherwise, we need to find it from provider_mappings
|
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
|
|
||||||
if (widget.album.provider == 'library') {
|
if (widget.album.provider == 'library') {
|
||||||
libraryItemId = int.tryParse(widget.album.itemId);
|
libraryItemId = int.tryParse(widget.album.itemId);
|
||||||
} else if (widget.album.providerMappings != null) {
|
} else if (widget.album.providerMappings != null) {
|
||||||
// Find the library mapping
|
|
||||||
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
final libraryMapping = widget.album.providerMappings!.firstWhere(
|
||||||
(m) => m.providerInstance == 'library',
|
(m) => m.providerInstance == 'library',
|
||||||
orElse: () => widget.album.providerMappings!.first,
|
orElse: () => widget.album.providerMappings!.first,
|
||||||
@@ -156,32 +171,41 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
_logger.log('Removing from favorites: libraryItemId=$libraryItemId');
|
||||||
await maProvider.api!.removeFromFavorites('album', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'album',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
if (success) {
|
||||||
_isFavorite = newState;
|
// Optimistically update UI regardless of online/offline state
|
||||||
});
|
setState(() {
|
||||||
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
// Invalidate home cache so the home screen shows updated favorite status
|
// Invalidate home cache so the home screen shows updated favorite status
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling favorite: $e');
|
_logger.log('Error toggling favorite: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to update favorite: $e'),
|
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -194,11 +218,11 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
final track = _tracks[trackIndex];
|
final track = _tracks[trackIndex];
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
final currentFavorite = track.favorite ?? false;
|
final currentFavorite = track.favorite ?? false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (currentFavorite) {
|
if (currentFavorite) {
|
||||||
// Remove from favorites - need library_item_id
|
// Remove from favorites - need library_item_id
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
@@ -215,7 +239,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItemId != null) {
|
if (libraryItemId != null) {
|
||||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
success = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
@@ -234,40 +263,49 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
actualItemId = mapping.itemId;
|
actualItemId = mapping.itemId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'track',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state - create new track with toggled favorite
|
if (success) {
|
||||||
setState(() {
|
// Optimistically update local state
|
||||||
_tracks[trackIndex] = Track(
|
setState(() {
|
||||||
itemId: track.itemId,
|
_tracks[trackIndex] = Track(
|
||||||
provider: track.provider,
|
itemId: track.itemId,
|
||||||
name: track.name,
|
provider: track.provider,
|
||||||
uri: track.uri,
|
name: track.name,
|
||||||
favorite: !currentFavorite,
|
uri: track.uri,
|
||||||
artists: track.artists,
|
favorite: !currentFavorite,
|
||||||
album: track.album,
|
artists: track.artists,
|
||||||
duration: track.duration,
|
album: track.album,
|
||||||
providerMappings: track.providerMappings,
|
duration: track.duration,
|
||||||
);
|
providerMappings: track.providerMappings,
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
!currentFavorite ? 'Added to favorites' : 'Removed from favorites',
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (!currentFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling track favorite: $e');
|
_logger.log('Error toggling track favorite: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to update favorite: $e'),
|
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -277,16 +315,46 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
|
|
||||||
Future<void> _loadTracks() async {
|
Future<void> _loadTracks() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = await provider.getAlbumTracksWithCache(
|
final cacheKey = '${widget.album.provider}_${widget.album.itemId}';
|
||||||
widget.album.provider,
|
|
||||||
widget.album.itemId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
// 1. Show cached data immediately (if available)
|
||||||
setState(() {
|
final cachedTracks = provider.getCachedAlbumTracks(cacheKey);
|
||||||
_tracks = tracks;
|
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||||
_isLoading = false;
|
if (mounted) {
|
||||||
});
|
setState(() {
|
||||||
|
_tracks = cachedTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
|
try {
|
||||||
|
final freshTracks = await provider.getAlbumTracksWithCache(
|
||||||
|
widget.album.provider,
|
||||||
|
widget.album.itemId,
|
||||||
|
forceRefresh: cachedTracks != null, // Force refresh if we had cache
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update if we got different data
|
||||||
|
if (mounted && freshTracks.isNotEmpty) {
|
||||||
|
final tracksChanged = _tracks.length != freshTracks.length ||
|
||||||
|
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
|
||||||
|
_tracks.first.itemId != freshTracks.first.itemId);
|
||||||
|
if (tracksChanged || _tracks.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = freshTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Background refresh failed: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure loading is false even if everything failed
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +367,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// Use the selected player
|
// Use the selected player
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
_showError('No player selected');
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +376,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// Queue all tracks via Music Assistant
|
// Queue all tracks via Music Assistant
|
||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||||
_logger.log('Album queued on ${player.name}');
|
_logger.log('Album queued on ${player.name}');
|
||||||
|
|
||||||
|
// Record to local recently played (per-profile)
|
||||||
|
RecentlyPlayedService.instance.recordAlbumPlayed(widget.album);
|
||||||
// Stay on album page - mini player will appear
|
// Stay on album page - mini player will appear
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error playing album: $e');
|
_logger.log('Error playing album: $e');
|
||||||
@@ -322,7 +393,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
// Use the selected player
|
// Use the selected player
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
_showError('No player selected');
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,14 +429,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Add album to queue on...',
|
S.of(context)!.addAlbumToQueueOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Text('No players available'),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -393,9 +464,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
_logger.log('Album added to queue on ${player.name}');
|
_logger.log('Album added to queue on ${player.name}');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Album added to queue'),
|
content: Text(S.of(context)!.albumAddedToQueue),
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -438,14 +509,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Add to queue on...',
|
S.of(context)!.addToQueueOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Text('No players available'),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -472,9 +543,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Tracks added to queue'),
|
content: Text(S.of(context)!.tracksAddedToQueue),
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -590,9 +661,12 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||||
// Only rebuild when specific properties change, not on every provider update
|
// Only rebuild when specific properties change, not on every provider update
|
||||||
final imageUrl = context.select<MusicAssistantProvider, String?>(
|
// Use _displayAlbum which has fresh data with images if available
|
||||||
(provider) => provider.getImageUrl(widget.album, size: 512),
|
final providerImageUrl = context.select<MusicAssistantProvider, String?>(
|
||||||
|
(provider) => provider.getImageUrl(_displayAlbum, size: 512),
|
||||||
);
|
);
|
||||||
|
// Use initialImageUrl as fallback for seamless hero animation
|
||||||
|
final imageUrl = providerImageUrl ?? widget.initialImageUrl;
|
||||||
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
(provider) => provider.adaptiveTheme,
|
(provider) => provider.adaptiveTheme,
|
||||||
);
|
);
|
||||||
@@ -697,7 +771,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.album.name,
|
widget.album.nameWithYear,
|
||||||
style: textTheme.headlineMedium?.copyWith(
|
style: textTheme.headlineMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -759,7 +833,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
|
onPressed: _isLoading || _tracks.isEmpty ? null : _playAlbum,
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: const Text('Play'),
|
label: Text(S.of(context)!.play),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
@@ -833,7 +907,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Tracks',
|
S.of(context)!.tracks,
|
||||||
style: textTheme.titleLarge?.copyWith(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -854,7 +928,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No tracks found',
|
S.of(context)!.noTracksFound,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onBackground.withOpacity(0.54),
|
color: colorScheme.onBackground.withOpacity(0.54),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -1085,14 +1159,14 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Play on...',
|
S.of(context)!.playOn,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: players.isEmpty
|
child: players.isEmpty
|
||||||
? const Center(
|
? Center(
|
||||||
child: Text('No players available'),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
padding: EdgeInsets.only(bottom: bottomPadding + 16),
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import '../constants/hero_tags.dart';
|
|||||||
import '../theme/palette_helper.dart';
|
import '../theme/palette_helper.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import '../services/metadata_service.dart';
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../utils/page_transitions.dart';
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ArtistDetailsScreen extends StatefulWidget {
|
class ArtistDetailsScreen extends StatefulWidget {
|
||||||
final Artist artist;
|
final Artist artist;
|
||||||
@@ -40,6 +42,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
String? _artistDescription;
|
String? _artistDescription;
|
||||||
String? _artistImageUrl;
|
String? _artistImageUrl;
|
||||||
|
|
||||||
|
// View preferences
|
||||||
|
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||||
|
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,6 +54,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
_isFavorite = widget.artist.favorite ?? false;
|
_isFavorite = widget.artist.favorite ?? false;
|
||||||
// Use initial image URL immediately for smooth hero animation
|
// Use initial image URL immediately for smooth hero animation
|
||||||
_artistImageUrl = widget.initialImageUrl;
|
_artistImageUrl = widget.initialImageUrl;
|
||||||
|
_loadViewPreferences();
|
||||||
_loadArtistAlbums();
|
_loadArtistAlbums();
|
||||||
_loadArtistDescription();
|
_loadArtistDescription();
|
||||||
_refreshFavoriteStatus();
|
_refreshFavoriteStatus();
|
||||||
@@ -64,6 +71,66 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadViewPreferences() async {
|
||||||
|
final sortOrder = await SettingsService.getArtistAlbumsSortOrder();
|
||||||
|
final viewMode = await SettingsService.getArtistAlbumsViewMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = sortOrder;
|
||||||
|
_viewMode = viewMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSortOrder() {
|
||||||
|
final newOrder = _sortOrder == 'alpha' ? 'year' : 'alpha';
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = newOrder;
|
||||||
|
_sortAlbums();
|
||||||
|
});
|
||||||
|
SettingsService.setArtistAlbumsSortOrder(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cycleViewMode() {
|
||||||
|
String newMode;
|
||||||
|
switch (_viewMode) {
|
||||||
|
case 'grid2':
|
||||||
|
newMode = 'grid3';
|
||||||
|
break;
|
||||||
|
case 'grid3':
|
||||||
|
newMode = 'list';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newMode = 'grid2';
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_viewMode = newMode;
|
||||||
|
});
|
||||||
|
SettingsService.setArtistAlbumsViewMode(newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sortAlbums() {
|
||||||
|
if (_sortOrder == 'year') {
|
||||||
|
// Sort by year ascending (oldest first), null years at end
|
||||||
|
_albums.sort((a, b) {
|
||||||
|
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
|
||||||
|
if (a.year == null) return 1;
|
||||||
|
if (b.year == null) return -1;
|
||||||
|
return a.year!.compareTo(b.year!);
|
||||||
|
});
|
||||||
|
_providerAlbums.sort((a, b) {
|
||||||
|
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
|
||||||
|
if (a.year == null) return 1;
|
||||||
|
if (b.year == null) return -1;
|
||||||
|
return a.year!.compareTo(b.year!);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Sort alphabetically
|
||||||
|
_albums.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
_providerAlbums.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshFavoriteStatus() async {
|
Future<void> _refreshFavoriteStatus() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
if (maProvider.api == null) return;
|
||||||
@@ -88,10 +155,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
if (maProvider.api == null) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newState = !_isFavorite;
|
final newState = !_isFavorite;
|
||||||
|
bool success;
|
||||||
|
|
||||||
if (newState) {
|
if (newState) {
|
||||||
String actualProvider = widget.artist.provider;
|
String actualProvider = widget.artist.provider;
|
||||||
@@ -110,7 +177,11 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
_logger.log('Adding artist to favorites: provider=$actualProvider, itemId=$actualItemId');
|
||||||
await maProvider.api!.addToFavorites('artist', actualItemId, actualProvider);
|
success = await maProvider.addToFavorites(
|
||||||
|
mediaType: 'artist',
|
||||||
|
itemId: actualItemId,
|
||||||
|
provider: actualProvider,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
int? libraryItemId;
|
int? libraryItemId;
|
||||||
|
|
||||||
@@ -132,31 +203,39 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
_logger.log('Removing artist from favorites: libraryItemId=$libraryItemId');
|
||||||
await maProvider.api!.removeFromFavorites('artist', libraryItemId);
|
success = await maProvider.removeFromFavorites(
|
||||||
|
mediaType: 'artist',
|
||||||
|
libraryItemId: libraryItemId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
if (success) {
|
||||||
_isFavorite = newState;
|
setState(() {
|
||||||
});
|
_isFavorite = newState;
|
||||||
|
});
|
||||||
|
|
||||||
maProvider.invalidateHomeCache();
|
maProvider.invalidateHomeCache();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final isOffline = !maProvider.isConnected;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(
|
SnackBar(
|
||||||
_isFavorite ? 'Added to favorites' : 'Removed from favorites',
|
content: Text(
|
||||||
|
isOffline
|
||||||
|
? S.of(context)!.actionQueuedForSync
|
||||||
|
: (_isFavorite ? S.of(context)!.addedToFavorites : S.of(context)!.removedFromFavorites),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 1),
|
);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('Error toggling artist favorite: $e');
|
_logger.log('Error toggling artist favorite: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to update favorite: $e'),
|
content: Text(S.of(context)!.failedToUpdateFavorite(e.toString())),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -170,7 +249,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
if (selectedPlayer == null) {
|
if (selectedPlayer == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('No player selected')),
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -180,7 +259,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Starting ${widget.artist.name} radio'),
|
content: Text(S.of(context)!.startingRadio(widget.artist.name)),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -190,7 +269,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to start radio: $e'),
|
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -216,14 +295,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Start ${widget.artist.name} radio on...',
|
S.of(context)!.startRadioOn(widget.artist.name),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (players.isEmpty)
|
if (players.isEmpty)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.all(32.0),
|
padding: const EdgeInsets.all(32.0),
|
||||||
child: Text('No players available'),
|
child: Text(S.of(context)!.noPlayersAvailable),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -245,7 +324,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Starting ${widget.artist.name} radio on ${player.name}'),
|
content: Text(S.of(context)!.startingRadioOnPlayer(widget.artist.name, player.name)),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -255,7 +334,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to start radio: $e'),
|
content: Text(S.of(context)!.failedToStartRadio(e.toString())),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -279,7 +358,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
|
|
||||||
if (selectedPlayer == null) {
|
if (selectedPlayer == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('No player selected')),
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,7 +369,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Added ${widget.artist.name} radio to queue'),
|
content: Text(S.of(context)!.addedRadioToQueue(widget.artist.name)),
|
||||||
duration: const Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -300,7 +379,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to add to queue: $e'),
|
content: Text(S.of(context)!.failedToAddToQueue(e.toString())),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -360,15 +439,51 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
Future<void> _loadArtistAlbums() async {
|
Future<void> _loadArtistAlbums() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
// Use cached artist albums method
|
// 1. Show cached data immediately (if available)
|
||||||
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
|
final cachedAlbums = provider.getCachedArtistAlbums(widget.artist.name);
|
||||||
|
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
||||||
|
final libraryAlbums = cachedAlbums.where((a) => a.inLibrary).toList();
|
||||||
|
final providerOnlyAlbums = cachedAlbums.where((a) => !a.inLibrary).toList();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_albums = allAlbums;
|
_albums = libraryAlbums;
|
||||||
_providerAlbums = []; // All albums are merged in the cache method
|
_providerAlbums = providerOnlyAlbums;
|
||||||
_isLoading = false;
|
_sortAlbums();
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
|
try {
|
||||||
|
final allAlbums = await provider.getArtistAlbumsWithCache(
|
||||||
|
widget.artist.name,
|
||||||
|
forceRefresh: cachedAlbums != null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted && allAlbums.isNotEmpty) {
|
||||||
|
// Check if data actually changed
|
||||||
|
final albumsChanged = _albums.length != allAlbums.where((a) => a.inLibrary).length;
|
||||||
|
|
||||||
|
if (albumsChanged || _albums.isEmpty) {
|
||||||
|
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||||
|
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_albums = libraryAlbums;
|
||||||
|
_providerAlbums = providerOnlyAlbums;
|
||||||
|
_sortAlbums();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,10 +553,12 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
height: 200,
|
height: 200,
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
size: 100,
|
size: 100,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -512,7 +629,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _startArtistRadio,
|
onPressed: _startArtistRadio,
|
||||||
icon: const Icon(Icons.radio),
|
icon: const Icon(Icons.radio),
|
||||||
label: const Text('Radio'),
|
label: Text(S.of(context)!.radio),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.primary,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
@@ -598,7 +715,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No albums found',
|
S.of(context)!.noAlbumsFound,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onBackground.withOpacity(0.54),
|
color: colorScheme.onBackground.withOpacity(0.54),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -607,72 +724,117 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else ...[
|
else ...[
|
||||||
// Library Albums Section
|
// Library Albums Section with inline controls
|
||||||
if (_albums.isNotEmpty) ...[
|
if (_albums.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||||
child: Text(
|
child: Row(
|
||||||
'In Library',
|
children: [
|
||||||
style: textTheme.titleLarge?.copyWith(
|
Text(
|
||||||
color: colorScheme.onBackground,
|
S.of(context)!.inLibrary,
|
||||||
fontWeight: FontWeight.bold,
|
style: textTheme.titleLarge?.copyWith(
|
||||||
),
|
color: colorScheme.onBackground,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
const Spacer(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
// Sort toggle
|
||||||
sliver: SliverGrid(
|
IconButton(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
icon: Icon(
|
||||||
crossAxisCount: 2,
|
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||||
childAspectRatio: 0.75,
|
color: colorScheme.primary,
|
||||||
crossAxisSpacing: 12,
|
size: 20,
|
||||||
mainAxisSpacing: 12,
|
),
|
||||||
),
|
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||||
delegate: SliverChildBuilderDelegate(
|
onPressed: _toggleSortOrder,
|
||||||
(context, index) {
|
visualDensity: VisualDensity.compact,
|
||||||
final album = _albums[index];
|
padding: EdgeInsets.zero,
|
||||||
return _buildAlbumCard(album);
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
},
|
),
|
||||||
childCount: _albums.length,
|
// View mode toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_viewMode == 'list'
|
||||||
|
? Icons.view_list
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? Icons.grid_view
|
||||||
|
: Icons.grid_on,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _viewMode == 'grid2'
|
||||||
|
? S.of(context)!.threeColumnGrid
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? S.of(context)!.listView
|
||||||
|
: S.of(context)!.twoColumnGrid,
|
||||||
|
onPressed: _cycleViewMode,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildAlbumSliver(_albums),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Provider Albums Section
|
// Provider Albums Section
|
||||||
if (_providerAlbums.isNotEmpty) ...[
|
if (_providerAlbums.isNotEmpty) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
|
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
|
||||||
child: Text(
|
child: Row(
|
||||||
'From Providers',
|
children: [
|
||||||
style: textTheme.titleLarge?.copyWith(
|
Text(
|
||||||
color: colorScheme.onBackground,
|
S.of(context)!.fromProviders,
|
||||||
fontWeight: FontWeight.bold,
|
style: textTheme.titleLarge?.copyWith(
|
||||||
),
|
color: colorScheme.onBackground,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
// Only show controls here if no library albums
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (_albums.isEmpty) ...[
|
||||||
sliver: SliverGrid(
|
const Spacer(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
IconButton(
|
||||||
crossAxisCount: 2,
|
icon: Icon(
|
||||||
childAspectRatio: 0.75,
|
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||||
crossAxisSpacing: 12,
|
color: colorScheme.primary,
|
||||||
mainAxisSpacing: 12,
|
size: 20,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||||
(context, index) {
|
onPressed: _toggleSortOrder,
|
||||||
final album = _providerAlbums[index];
|
visualDensity: VisualDensity.compact,
|
||||||
return _buildAlbumCard(album);
|
padding: EdgeInsets.zero,
|
||||||
},
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
childCount: _providerAlbums.length,
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_viewMode == 'list'
|
||||||
|
? Icons.view_list
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? Icons.grid_view
|
||||||
|
: Icons.grid_on,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _viewMode == 'grid2'
|
||||||
|
? S.of(context)!.threeColumnGrid
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? S.of(context)!.listView
|
||||||
|
: S.of(context)!.twoColumnGrid,
|
||||||
|
onPressed: _cycleViewMode,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildAlbumSliver(_providerAlbums),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -700,6 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: album,
|
album: album,
|
||||||
heroTagSuffix: heroSuffix,
|
heroTagSuffix: heroSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -707,7 +870,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0, // Square album art
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
@@ -715,12 +879,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: imageUrl != null
|
child: imageUrl != null
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
errorBuilder: (_, __, ___) => Center(
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.album_rounded,
|
Icons.album_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
@@ -745,7 +911,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
album.name,
|
album.nameWithYear,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -773,4 +939,101 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumSliver(List<Album> albums) {
|
||||||
|
if (_viewMode == 'list') {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAlbumListTile(albums[index]),
|
||||||
|
childCount: albums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
|
||||||
|
final childAspectRatio = _viewMode == 'grid3' ? 0.70 : 0.78;
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
childAspectRatio: childAspectRatio,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAlbumCard(albums[index]),
|
||||||
|
childCount: albums.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAlbumListTile(Album album) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(album, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: colorScheme.surfaceVariant,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.album_rounded,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
album.nameWithYear,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
album.artistsString,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: AlbumDetailsScreen(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: 'artist_albums',
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../theme/palette_helper.dart';
|
||||||
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/metadata_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import 'audiobook_detail_screen.dart';
|
||||||
|
|
||||||
|
class AudiobookAuthorScreen extends StatefulWidget {
|
||||||
|
final String authorName;
|
||||||
|
final List<Audiobook> audiobooks;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final String? initialAuthorImageUrl;
|
||||||
|
|
||||||
|
const AudiobookAuthorScreen({
|
||||||
|
super.key,
|
||||||
|
required this.authorName,
|
||||||
|
required this.audiobooks,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.initialAuthorImageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudiobookAuthorScreen> createState() => _AudiobookAuthorScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudiobookAuthorScreenState extends State<AudiobookAuthorScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
late List<Audiobook> _audiobooks;
|
||||||
|
ColorScheme? _lightColorScheme;
|
||||||
|
ColorScheme? _darkColorScheme;
|
||||||
|
String? _authorImageUrl;
|
||||||
|
|
||||||
|
// View preferences
|
||||||
|
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||||
|
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
|
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_audiobooks = List.from(widget.audiobooks);
|
||||||
|
// Use initial image URL immediately for smooth hero animation
|
||||||
|
_authorImageUrl = widget.initialAuthorImageUrl;
|
||||||
|
_loadViewPreferences();
|
||||||
|
_sortAudiobooks();
|
||||||
|
_loadAuthorImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAuthorImage() async {
|
||||||
|
final imageUrl = await MetadataService.getAuthorImageUrl(widget.authorName);
|
||||||
|
if (mounted && imageUrl != null) {
|
||||||
|
setState(() {
|
||||||
|
_authorImageUrl = imageUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadViewPreferences() async {
|
||||||
|
final sortOrder = await SettingsService.getAuthorAudiobooksSortOrder();
|
||||||
|
final viewMode = await SettingsService.getAuthorAudiobooksViewMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = sortOrder;
|
||||||
|
_viewMode = viewMode;
|
||||||
|
_sortAudiobooks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSortOrder() {
|
||||||
|
final newOrder = _sortOrder == 'alpha' ? 'year' : 'alpha';
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = newOrder;
|
||||||
|
_sortAudiobooks();
|
||||||
|
});
|
||||||
|
SettingsService.setAuthorAudiobooksSortOrder(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cycleViewMode() {
|
||||||
|
String newMode;
|
||||||
|
switch (_viewMode) {
|
||||||
|
case 'grid2':
|
||||||
|
newMode = 'grid3';
|
||||||
|
break;
|
||||||
|
case 'grid3':
|
||||||
|
newMode = 'list';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newMode = 'grid2';
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_viewMode = newMode;
|
||||||
|
});
|
||||||
|
SettingsService.setAuthorAudiobooksViewMode(newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sortAudiobooks() {
|
||||||
|
if (_sortOrder == 'year') {
|
||||||
|
_audiobooks.sort((a, b) {
|
||||||
|
if (a.year == null && b.year == null) return a.name.compareTo(b.name);
|
||||||
|
if (a.year == null) return 1;
|
||||||
|
if (b.year == null) return -1;
|
||||||
|
return a.year!.compareTo(b.year!);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final adaptiveTheme = context.select<ThemeProvider, bool>(
|
||||||
|
(provider) => provider.adaptiveTheme,
|
||||||
|
);
|
||||||
|
final adaptiveLightScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveLightScheme,
|
||||||
|
);
|
||||||
|
final adaptiveDarkScheme = context.select<ThemeProvider, ColorScheme?>(
|
||||||
|
(provider) => provider.adaptiveDarkScheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
ColorScheme? adaptiveScheme;
|
||||||
|
if (adaptiveTheme) {
|
||||||
|
adaptiveScheme = isDark
|
||||||
|
? (_darkColorScheme ?? adaptiveDarkScheme)
|
||||||
|
: (_lightColorScheme ?? adaptiveLightScheme);
|
||||||
|
}
|
||||||
|
final colorScheme = adaptiveScheme ?? Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return PopScope(
|
||||||
|
canPop: true,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (didPop) {
|
||||||
|
clearAdaptiveColorsOnBack(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
clearAdaptiveColorsOnBack(context);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.authorImage + widget.authorName + _heroTagSuffix,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: ClipOval(
|
||||||
|
child: _authorImageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: _authorImageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Icon(
|
||||||
|
MdiIcons.accountOutline,
|
||||||
|
size: 100,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
MdiIcons.accountOutline,
|
||||||
|
size: 100,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
MdiIcons.accountOutline,
|
||||||
|
size: 100,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.authorName,
|
||||||
|
style: textTheme.headlineMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.audiobookCount(_audiobooks.length),
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Audiobooks Section Header with controls
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
S.of(context)!.audiobooks,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Sort toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _sortOrder == 'alpha' ? S.of(context)!.sortByYear : S.of(context)!.sortAlphabetically,
|
||||||
|
onPressed: _toggleSortOrder,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
// View mode toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_viewMode == 'list'
|
||||||
|
? Icons.view_list
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? Icons.grid_view
|
||||||
|
: Icons.grid_on,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _viewMode == 'grid2'
|
||||||
|
? S.of(context)!.threeColumnGrid
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? S.of(context)!.listView
|
||||||
|
: S.of(context)!.twoColumnGrid,
|
||||||
|
onPressed: _cycleViewMode,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildAudiobookSliver(),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 140)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookSliver() {
|
||||||
|
if (_viewMode == 'list') {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAudiobookListTile(_audiobooks[index]),
|
||||||
|
childCount: _audiobooks.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
|
||||||
|
final childAspectRatio = _viewMode == 'grid3' ? 0.65 : 0.70;
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
childAspectRatio: childAspectRatio,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAudiobookCard(_audiobooks[index]),
|
||||||
|
childCount: _audiobooks.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookCard(Audiobook book) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(book, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final heroSuffix = 'author${_heroTagSuffix}';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Progress indicator
|
||||||
|
if (book.progress > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: book.progress,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
|
minHeight: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
book.name,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (book.narratorsString != S.of(context)!.unknownNarrator)
|
||||||
|
Text(
|
||||||
|
S.of(context)!.narratedBy(book.narratorsString),
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookListTile(Audiobook book) {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final imageUrl = maProvider.getImageUrl(book, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final heroSuffix = 'author${_heroTagSuffix}';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
leading: Hero(
|
||||||
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (book.progress > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: book.progress,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
|
minHeight: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
book.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
book.narratorsString != S.of(context)!.unknownNarrator
|
||||||
|
? S.of(context)!.narratedBy(book.narratorsString)
|
||||||
|
: book.duration != null
|
||||||
|
? _formatDuration(book.duration!)
|
||||||
|
: '',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: book.progress > 0
|
||||||
|
? Text(
|
||||||
|
'${(book.progress * 100).toInt()}%',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: AudiobookDetailScreen(
|
||||||
|
audiobook: book,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: initialImageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../widgets/global_player_overlay.dart' show BottomSpacing;
|
||||||
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
import 'audiobook_detail_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class AudiobookSeriesScreen extends StatefulWidget {
|
||||||
|
final AudiobookSeries series;
|
||||||
|
final String? heroTag;
|
||||||
|
/// Pre-loaded cover URLs from the library screen for smooth Hero animation
|
||||||
|
final List<String>? initialCovers;
|
||||||
|
|
||||||
|
const AudiobookSeriesScreen({
|
||||||
|
super.key,
|
||||||
|
required this.series,
|
||||||
|
this.heroTag,
|
||||||
|
this.initialCovers,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudiobookSeriesScreen> createState() => _AudiobookSeriesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudiobookSeriesScreenState extends State<AudiobookSeriesScreen> {
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
List<Audiobook> _audiobooks = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
// View preferences
|
||||||
|
String _sortOrder = 'series'; // 'series' (by series number) or 'alpha'
|
||||||
|
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
|
// Extracted colors from book covers
|
||||||
|
List<Color> _extractedColors = [];
|
||||||
|
|
||||||
|
// Cover URLs - start with initial covers, update when books load
|
||||||
|
List<String> _coverUrls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Use initial covers immediately for smooth Hero animation
|
||||||
|
if (widget.initialCovers != null && widget.initialCovers!.isNotEmpty) {
|
||||||
|
_coverUrls = List.from(widget.initialCovers!);
|
||||||
|
// Start color extraction from initial covers
|
||||||
|
_extractColorsFromUrls(widget.initialCovers!);
|
||||||
|
}
|
||||||
|
_loadViewPreferences();
|
||||||
|
// Defer loading until after first frame to ensure UI renders first
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_loadSeriesBooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract colors from cover URLs (used for initial covers)
|
||||||
|
Future<void> _extractColorsFromUrls(List<String> coverUrls) async {
|
||||||
|
final extractedColors = <Color>[];
|
||||||
|
|
||||||
|
for (final url in coverUrls.take(4)) {
|
||||||
|
try {
|
||||||
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(url),
|
||||||
|
maximumColorCount: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (palette.darkMutedColor != null) {
|
||||||
|
extractedColors.add(palette.darkMutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.mutedColor != null) {
|
||||||
|
extractedColors.add(palette.mutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.darkVibrantColor != null) {
|
||||||
|
extractedColors.add(palette.darkVibrantColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.dominantColor != null) {
|
||||||
|
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
|
||||||
|
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error extracting colors: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedColors.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_extractedColors = extractedColors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadViewPreferences() async {
|
||||||
|
final sortOrder = await SettingsService.getSeriesAudiobooksSortOrder();
|
||||||
|
final viewMode = await SettingsService.getSeriesAudiobooksViewMode();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = sortOrder;
|
||||||
|
_viewMode = viewMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSortOrder() {
|
||||||
|
final newOrder = _sortOrder == 'series' ? 'alpha' : 'series';
|
||||||
|
setState(() {
|
||||||
|
_sortOrder = newOrder;
|
||||||
|
_sortAudiobooks();
|
||||||
|
});
|
||||||
|
SettingsService.setSeriesAudiobooksSortOrder(newOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cycleViewMode() {
|
||||||
|
String newMode;
|
||||||
|
switch (_viewMode) {
|
||||||
|
case 'grid2':
|
||||||
|
newMode = 'grid3';
|
||||||
|
break;
|
||||||
|
case 'grid3':
|
||||||
|
newMode = 'list';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
newMode = 'grid2';
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_viewMode = newMode;
|
||||||
|
});
|
||||||
|
SettingsService.setSeriesAudiobooksViewMode(newMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sortAudiobooks() {
|
||||||
|
if (_sortOrder == 'series') {
|
||||||
|
// Sort by series number (sequence), falling back to name for books without sequence
|
||||||
|
_audiobooks.sort((a, b) {
|
||||||
|
final seqA = a.seriesSequence;
|
||||||
|
final seqB = b.seriesSequence;
|
||||||
|
if (seqA == null && seqB == null) return a.name.compareTo(b.name);
|
||||||
|
if (seqA == null) return 1; // Books without sequence go to end
|
||||||
|
if (seqB == null) return -1;
|
||||||
|
return seqA.compareTo(seqB);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Sort alphabetically
|
||||||
|
_audiobooks.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSeriesBooks() async {
|
||||||
|
_logger.log('📚 SeriesScreen _loadSeriesBooks START');
|
||||||
|
try {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
_logger.log('📚 SeriesScreen setState done, getting provider...');
|
||||||
|
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
_logger.log('📚 SeriesScreen got provider, api=${maProvider.api != null}');
|
||||||
|
|
||||||
|
if (maProvider.api == null) {
|
||||||
|
setState(() {
|
||||||
|
_error = S.of(context)!.notConnected;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 SeriesScreen calling getSeriesAudiobooks: path=${widget.series.id}');
|
||||||
|
final books = await maProvider.api!.getSeriesAudiobooks(widget.series.id);
|
||||||
|
_logger.log('📚 SeriesScreen got ${books.length} books');
|
||||||
|
|
||||||
|
// Debug: Log sequence data for each book
|
||||||
|
for (final book in books) {
|
||||||
|
_logger.log('📚 Book: "${book.name}" | position=${book.position} | browseOrder=${book.browseOrder} | sortName=${book.sortName} | seq=${book.seriesSequence} | metadata.series=${book.metadata?['series']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
// Update cover URLs from loaded books
|
||||||
|
final newCovers = <String>[];
|
||||||
|
for (final book in books.take(9)) {
|
||||||
|
final imageUrl = maProvider.getImageUrl(book);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
newCovers.add(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_audiobooks = books;
|
||||||
|
_sortAudiobooks(); // Sort inside setState so UI updates
|
||||||
|
// Update covers if we got new ones (may have more than initial)
|
||||||
|
if (newCovers.isNotEmpty) {
|
||||||
|
_coverUrls = newCovers;
|
||||||
|
}
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Debug: Log sorted order
|
||||||
|
_logger.log('📚 Sorted order ($_sortOrder):');
|
||||||
|
for (final book in _audiobooks) {
|
||||||
|
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract colors from book covers asynchronously
|
||||||
|
_extractCoverColors(maProvider);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.log('📚 SeriesScreen error: $e');
|
||||||
|
_logger.log('📚 SeriesScreen stack: $stack');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Failed to load books: $e';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract colors from book covers for empty grid cells
|
||||||
|
Future<void> _extractCoverColors(MusicAssistantProvider maProvider) async {
|
||||||
|
final extractedColors = <Color>[];
|
||||||
|
|
||||||
|
// Extract colors from first few book covers
|
||||||
|
for (final book in _audiobooks.take(4)) {
|
||||||
|
final imageUrl = maProvider.getImageUrl(book);
|
||||||
|
if (imageUrl == null) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
maximumColorCount: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get dark muted colors for grid squares
|
||||||
|
if (palette.darkMutedColor != null) {
|
||||||
|
extractedColors.add(palette.darkMutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.mutedColor != null) {
|
||||||
|
extractedColors.add(palette.mutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.darkVibrantColor != null) {
|
||||||
|
extractedColors.add(palette.darkVibrantColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.dominantColor != null) {
|
||||||
|
// Darken the dominant color for better appearance
|
||||||
|
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
|
||||||
|
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error extracting colors: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedColors.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_extractedColors = extractedColors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final maProvider = context.watch<MusicAssistantProvider>();
|
||||||
|
final l10n = S.of(context)!;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
body: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// App bar with series cover collage (matches audiobook detail screen)
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 350,
|
||||||
|
pinned: true,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
background: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
// Series cover collage - same size as audiobook detail cover
|
||||||
|
Hero(
|
||||||
|
tag: widget.heroTag ?? 'series_cover_${widget.series.id}',
|
||||||
|
child: Container(
|
||||||
|
width: 280,
|
||||||
|
height: 280,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: _buildSeriesCover(colorScheme, maProvider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Title and count
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.series.name,
|
||||||
|
style: textTheme.headlineMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_isLoading
|
||||||
|
? l10n.loading
|
||||||
|
: l10n.bookCount(_audiobooks.length),
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Books section header with controls
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.books,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Sort toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_sortOrder == 'series' ? Icons.format_list_numbered : Icons.sort_by_alpha,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _sortOrder == 'series' ? S.of(context)!.sortAlphabetically : S.of(context)!.sortBySeriesOrder,
|
||||||
|
onPressed: _toggleSortOrder,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
// View mode toggle
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_viewMode == 'list'
|
||||||
|
? Icons.view_list
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? Icons.grid_view
|
||||||
|
: Icons.grid_on,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
tooltip: _viewMode == 'grid2'
|
||||||
|
? S.of(context)!.threeColumnGrid
|
||||||
|
: _viewMode == 'grid3'
|
||||||
|
? S.of(context)!.listView
|
||||||
|
: S.of(context)!.twoColumnGrid,
|
||||||
|
onPressed: _cycleViewMode,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (_isLoading)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (_error != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, style: TextStyle(color: colorScheme.error)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadSeriesBooks,
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_audiobooks.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.library_books_outlined,
|
||||||
|
size: 48, color: colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.noBooksInSeries,
|
||||||
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildAudiobookSliver(maProvider),
|
||||||
|
|
||||||
|
// Bottom padding for mini player
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark pastel colors for empty cells - muted and book-like
|
||||||
|
static const _emptyColors = [
|
||||||
|
Color(0xFF2D3436), // Dark slate
|
||||||
|
Color(0xFF34495E), // Dark blue-grey
|
||||||
|
Color(0xFF4A3728), // Dark brown
|
||||||
|
Color(0xFF2C3E50), // Midnight blue
|
||||||
|
Color(0xFF3D3D3D), // Charcoal
|
||||||
|
Color(0xFF4A4458), // Dark purple-grey
|
||||||
|
Color(0xFF3E4A47), // Dark teal-grey
|
||||||
|
Color(0xFF4A3F35), // Dark warm grey
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
|
||||||
|
// Use _coverUrls which may have initial covers or loaded covers
|
||||||
|
// Only show placeholder if we truly have no covers
|
||||||
|
if (_coverUrls.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final covers = _coverUrls;
|
||||||
|
|
||||||
|
// Dynamic grid size based on number of covers (matches library screen)
|
||||||
|
// 1 cover = 1x1, 2-4 covers = 2x2, 5+ covers = 3x3
|
||||||
|
int gridSize;
|
||||||
|
if (covers.length == 1) {
|
||||||
|
gridSize = 1;
|
||||||
|
} else if (covers.length <= 4) {
|
||||||
|
gridSize = 2;
|
||||||
|
} else {
|
||||||
|
gridSize = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use series ID to pick consistent colors and nested grid patterns
|
||||||
|
final colorSeed = widget.series.id.hashCode;
|
||||||
|
|
||||||
|
// Pad covers to fill grid
|
||||||
|
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
|
||||||
|
for (var i = 0; i < covers.length && i < displayCovers.length; i++) {
|
||||||
|
displayCovers[i] = covers[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build grid using Column/Row for proper sizing (no scrolling issues)
|
||||||
|
return Column(
|
||||||
|
children: List.generate(gridSize, (row) {
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(gridSize, (col) {
|
||||||
|
final index = row * gridSize + col;
|
||||||
|
final coverUrl = displayCovers[index];
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(
|
||||||
|
Icons.book,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildEmptyCell(colorSeed, index),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an empty cell with either a solid color or a nested grid
|
||||||
|
/// The pattern is deterministic based on series ID and cell index
|
||||||
|
Widget _buildEmptyCell(int colorSeed, int cellIndex) {
|
||||||
|
// Use extracted colors if available, otherwise fall back to static palette
|
||||||
|
final baseColors = _extractedColors.isNotEmpty ? _extractedColors : _emptyColors;
|
||||||
|
// Tone down colors - reduce saturation and darken
|
||||||
|
final colors = baseColors.map((c) {
|
||||||
|
final hsl = HSLColor.fromColor(c);
|
||||||
|
return hsl
|
||||||
|
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
|
||||||
|
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
|
||||||
|
.toColor();
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Use combined seed for deterministic but varied patterns
|
||||||
|
final seed = colorSeed + cellIndex * 17; // Prime multiplier for better distribution
|
||||||
|
|
||||||
|
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
|
||||||
|
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
|
||||||
|
final sizeRoll = seed.abs() % 100;
|
||||||
|
int nestedSize;
|
||||||
|
if (sizeRoll < 50) {
|
||||||
|
nestedSize = 1; // Solid color
|
||||||
|
} else if (sizeRoll < 80) {
|
||||||
|
nestedSize = 2; // 2x2 grid
|
||||||
|
} else {
|
||||||
|
nestedSize = 3; // 3x3 grid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nestedSize == 1) {
|
||||||
|
// Solid color
|
||||||
|
final colorIndex = seed.abs() % colors.length;
|
||||||
|
return Container(color: colors[colorIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nested grid (no margins - seamless)
|
||||||
|
return Column(
|
||||||
|
children: List.generate(nestedSize, (row) {
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(nestedSize, (col) {
|
||||||
|
final nestedIndex = row * nestedSize + col;
|
||||||
|
// Use different seed for each nested cell
|
||||||
|
final nestedSeed = seed + nestedIndex * 7;
|
||||||
|
final colorIndex = nestedSeed.abs() % colors.length;
|
||||||
|
return Expanded(
|
||||||
|
child: Container(color: colors[colorIndex]),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookSliver(MusicAssistantProvider maProvider) {
|
||||||
|
if (_viewMode == 'list') {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAudiobookListTile(_audiobooks[index], maProvider),
|
||||||
|
childCount: _audiobooks.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final crossAxisCount = _viewMode == 'grid3' ? 3 : 2;
|
||||||
|
final childAspectRatio = _viewMode == 'grid3' ? 0.65 : 0.70;
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
childAspectRatio: childAspectRatio,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildAudiobookCard(_audiobooks[index], maProvider),
|
||||||
|
childCount: _audiobooks.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookCard(Audiobook book, MusicAssistantProvider maProvider) {
|
||||||
|
final imageUrl = maProvider.getImageUrl(book, size: 256);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final heroSuffix = 'series_${widget.series.id}';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Progress indicator
|
||||||
|
if (book.progress > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: book.progress,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
|
minHeight: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
book.name,
|
||||||
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor)
|
||||||
|
Text(
|
||||||
|
book.authorsString,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAudiobookListTile(Audiobook book, MusicAssistantProvider maProvider) {
|
||||||
|
final imageUrl = maProvider.getImageUrl(book, size: 128);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final heroSuffix = 'series_${widget.series.id}';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
leading: Hero(
|
||||||
|
tag: HeroTags.audiobookCover + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (book.progress > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: book.progress,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
|
minHeight: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + (book.uri ?? book.itemId) + '_$heroSuffix',
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
book.name,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
book.authorsString.isNotEmpty && book.authorsString != S.of(context)!.unknownAuthor
|
||||||
|
? book.authorsString
|
||||||
|
: book.duration != null
|
||||||
|
? _formatDuration(book.duration!)
|
||||||
|
: '',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: book.progress > 0
|
||||||
|
? Text(
|
||||||
|
'${(book.progress * 100).toInt()}%',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _navigateToAudiobook(book, heroTagSuffix: heroSuffix, initialImageUrl: imageUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix, String? initialImageUrl}) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: AudiobookDetailScreen(
|
||||||
|
audiobook: book,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: initialImageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
}
|
||||||
|
return '${minutes}m';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class DebugLogScreen extends StatefulWidget {
|
class DebugLogScreen extends StatefulWidget {
|
||||||
const DebugLogScreen({super.key});
|
const DebugLogScreen({super.key});
|
||||||
@@ -42,10 +43,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
void _copyLogs() {
|
void _copyLogs() {
|
||||||
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
Clipboard.setData(ClipboardData(text: _logger.getAllLogs()));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Logs copied to clipboard'),
|
content: Text(S.of(context)!.logsCopied),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
|
|
||||||
await Share.share(
|
await Share.share(
|
||||||
report,
|
report,
|
||||||
subject: 'Ensemble Bug Report',
|
subject: S.of(context)!.ensembleBugReport,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -72,9 +73,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
_logger.clear();
|
_logger.clear();
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Logs cleared'),
|
content: Text(S.of(context)!.logsCleared),
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: const Color(0xFF2a2a2a),
|
backgroundColor: const Color(0xFF2a2a2a),
|
||||||
title: Text(
|
title: Text(
|
||||||
'All Players (${allPlayers.length})',
|
S.of(context)!.allPlayersCount(allPlayers.length),
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
@@ -153,27 +154,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'ID: ${player.playerId}',
|
S.of(context)!.playerId(player.playerId),
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Available: ${player.available} | Provider: ${player.provider}',
|
S.of(context)!.playerInfo(player.available.toString(), player.provider ?? 'unknown'),
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
),
|
),
|
||||||
if (isCurrentPlayer)
|
if (isCurrentPlayer)
|
||||||
const Text(
|
Text(
|
||||||
'← This device',
|
S.of(context)!.thisDevice,
|
||||||
style: TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
|
style: const TextStyle(color: Colors.green, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
if (isGhost && !isCorrupt)
|
if (isGhost && !isCorrupt)
|
||||||
const Text(
|
Text(
|
||||||
'⚠️ Ghost player (duplicate)',
|
S.of(context)!.ghostPlayer,
|
||||||
style: TextStyle(color: Colors.red, fontSize: 11),
|
style: const TextStyle(color: Colors.red, fontSize: 11),
|
||||||
),
|
),
|
||||||
if (isCorrupt)
|
if (isCorrupt)
|
||||||
const Text(
|
Text(
|
||||||
'⚠️ Unavailable/Corrupt',
|
S.of(context)!.unavailableCorrupt,
|
||||||
style: TextStyle(color: Colors.orange, fontSize: 11),
|
style: const TextStyle(color: Colors.orange, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -195,15 +196,15 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Player list copied to clipboard!')),
|
SnackBar(content: Text(S.of(context)!.playerListCopied)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Copy List'),
|
child: Text(S.of(context)!.copyList),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Close'),
|
child: Text(S.of(context)!.close),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -211,7 +212,7 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Error loading players: $e')),
|
SnackBar(content: Text(S.of(context)!.errorLoadingPlayers(e.toString()))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,9 +230,9 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Debug Logs',
|
S.of(context)!.debugLogs,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -256,11 +257,11 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
icon: const Icon(Icons.share_rounded),
|
icon: const Icon(Icons.share_rounded),
|
||||||
onPressed: _shareBugReport,
|
onPressed: _shareBugReport,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
tooltip: 'Share bug report',
|
tooltip: S.of(context)!.shareBugReport,
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
tooltip: 'More options',
|
tooltip: S.of(context)!.moreOptions,
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case 'show_players':
|
case 'show_players':
|
||||||
@@ -275,27 +276,27 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'show_players',
|
value: 'show_players',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.speaker_group_rounded),
|
leading: const Icon(Icons.speaker_group_rounded),
|
||||||
title: Text('View All Players'),
|
title: Text(S.of(context)!.viewAllPlayers),
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'copy_logs',
|
value: 'copy_logs',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.copy_rounded),
|
leading: const Icon(Icons.copy_rounded),
|
||||||
title: Text('Copy Logs'),
|
title: Text(S.of(context)!.copyLogs),
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'clear_logs',
|
value: 'clear_logs',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.delete_rounded),
|
leading: const Icon(Icons.delete_rounded),
|
||||||
title: Text('Clear Logs'),
|
title: Text(S.of(context)!.clearLogs),
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -317,20 +318,20 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildFilterChip('All', LogLevel.debug),
|
_buildFilterChip(S.of(context)!.all, LogLevel.debug),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildFilterChip('Info+', LogLevel.info),
|
_buildFilterChip(S.of(context)!.infoPlus, LogLevel.info),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildFilterChip('Warnings', LogLevel.warning),
|
_buildFilterChip(S.of(context)!.warnings, LogLevel.warning),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildFilterChip('Errors', LogLevel.error),
|
_buildFilterChip(S.of(context)!.errors, LogLevel.error),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Stats
|
// Stats
|
||||||
Text(
|
Text(
|
||||||
'Showing ${_filteredEntries.length} of ${_logger.entries.length} entries',
|
S.of(context)!.showingEntries(_filteredEntries.length, _logger.entries.length),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -338,10 +339,10 @@ class _DebugLogScreenState extends State<DebugLogScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _filteredEntries.isEmpty
|
child: _filteredEntries.isEmpty
|
||||||
? const Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No logs yet',
|
S.of(context)!.noLogsYet,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white54,
|
color: Colors.white54,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'new_home_screen.dart';
|
import 'new_home_screen.dart';
|
||||||
@@ -39,7 +40,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('Press back again to minimize'),
|
content: Text(S.of(context)!.pressBackToMinimize),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
||||||
@@ -61,6 +62,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
|
|
||||||
|
// If player reveal (device list) is visible, dismiss it first (with animation)
|
||||||
|
if (GlobalPlayerOverlay.isPlayerRevealVisible) {
|
||||||
|
GlobalPlayerOverlay.dismissPlayerReveal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If global player is expanded, collapse it first
|
// If global player is expanded, collapse it first
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/album_card.dart';
|
import '../widgets/album_card.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryAlbumsScreen extends StatelessWidget {
|
class LibraryAlbumsScreen extends StatelessWidget {
|
||||||
const LibraryAlbumsScreen({super.key});
|
const LibraryAlbumsScreen({super.key});
|
||||||
@@ -25,7 +26,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Albums',
|
S.of(context)!.albums,
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -46,7 +47,9 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
Widget _buildAlbumsList(BuildContext context, List<Album> albums, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (albums.isEmpty && isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
@@ -54,6 +57,7 @@ class LibraryAlbumsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
if (albums.isEmpty) {
|
if (albums.isEmpty) {
|
||||||
return EmptyState.albums(
|
return EmptyState.albums(
|
||||||
|
context: context,
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../utils/page_transitions.dart';
|
|||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import '../widgets/artist_avatar.dart';
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryArtistsScreen extends StatelessWidget {
|
class LibraryArtistsScreen extends StatelessWidget {
|
||||||
const LibraryArtistsScreen({super.key});
|
const LibraryArtistsScreen({super.key});
|
||||||
@@ -27,7 +28,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Artists',
|
S.of(context)!.artists,
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -48,7 +49,9 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
Widget _buildArtistsList(BuildContext context, List<Artist> artists, bool isLoading) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (artists.isEmpty && isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
@@ -56,6 +59,7 @@ class LibraryArtistsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
if (artists.isEmpty) {
|
if (artists.isEmpty) {
|
||||||
return EmptyState.artists(
|
return EmptyState.artists(
|
||||||
|
context: context,
|
||||||
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
onRefresh: () => context.read<MusicAssistantProvider>().loadLibrary(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
import 'playlist_details_screen.dart';
|
import 'playlist_details_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryPlaylistsScreen extends StatefulWidget {
|
class LibraryPlaylistsScreen extends StatefulWidget {
|
||||||
const LibraryPlaylistsScreen({super.key});
|
const LibraryPlaylistsScreen({super.key});
|
||||||
@@ -59,7 +60,7 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Playlists',
|
S.of(context)!.playlists,
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -74,14 +75,16 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
Widget _buildPlaylistsList(BuildContext context, MusicAssistantProvider provider) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (_isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (_playlists.isEmpty && _isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_playlists.isEmpty) {
|
if (_playlists.isEmpty) {
|
||||||
return EmptyState.playlists(onRefresh: _loadPlaylists);
|
return EmptyState.playlists(context: context, onRefresh: _loadPlaylists);
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -138,8 +141,8 @@ class _LibraryPlaylistsScreenState extends State<LibraryPlaylistsScreen> {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
playlist.trackCount != null
|
playlist.trackCount != null
|
||||||
? '${playlist.trackCount} tracks'
|
? '${playlist.trackCount} ${S.of(context)!.tracks}'
|
||||||
: playlist.owner ?? 'Playlist',
|
: playlist.owner ?? S.of(context)!.playlist,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurface.withOpacity(0.7),
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class LibraryTracksScreen extends StatelessWidget {
|
class LibraryTracksScreen extends StatelessWidget {
|
||||||
const LibraryTracksScreen({super.key});
|
const LibraryTracksScreen({super.key});
|
||||||
@@ -25,7 +26,7 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Tracks',
|
S.of(context)!.tracks,
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -41,14 +42,16 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
BuildContext context, MusicAssistantProvider provider) {
|
BuildContext context, MusicAssistantProvider provider) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (provider.isLoading) {
|
// Show cached data immediately if available, even while loading
|
||||||
|
// Only show spinner if we have no data at all AND we're loading
|
||||||
|
if (provider.tracks.isEmpty && provider.isLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.tracks.isEmpty) {
|
if (provider.tracks.isEmpty) {
|
||||||
return EmptyState.tracks(onRefresh: provider.loadLibrary);
|
return EmptyState.tracks(context: context, onRefresh: provider.loadLibrary);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
@@ -129,7 +132,7 @@ class LibraryTracksScreen extends StatelessWidget {
|
|||||||
|
|
||||||
if (maProvider.selectedPlayer == null) {
|
if (maProvider.selectedPlayer == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('No player selected')),
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
|
import '../services/profile_service.dart';
|
||||||
import '../services/auth/auth_strategy.dart';
|
import '../services/auth/auth_strategy.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../widgets/debug/debug_console.dart';
|
import '../widgets/debug/debug_console.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -30,6 +33,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _showDebug = false;
|
bool _showDebug = false;
|
||||||
List<String> _debugLogs = [];
|
List<String> _debugLogs = [];
|
||||||
|
String? _resolvedServerUrl; // The actual URL after fallback (e.g., HTTP if HTTPS failed)
|
||||||
|
bool _usedHttpFallback = false; // True if HTTPS failed and we fell back to HTTP
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -94,10 +99,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return 'http://$url';
|
return 'http://$url';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tailscale URLs (*.ts.net) - default to http:// since VPN tunnel is already encrypted
|
||||||
|
if (url.endsWith('.ts.net') || url.contains('.ts.net:')) {
|
||||||
|
return 'http://$url';
|
||||||
|
}
|
||||||
|
|
||||||
// For domain names, default to https://
|
// For domain names, default to https://
|
||||||
return 'https://$url';
|
return 'https://$url';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the URL is using HTTP (for security warning display)
|
||||||
|
bool _isHttpConnection(String url) {
|
||||||
|
final normalized = _normalizeServerUrl(url);
|
||||||
|
return normalized.startsWith('http://') && !normalized.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the URL is a Tailscale URL (safe for HTTP)
|
||||||
|
bool _isTailscaleUrl(String url) {
|
||||||
|
final normalized = url.trim().toLowerCase();
|
||||||
|
return normalized.contains('.ts.net');
|
||||||
|
}
|
||||||
|
|
||||||
String _buildServerUrl() {
|
String _buildServerUrl() {
|
||||||
// Normalize the base URL
|
// Normalize the base URL
|
||||||
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||||
@@ -137,7 +159,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Future<void> _detectAuthRequirements() async {
|
Future<void> _detectAuthRequirements() async {
|
||||||
if (_serverUrlController.text.trim().isEmpty) {
|
if (_serverUrlController.text.trim().isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter your Music Assistant server address';
|
_error = S.of(context)!.pleaseEnterServerAddress;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,6 +169,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_detectedAuthStrategy = null;
|
_detectedAuthStrategy = null;
|
||||||
_detectedAuthType = null;
|
_detectedAuthType = null;
|
||||||
|
_resolvedServerUrl = null;
|
||||||
|
_usedHttpFallback = false;
|
||||||
_debugLogs.clear();
|
_debugLogs.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,11 +184,18 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
// Auto-detect authentication requirements
|
// Auto-detect authentication requirements
|
||||||
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
final strategy = await provider.authManager.detectAuthStrategy(serverUrl);
|
||||||
|
|
||||||
|
// Check if auth manager used a different URL (e.g., HTTP fallback)
|
||||||
|
final resolvedUrl = provider.authManager.lastSuccessfulUrl;
|
||||||
|
if (resolvedUrl != null && resolvedUrl != serverUrl) {
|
||||||
|
_addDebugLog('URL resolved via fallback: $resolvedUrl');
|
||||||
|
_usedHttpFallback = serverUrl.startsWith('https://') && resolvedUrl.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
_addDebugLog('Detection complete. Strategy: ${strategy?.name ?? 'null'}');
|
||||||
|
|
||||||
if (strategy == null) {
|
if (strategy == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Cannot determine authentication requirements. Please check server URL.';
|
_error = S.of(context)!.cannotDetermineAuth;
|
||||||
_isDetectingAuth = false;
|
_isDetectingAuth = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -173,6 +204,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_detectedAuthStrategy = strategy;
|
_detectedAuthStrategy = strategy;
|
||||||
_detectedAuthType = _getAuthTypeName(strategy.name);
|
_detectedAuthType = _getAuthTypeName(strategy.name);
|
||||||
|
_resolvedServerUrl = resolvedUrl;
|
||||||
_isDetectingAuth = false;
|
_isDetectingAuth = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,13 +231,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
String _getAuthTypeName(String strategyName) {
|
String _getAuthTypeName(String strategyName) {
|
||||||
switch (strategyName) {
|
switch (strategyName) {
|
||||||
case 'none':
|
case 'none':
|
||||||
return 'No Authentication';
|
return S.of(context)!.noAuthentication;
|
||||||
case 'basic':
|
case 'basic':
|
||||||
return 'HTTP Basic Auth';
|
return S.of(context)!.httpBasicAuth;
|
||||||
case 'authelia':
|
case 'authelia':
|
||||||
return 'Authelia';
|
return S.of(context)!.authelia;
|
||||||
case 'music_assistant':
|
case 'music_assistant':
|
||||||
return 'Music Assistant Login';
|
return S.of(context)!.musicAssistantLogin;
|
||||||
default:
|
default:
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
@@ -229,7 +261,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Future<void> _connect() async {
|
Future<void> _connect() async {
|
||||||
if (_serverUrlController.text.trim().isEmpty) {
|
if (_serverUrlController.text.trim().isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter your Music Assistant server address';
|
_error = S.of(context)!.pleaseEnterServerAddress;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -239,7 +271,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
|
final isMaAuth = _detectedAuthStrategy?.name == 'music_assistant';
|
||||||
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
|
if (!isMaAuth && _ownerNameController.text.trim().isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter your name';
|
_error = S.of(context)!.pleaseEnterName;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -250,7 +282,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final serverUrl = _buildServerUrl();
|
// Use the resolved URL if available (e.g., from HTTP fallback), otherwise build it
|
||||||
|
final serverUrl = _resolvedServerUrl ?? _buildServerUrl();
|
||||||
|
_addDebugLog('Using server URL for connection: $serverUrl');
|
||||||
final port = _portController.text.trim();
|
final port = _portController.text.trim();
|
||||||
|
|
||||||
// Validate port (if provided)
|
// Validate port (if provided)
|
||||||
@@ -259,7 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
portNum = int.tryParse(port);
|
portNum = int.tryParse(port);
|
||||||
if (portNum == null || portNum < 1 || portNum > 65535) {
|
if (portNum == null || portNum < 1 || portNum > 65535) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter a valid port number (1-65535)';
|
_error = S.of(context)!.pleaseEnterValidPort;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -269,7 +303,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
// Save owner name (if provided) and port to settings
|
// Save owner name (if provided) and port to settings
|
||||||
// For MA auth, the owner name will be set from user profile after login
|
// For MA auth, the owner name will be set from user profile after login
|
||||||
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
|
if (!isMaAuth && _ownerNameController.text.trim().isNotEmpty) {
|
||||||
await SettingsService.setOwnerName(_ownerNameController.text.trim());
|
final ownerName = _ownerNameController.text.trim();
|
||||||
|
await SettingsService.setOwnerName(ownerName);
|
||||||
|
|
||||||
|
// Create/activate profile in local database for manual name entry
|
||||||
|
if (DatabaseService.instance.isInitialized) {
|
||||||
|
await ProfileService.instance.onManualNameEntered(ownerName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await SettingsService.setWebSocketPort(portNum);
|
await SettingsService.setWebSocketPort(portNum);
|
||||||
|
|
||||||
@@ -285,7 +325,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (username.isEmpty || password.isEmpty) {
|
if (username.isEmpty || password.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter username and password';
|
_error = S.of(context)!.pleaseEnterCredentials;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -311,7 +351,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Authentication failed. Please check your credentials.';
|
_error = S.of(context)!.authFailed;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -345,7 +385,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (username.isEmpty || password.isEmpty) {
|
if (username.isEmpty || password.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Please enter username and password';
|
_error = S.of(context)!.pleaseEnterCredentials;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -369,7 +409,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
if (accessToken == null) {
|
if (accessToken == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Music Assistant login failed. Please check your credentials.';
|
_error = S.of(context)!.maLoginFailed;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -413,7 +453,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = 'Could not connect to server. Please check the address and try again.';
|
_error = S.of(context)!.connectionFailed;
|
||||||
_isConnecting = false;
|
_isConnecting = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -461,7 +501,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
// Server URL
|
// Server URL
|
||||||
Text(
|
Text(
|
||||||
'Server Address',
|
S.of(context)!.serverAddress,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -473,7 +513,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _serverUrlController,
|
controller: _serverUrlController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'e.g., music.example.com or 192.168.1.100',
|
hintText: S.of(context)!.serverAddressHint,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -500,7 +540,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
if (_detectedAuthStrategy != null &&
|
if (_detectedAuthStrategy != null &&
|
||||||
_detectedAuthStrategy!.name != 'music_assistant') ...[
|
_detectedAuthStrategy!.name != 'music_assistant') ...[
|
||||||
Text(
|
Text(
|
||||||
'Your Name',
|
S.of(context)!.yourName,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -512,7 +552,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _ownerNameController,
|
controller: _ownerNameController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Your first name',
|
hintText: S.of(context)!.yourFirstName,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -535,7 +575,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
// Port
|
// Port
|
||||||
Text(
|
Text(
|
||||||
'Port (Optional)',
|
S.of(context)!.portOptional,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -543,7 +583,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Leave blank for reverse proxy or standard ports. Enter 8095 for direct connection.',
|
S.of(context)!.portDescription,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -555,7 +595,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _portController,
|
controller: _portController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'e.g., 8095 or leave blank',
|
hintText: S.of(context)!.portHint,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -600,7 +640,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Detected: $_detectedAuthType',
|
S.of(context)!.detectedAuthType(_detectedAuthType!),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: needsAuth
|
color: needsAuth
|
||||||
? colorScheme.onPrimaryContainer
|
? colorScheme.onPrimaryContainer
|
||||||
@@ -613,7 +653,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'This server requires authentication',
|
S.of(context)!.serverRequiresAuth,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
|
color: colorScheme.onPrimaryContainer.withOpacity(0.7),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -627,12 +667,77 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// HTTP connection info banner
|
||||||
|
if (_resolvedServerUrl != null && _isHttpConnection(_resolvedServerUrl!))
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.secondaryContainer.withOpacity(0.3)
|
||||||
|
: colorScheme.errorContainer.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? null
|
||||||
|
: Border.all(color: colorScheme.error.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? Icons.vpn_lock_rounded
|
||||||
|
: Icons.warning_amber_rounded,
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.secondary
|
||||||
|
: colorScheme.error,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? S.of(context)!.tailscaleVpnConnection
|
||||||
|
: S.of(context)!.unencryptedConnection,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.onSecondaryContainer
|
||||||
|
: colorScheme.onErrorContainer,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? S.of(context)!.usingHttpOverTailscale
|
||||||
|
: _usedHttpFallback
|
||||||
|
? S.of(context)!.httpsFailedUsingHttp
|
||||||
|
: S.of(context)!.httpNotEncrypted,
|
||||||
|
style: TextStyle(
|
||||||
|
color: (_isTailscaleUrl(_serverUrlController.text)
|
||||||
|
? colorScheme.onSecondaryContainer
|
||||||
|
: colorScheme.onErrorContainer).withOpacity(0.7),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Authentication fields (shown only if auth required)
|
// Authentication fields (shown only if auth required)
|
||||||
if (needsAuth) ...[
|
if (needsAuth) ...[
|
||||||
// Auth Server URL (only for Authelia)
|
// Auth Server URL (only for Authelia)
|
||||||
if (_detectedAuthStrategy?.name == 'authelia') ...[
|
if (_detectedAuthStrategy?.name == 'authelia') ...[
|
||||||
Text(
|
Text(
|
||||||
'Auth Server URL (Optional)',
|
S.of(context)!.authServerUrlOptional,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -640,7 +745,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Leave empty if authentication is on the same server',
|
S.of(context)!.authServerUrlDescription,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -652,7 +757,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _authServerUrlController,
|
controller: _authServerUrlController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'e.g., auth.example.com (if different from server)',
|
hintText: S.of(context)!.authServerUrlHint,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -673,7 +778,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Username',
|
S.of(context)!.username,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -686,7 +791,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
focusNode: _usernameFocusNode,
|
focusNode: _usernameFocusNode,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Username',
|
hintText: S.of(context)!.username,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -706,7 +811,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Password',
|
S.of(context)!.password,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -718,7 +823,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Password',
|
hintText: S.of(context)!.password,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -808,9 +913,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text(
|
Text(
|
||||||
'Detecting authentication...',
|
S.of(context)!.detectingAuth,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -827,7 +932,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
_detectedAuthStrategy == null ? 'Detect & Connect' : 'Connect',
|
_detectedAuthStrategy == null ? S.of(context)!.detectAndConnect : S.of(context)!.connect,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/sync_service.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import '../widgets/player_selector.dart';
|
import '../widgets/player_selector.dart';
|
||||||
import '../widgets/album_row.dart';
|
import '../widgets/album_row.dart';
|
||||||
import '../widgets/artist_row.dart';
|
import '../widgets/artist_row.dart';
|
||||||
import '../widgets/track_row.dart';
|
import '../widgets/track_row.dart';
|
||||||
|
import '../widgets/audiobook_row.dart';
|
||||||
|
import '../widgets/series_row.dart';
|
||||||
import '../widgets/common/disconnected_state.dart';
|
import '../widgets/common/disconnected_state.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
@@ -31,8 +35,12 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
bool _showFavoriteAlbums = false;
|
bool _showFavoriteAlbums = false;
|
||||||
bool _showFavoriteArtists = false;
|
bool _showFavoriteArtists = false;
|
||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
// Random order for favorites (generated once per session)
|
// Audiobook rows (default off)
|
||||||
late List<int> _favoritesOrder;
|
bool _showContinueListeningAudiobooks = false;
|
||||||
|
bool _showDiscoverAudiobooks = false;
|
||||||
|
bool _showDiscoverSeries = false;
|
||||||
|
// Row order (loaded from settings)
|
||||||
|
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -41,8 +49,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// Generate random order for favorites rows (0, 1, 2 shuffled)
|
|
||||||
_favoritesOrder = [0, 1, 2]..shuffle();
|
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +73,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
|
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -75,6 +85,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
_showFavoriteAlbums = showFavAlbums;
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
_showFavoriteArtists = showFavArtists;
|
_showFavoriteArtists = showFavArtists;
|
||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||||
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
|
_showDiscoverSeries = showDiscSeries;
|
||||||
|
_homeRowOrder = rowOrder;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +98,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
provider.invalidateHomeCache();
|
provider.invalidateHomeCache();
|
||||||
|
|
||||||
|
// Force full library sync from MA API
|
||||||
|
await provider.forceLibrarySync();
|
||||||
|
|
||||||
// Reload settings in case they changed
|
// Reload settings in case they changed
|
||||||
await _loadSettings();
|
await _loadSettings();
|
||||||
|
|
||||||
@@ -127,6 +144,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
titleSpacing: 0,
|
titleSpacing: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
|
// Sync indicator - shows when library is syncing in background
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: SyncService.instance,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (!SyncService.instance.isSyncing) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Theme.of(context).colorScheme.primary.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
const PlayerSelector(),
|
const PlayerSelector(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -135,19 +174,63 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
selector: (_, p) => p.isConnected,
|
selector: (_, p) => p.isConnected,
|
||||||
builder: (context, isConnected, child) {
|
builder: (context, isConnected, child) {
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
return !isConnected
|
final syncService = SyncService.instance;
|
||||||
? DisconnectedState.full(
|
|
||||||
onSettings: () => Navigator.push(
|
// Show cached data even when not connected (if we have cache)
|
||||||
context,
|
// Only show disconnected state if we have no cached data at all
|
||||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
if (!isConnected && !syncService.hasCache) {
|
||||||
|
return DisconnectedState.full(
|
||||||
|
context: context,
|
||||||
|
onSettings: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Main content
|
||||||
|
RefreshIndicator(
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
child: _buildConnectedView(context, maProvider),
|
||||||
|
),
|
||||||
|
// Connecting banner overlay (doesn't affect layout)
|
||||||
|
// Hide when we have cached players - UI is functional during background reconnect
|
||||||
|
if (!isConnected && syncService.hasCache && !maProvider.hasCachedPlayers)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
color: colorScheme.primaryContainer.withOpacity(0.9),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.connecting,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: RefreshIndicator(
|
],
|
||||||
onRefresh: _onRefresh,
|
);
|
||||||
color: colorScheme.primary,
|
|
||||||
backgroundColor: colorScheme.surface,
|
|
||||||
child: _buildConnectedView(context, maProvider),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -165,9 +248,6 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
final availableHeight = constraints.maxHeight - BottomSpacing.withMiniPlayer;
|
||||||
final rowHeight = availableHeight / 3;
|
final rowHeight = availableHeight / 3;
|
||||||
|
|
||||||
// Build favorites rows in random order
|
|
||||||
final favoritesWidgets = _buildFavoritesRows(provider, rowHeight);
|
|
||||||
|
|
||||||
// Use Android 12+ stretch overscroll effect
|
// Use Android 12+ stretch overscroll effect
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
@@ -192,37 +272,7 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
child: Column(
|
child: Column(
|
||||||
key: _refreshKey,
|
key: _refreshKey,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: _buildOrderedRows(provider, rowHeight),
|
||||||
// Recently played albums (optional)
|
|
||||||
if (_showRecentAlbums)
|
|
||||||
AlbumRow(
|
|
||||||
key: const ValueKey('recent-albums'),
|
|
||||||
title: 'Recently Played',
|
|
||||||
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
|
||||||
rowHeight: rowHeight,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Discover Artists (optional)
|
|
||||||
if (_showDiscoverArtists)
|
|
||||||
ArtistRow(
|
|
||||||
key: const ValueKey('discover-artists'),
|
|
||||||
title: 'Discover Artists',
|
|
||||||
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
|
||||||
rowHeight: rowHeight,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Discover Albums (optional)
|
|
||||||
if (_showDiscoverAlbums)
|
|
||||||
AlbumRow(
|
|
||||||
key: const ValueKey('discover-albums'),
|
|
||||||
title: 'Discover Albums',
|
|
||||||
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
|
||||||
rowHeight: rowHeight,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Favorites rows in random order
|
|
||||||
...favoritesWidgets,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -231,51 +281,101 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build favorites rows in random order (order set once per session)
|
/// Build rows in the user's configured order
|
||||||
List<Widget> _buildFavoritesRows(MusicAssistantProvider provider, double rowHeight) {
|
List<Widget> _buildOrderedRows(MusicAssistantProvider provider, double rowHeight) {
|
||||||
// Create list of enabled favorites with their order index
|
final rows = <Widget>[];
|
||||||
final enabledFavorites = <MapEntry<int, Widget>>[];
|
|
||||||
|
|
||||||
if (_showFavoriteAlbums) {
|
for (final rowId in _homeRowOrder) {
|
||||||
enabledFavorites.add(MapEntry(
|
final widget = _buildRowWidget(rowId, provider, rowHeight);
|
||||||
_favoritesOrder[0],
|
if (widget != null) {
|
||||||
AlbumRow(
|
rows.add(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a single row widget by ID, returns null if row is disabled
|
||||||
|
Widget? _buildRowWidget(String rowId, MusicAssistantProvider provider, double rowHeight) {
|
||||||
|
switch (rowId) {
|
||||||
|
case 'recent-albums':
|
||||||
|
if (!_showRecentAlbums) return null;
|
||||||
|
return AlbumRow(
|
||||||
|
key: const ValueKey('recent-albums'),
|
||||||
|
title: S.of(context)!.recentlyPlayed,
|
||||||
|
loadAlbums: () => provider.getRecentAlbumsWithCache(),
|
||||||
|
getCachedAlbums: () => provider.getCachedRecentAlbums(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'discover-artists':
|
||||||
|
if (!_showDiscoverArtists) return null;
|
||||||
|
return ArtistRow(
|
||||||
|
key: const ValueKey('discover-artists'),
|
||||||
|
title: S.of(context)!.discoverArtists,
|
||||||
|
loadArtists: () => provider.getDiscoverArtistsWithCache(),
|
||||||
|
getCachedArtists: () => provider.getCachedDiscoverArtists(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'discover-albums':
|
||||||
|
if (!_showDiscoverAlbums) return null;
|
||||||
|
return AlbumRow(
|
||||||
|
key: const ValueKey('discover-albums'),
|
||||||
|
title: S.of(context)!.discoverAlbums,
|
||||||
|
loadAlbums: () => provider.getDiscoverAlbumsWithCache(),
|
||||||
|
getCachedAlbums: () => provider.getCachedDiscoverAlbums(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'continue-listening':
|
||||||
|
if (!_showContinueListeningAudiobooks) return null;
|
||||||
|
return AudiobookRow(
|
||||||
|
key: const ValueKey('continue-listening'),
|
||||||
|
title: S.of(context)!.continueListening,
|
||||||
|
loadAudiobooks: () => provider.getInProgressAudiobooks(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'discover-audiobooks':
|
||||||
|
if (!_showDiscoverAudiobooks) return null;
|
||||||
|
return AudiobookRow(
|
||||||
|
key: const ValueKey('discover-audiobooks'),
|
||||||
|
title: S.of(context)!.discoverAudiobooks,
|
||||||
|
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'discover-series':
|
||||||
|
if (!_showDiscoverSeries) return null;
|
||||||
|
return SeriesRow(
|
||||||
|
key: const ValueKey('discover-series'),
|
||||||
|
title: S.of(context)!.discoverSeries,
|
||||||
|
loadSeries: () => provider.getDiscoverSeries(),
|
||||||
|
rowHeight: rowHeight,
|
||||||
|
);
|
||||||
|
case 'favorite-albums':
|
||||||
|
if (!_showFavoriteAlbums) return null;
|
||||||
|
return AlbumRow(
|
||||||
key: const ValueKey('favorite-albums'),
|
key: const ValueKey('favorite-albums'),
|
||||||
title: 'Favorite Albums',
|
title: S.of(context)!.favoriteAlbums,
|
||||||
loadAlbums: () => provider.getFavoriteAlbums(),
|
loadAlbums: () => provider.getFavoriteAlbums(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
),
|
);
|
||||||
));
|
case 'favorite-artists':
|
||||||
}
|
if (!_showFavoriteArtists) return null;
|
||||||
|
return ArtistRow(
|
||||||
if (_showFavoriteArtists) {
|
|
||||||
enabledFavorites.add(MapEntry(
|
|
||||||
_favoritesOrder[1],
|
|
||||||
ArtistRow(
|
|
||||||
key: const ValueKey('favorite-artists'),
|
key: const ValueKey('favorite-artists'),
|
||||||
title: 'Favorite Artists',
|
title: S.of(context)!.favoriteArtists,
|
||||||
loadArtists: () => provider.getFavoriteArtists(),
|
loadArtists: () => provider.getFavoriteArtists(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
),
|
);
|
||||||
));
|
case 'favorite-tracks':
|
||||||
}
|
if (!_showFavoriteTracks) return null;
|
||||||
|
return TrackRow(
|
||||||
if (_showFavoriteTracks) {
|
|
||||||
enabledFavorites.add(MapEntry(
|
|
||||||
_favoritesOrder[2],
|
|
||||||
TrackRow(
|
|
||||||
key: const ValueKey('favorite-tracks'),
|
key: const ValueKey('favorite-tracks'),
|
||||||
title: 'Favorite Tracks',
|
title: S.of(context)!.favoriteTracks,
|
||||||
loadTracks: () => provider.getFavoriteTracks(),
|
loadTracks: () => provider.getFavoriteTracks(),
|
||||||
rowHeight: rowHeight,
|
rowHeight: rowHeight,
|
||||||
),
|
);
|
||||||
));
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by the random order index
|
|
||||||
enabledFavorites.sort((a, b) => a.key.compareTo(b.key));
|
|
||||||
|
|
||||||
return enabledFavorites.map((e) => e.value).toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/recently_played_service.dart';
|
||||||
|
|
||||||
final _playlistLogger = DebugLogger();
|
final _playlistLogger = DebugLogger();
|
||||||
|
|
||||||
@@ -33,21 +35,48 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadTracks() async {
|
Future<void> _loadTracks() async {
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final maProvider = context.read<MusicAssistantProvider>();
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
final tracks = await maProvider.getPlaylistTracksWithCache(
|
final cacheKey = '${widget.provider}_${widget.itemId}';
|
||||||
widget.provider,
|
|
||||||
widget.itemId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
// 1. Show cached data immediately (if available)
|
||||||
setState(() {
|
final cachedTracks = maProvider.getCachedPlaylistTracks(cacheKey);
|
||||||
_tracks = tracks;
|
if (cachedTracks != null && cachedTracks.isNotEmpty) {
|
||||||
_isLoading = false;
|
if (mounted) {
|
||||||
});
|
setState(() {
|
||||||
|
_tracks = cachedTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh data in background (silent refresh)
|
||||||
|
try {
|
||||||
|
final freshTracks = await maProvider.getPlaylistTracksWithCache(
|
||||||
|
widget.provider,
|
||||||
|
widget.itemId,
|
||||||
|
forceRefresh: cachedTracks != null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Update if we got different data
|
||||||
|
if (mounted && freshTracks.isNotEmpty) {
|
||||||
|
final tracksChanged = _tracks.length != freshTracks.length ||
|
||||||
|
(_tracks.isNotEmpty && freshTracks.isNotEmpty &&
|
||||||
|
_tracks.first.itemId != freshTracks.first.itemId);
|
||||||
|
if (tracksChanged || _tracks.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_tracks = freshTracks;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +89,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
// Use the selected player
|
// Use the selected player
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
_showError('No player selected');
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +99,13 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||||
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
||||||
|
|
||||||
|
// Record to local recently played (per-profile)
|
||||||
|
RecentlyPlayedService.instance.recordPlaylistPlayed(widget.playlist);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Playing ${widget.playlist.name}'),
|
content: Text(S.of(context)!.playing(widget.playlist.name)),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -191,7 +223,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isLoading ? null : _playPlaylist,
|
onPressed: _isLoading ? null : _playPlaylist,
|
||||||
icon: const Icon(Icons.play_arrow_rounded),
|
icon: const Icon(Icons.play_arrow_rounded),
|
||||||
label: const Text('Play'),
|
label: Text(S.of(context)!.play),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
foregroundColor: const Color(0xFF1a1a1a),
|
foregroundColor: const Color(0xFF1a1a1a),
|
||||||
@@ -204,9 +236,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// Tracks header
|
// Tracks header
|
||||||
const Text(
|
Text(
|
||||||
'Tracks',
|
S.of(context)!.tracks,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -226,7 +258,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
? SliverFillRemaining(
|
? SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No tracks in playlist',
|
S.of(context)!.noTracksInPlaylist,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -323,7 +355,7 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final player = maProvider.selectedPlayer;
|
final player = maProvider.selectedPlayer;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
_showError('No player selected');
|
_showError(S.of(context)!.noPlayerSelected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,9 +364,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
|||||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: index);
|
await maProvider.playTracks(player.playerId, _tracks, startIndex: index);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Playing track'),
|
content: Text(S.of(context)!.playingTrack),
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../providers/music_assistant_provider.dart';
|
|||||||
import '../models/player.dart';
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
import '../widgets/common/empty_state.dart';
|
import '../widgets/common/empty_state.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class QueueScreen extends StatefulWidget {
|
class QueueScreen extends StatefulWidget {
|
||||||
const QueueScreen({super.key});
|
const QueueScreen({super.key});
|
||||||
@@ -51,7 +52,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_error = 'No player selected';
|
_error = S.of(context)!.noPlayerSelected;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(
|
title: Text(
|
||||||
player != null ? '${player.name} Queue' : 'Queue',
|
player != null ? S.of(context)!.playerQueue(player.name) : S.of(context)!.queue,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -116,7 +117,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _loadQueue,
|
onPressed: _loadQueue,
|
||||||
child: const Text('Retry'),
|
child: Text(S.of(context)!.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -131,7 +132,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]),
|
Icon(Icons.speaker_group, size: 64, color: Colors.grey[700]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No player selected',
|
S.of(context)!.noPlayerSelected,
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
style: TextStyle(color: Colors.grey[600], fontSize: 18),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -140,7 +141,7 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_queue == null || _queue!.items.isEmpty) {
|
if (_queue == null || _queue!.items.isEmpty) {
|
||||||
return EmptyState.queue();
|
return EmptyState.queue(context: context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to show only current and upcoming items (not history)
|
// Filter to show only current and upcoming items (not history)
|
||||||
@@ -148,9 +149,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
final upcomingItems = _queue!.items.sublist(currentIndex);
|
final upcomingItems = _queue!.items.sublist(currentIndex);
|
||||||
|
|
||||||
if (upcomingItems.isEmpty) {
|
if (upcomingItems.isEmpty) {
|
||||||
return const EmptyState(
|
return EmptyState(
|
||||||
icon: Icons.queue_music,
|
icon: Icons.queue_music,
|
||||||
message: 'No upcoming tracks',
|
message: S.of(context)!.noUpcomingTracks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,9 +221,9 @@ class _QueueScreenState extends State<QueueScreen> {
|
|||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Removed ${item.track.name}'),
|
content: Text(S.of(context)!.removedItem(item.track.name)),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'Undo',
|
label: S.of(context)!.undo,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_queue!.items.insert(index, item);
|
_queue!.items.insert(index, item);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../widgets/common/disconnected_state.dart';
|
|||||||
import '../widgets/artist_avatar.dart';
|
import '../widgets/artist_avatar.dart';
|
||||||
import 'album_details_screen.dart';
|
import 'album_details_screen.dart';
|
||||||
import 'artist_details_screen.dart';
|
import 'artist_details_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class SearchScreen extends StatefulWidget {
|
class SearchScreen extends StatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
@@ -140,7 +141,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
_searchError = 'Search failed. Please check your connection.';
|
_searchError = S.of(context)!.searchFailed;
|
||||||
});
|
});
|
||||||
if (keepFocus && _focusNode.hasFocus) {
|
if (keepFocus && _focusNode.hasFocus) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
@@ -166,7 +167,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
cursorColor: colorScheme.primary,
|
cursorColor: colorScheme.primary,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search music...',
|
hintText: S.of(context)!.searchMusic,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
@@ -191,7 +192,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: !maProvider.isConnected
|
body: !maProvider.isConnected
|
||||||
? DisconnectedState.simple()
|
? DisconnectedState.simple(context)
|
||||||
: _buildSearchContent(),
|
: _buildSearchContent(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,12 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
Widget _buildSearchContent() {
|
Widget _buildSearchContent() {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (_isSearching) {
|
// Show cached results even while searching - only show spinner if no cached results
|
||||||
|
final hasCachedResults = _searchResults['artists']?.isNotEmpty == true ||
|
||||||
|
_searchResults['albums']?.isNotEmpty == true ||
|
||||||
|
_searchResults['tracks']?.isNotEmpty == true;
|
||||||
|
|
||||||
|
if (_isSearching && !hasCachedResults) {
|
||||||
return Center(
|
return Center(
|
||||||
child: CircularProgressIndicator(color: colorScheme.primary),
|
child: CircularProgressIndicator(color: colorScheme.primary),
|
||||||
);
|
);
|
||||||
@@ -217,7 +223,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Search for artists, albums, or tracks',
|
S.of(context)!.searchForContent,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onBackground.withOpacity(0.5),
|
color: colorScheme.onBackground.withOpacity(0.5),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -250,7 +256,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () => _performSearch(_searchController.text),
|
onPressed: () => _performSearch(_searchController.text),
|
||||||
child: const Text('Retry'),
|
child: Text(S.of(context)!.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -264,7 +270,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty;
|
||||||
|
|
||||||
if (!hasResults) {
|
if (!hasResults) {
|
||||||
return EmptyState.search();
|
return EmptyState.search(context: context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -275,18 +281,18 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_buildFilterChip('All', 'all'),
|
_buildFilterChip(S.of(context)!.all, 'all'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (artists.isNotEmpty) ...[
|
if (artists.isNotEmpty) ...[
|
||||||
_buildFilterChip('Artists', 'artists'),
|
_buildFilterChip(S.of(context)!.artists, 'artists'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (albums.isNotEmpty) ...[
|
if (albums.isNotEmpty) ...[
|
||||||
_buildFilterChip('Albums', 'albums'),
|
_buildFilterChip(S.of(context)!.albums, 'albums'),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
if (tracks.isNotEmpty) ...[
|
if (tracks.isNotEmpty) ...[
|
||||||
_buildFilterChip('Tracks', 'tracks'),
|
_buildFilterChip(S.of(context)!.tracks, 'tracks'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -336,7 +342,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// Add artists section
|
// Add artists section
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
|
if ((_activeFilter == 'all' || _activeFilter == 'artists') && artists.isNotEmpty) {
|
||||||
if (_activeFilter == 'all') {
|
if (_activeFilter == 'all') {
|
||||||
items.add(_ListItem.header('Artists', artists.length));
|
items.add(_ListItem.header(S.of(context)!.artists, artists.length));
|
||||||
}
|
}
|
||||||
for (final artist in artists) {
|
for (final artist in artists) {
|
||||||
items.add(_ListItem.artist(artist));
|
items.add(_ListItem.artist(artist));
|
||||||
@@ -347,7 +353,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// Add albums section
|
// Add albums section
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
|
if ((_activeFilter == 'all' || _activeFilter == 'albums') && albums.isNotEmpty) {
|
||||||
if (_activeFilter == 'all') {
|
if (_activeFilter == 'all') {
|
||||||
items.add(_ListItem.header('Albums', albums.length));
|
items.add(_ListItem.header(S.of(context)!.albums, albums.length));
|
||||||
}
|
}
|
||||||
for (final album in albums) {
|
for (final album in albums) {
|
||||||
items.add(_ListItem.album(album));
|
items.add(_ListItem.album(album));
|
||||||
@@ -358,7 +364,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
// Add tracks section
|
// Add tracks section
|
||||||
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
|
if ((_activeFilter == 'all' || _activeFilter == 'tracks') && tracks.isNotEmpty) {
|
||||||
if (_activeFilter == 'all') {
|
if (_activeFilter == 'all') {
|
||||||
items.add(_ListItem.header('Tracks', tracks.length));
|
items.add(_ListItem.header(S.of(context)!.tracks, tracks.length));
|
||||||
}
|
}
|
||||||
for (final track in tracks) {
|
for (final track in tracks) {
|
||||||
items.add(_ListItem.track(track));
|
items.add(_ListItem.track(track));
|
||||||
@@ -428,7 +434,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Artist',
|
S.of(context)!.artist,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -472,7 +478,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
album.name,
|
album.nameWithYear,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -490,7 +496,10 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => AlbumDetailsScreen(album: album),
|
builder: (context) => AlbumDetailsScreen(
|
||||||
|
album: album,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -556,7 +565,7 @@ class SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
if (maProvider.selectedPlayer == null) {
|
if (maProvider.selectedPlayer == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('No player selected')),
|
SnackBar(content: Text(S.of(context)!.noPlayerSelected)),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/locale_provider.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/music_assistant_api.dart';
|
import '../services/music_assistant_api.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/global_player_overlay.dart';
|
import '../widgets/global_player_overlay.dart';
|
||||||
import 'debug_log_screen.dart';
|
import 'debug_log_screen.dart';
|
||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
@@ -26,6 +29,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showFavoriteAlbums = false;
|
bool _showFavoriteAlbums = false;
|
||||||
bool _showFavoriteArtists = false;
|
bool _showFavoriteArtists = false;
|
||||||
bool _showFavoriteTracks = false;
|
bool _showFavoriteTracks = false;
|
||||||
|
// Audiobook home rows (default off)
|
||||||
|
bool _showContinueListeningAudiobooks = false;
|
||||||
|
bool _showDiscoverAudiobooks = false;
|
||||||
|
bool _showDiscoverSeries = false;
|
||||||
|
// Home row order
|
||||||
|
List<String> _homeRowOrder = List.from(SettingsService.defaultHomeRowOrder);
|
||||||
|
// Audiobook libraries
|
||||||
|
List<Map<String, String>> _discoveredLibraries = [];
|
||||||
|
Map<String, bool> _libraryEnabled = {};
|
||||||
|
// Player settings
|
||||||
|
bool _preferLocalPlayer = false;
|
||||||
|
bool _smartSortPlayers = false;
|
||||||
|
// Hint settings
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -51,6 +68,32 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||||
|
|
||||||
|
// Load audiobook home row settings
|
||||||
|
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||||
|
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||||
|
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||||
|
|
||||||
|
// Load home row order
|
||||||
|
final rowOrder = await SettingsService.getHomeRowOrder();
|
||||||
|
|
||||||
|
// Load audiobook library settings
|
||||||
|
final discovered = await SettingsService.getDiscoveredAbsLibraries() ?? [];
|
||||||
|
final enabled = await SettingsService.getEnabledAbsLibraries();
|
||||||
|
final libraryEnabled = <String, bool>{};
|
||||||
|
for (final lib in discovered) {
|
||||||
|
final path = lib['path'] ?? '';
|
||||||
|
// null means all enabled
|
||||||
|
libraryEnabled[path] = enabled == null || enabled.contains(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load player settings
|
||||||
|
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||||
|
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
final showHints = await SettingsService.getShowHints();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showRecentAlbums = showRecent;
|
_showRecentAlbums = showRecent;
|
||||||
@@ -59,6 +102,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_showFavoriteAlbums = showFavAlbums;
|
_showFavoriteAlbums = showFavAlbums;
|
||||||
_showFavoriteArtists = showFavArtists;
|
_showFavoriteArtists = showFavArtists;
|
||||||
_showFavoriteTracks = showFavTracks;
|
_showFavoriteTracks = showFavTracks;
|
||||||
|
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||||
|
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||||
|
_showDiscoverSeries = showDiscSeries;
|
||||||
|
_homeRowOrder = rowOrder;
|
||||||
|
_discoveredLibraries = discovered;
|
||||||
|
_libraryEnabled = libraryEnabled;
|
||||||
|
_preferLocalPlayer = preferLocal;
|
||||||
|
_smartSortPlayers = smartSort;
|
||||||
|
_showHints = showHints;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +122,103 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to get row display info
|
||||||
|
Map<String, String> _getRowInfo(String rowId) {
|
||||||
|
final s = S.of(context)!;
|
||||||
|
switch (rowId) {
|
||||||
|
case 'recent-albums':
|
||||||
|
return {'title': s.recentlyPlayed, 'subtitle': s.showRecentlyPlayedAlbums};
|
||||||
|
case 'discover-artists':
|
||||||
|
return {'title': s.discoverArtists, 'subtitle': s.showRandomArtists};
|
||||||
|
case 'discover-albums':
|
||||||
|
return {'title': s.discoverAlbums, 'subtitle': s.showRandomAlbums};
|
||||||
|
case 'continue-listening':
|
||||||
|
return {'title': s.continueListening, 'subtitle': s.showAudiobooksInProgress};
|
||||||
|
case 'discover-audiobooks':
|
||||||
|
return {'title': s.discoverAudiobooks, 'subtitle': s.showRandomAudiobooks};
|
||||||
|
case 'discover-series':
|
||||||
|
return {'title': s.discoverSeries, 'subtitle': s.showRandomSeries};
|
||||||
|
case 'favorite-albums':
|
||||||
|
return {'title': s.favoriteAlbums, 'subtitle': s.showFavoriteAlbums};
|
||||||
|
case 'favorite-artists':
|
||||||
|
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
||||||
|
case 'favorite-tracks':
|
||||||
|
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
||||||
|
default:
|
||||||
|
return {'title': rowId, 'subtitle': ''};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get row enabled state
|
||||||
|
bool _getRowEnabled(String rowId) {
|
||||||
|
switch (rowId) {
|
||||||
|
case 'recent-albums':
|
||||||
|
return _showRecentAlbums;
|
||||||
|
case 'discover-artists':
|
||||||
|
return _showDiscoverArtists;
|
||||||
|
case 'discover-albums':
|
||||||
|
return _showDiscoverAlbums;
|
||||||
|
case 'continue-listening':
|
||||||
|
return _showContinueListeningAudiobooks;
|
||||||
|
case 'discover-audiobooks':
|
||||||
|
return _showDiscoverAudiobooks;
|
||||||
|
case 'discover-series':
|
||||||
|
return _showDiscoverSeries;
|
||||||
|
case 'favorite-albums':
|
||||||
|
return _showFavoriteAlbums;
|
||||||
|
case 'favorite-artists':
|
||||||
|
return _showFavoriteArtists;
|
||||||
|
case 'favorite-tracks':
|
||||||
|
return _showFavoriteTracks;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set row enabled state
|
||||||
|
void _setRowEnabled(String rowId, bool value) {
|
||||||
|
setState(() {
|
||||||
|
switch (rowId) {
|
||||||
|
case 'recent-albums':
|
||||||
|
_showRecentAlbums = value;
|
||||||
|
SettingsService.setShowRecentAlbums(value);
|
||||||
|
break;
|
||||||
|
case 'discover-artists':
|
||||||
|
_showDiscoverArtists = value;
|
||||||
|
SettingsService.setShowDiscoverArtists(value);
|
||||||
|
break;
|
||||||
|
case 'discover-albums':
|
||||||
|
_showDiscoverAlbums = value;
|
||||||
|
SettingsService.setShowDiscoverAlbums(value);
|
||||||
|
break;
|
||||||
|
case 'continue-listening':
|
||||||
|
_showContinueListeningAudiobooks = value;
|
||||||
|
SettingsService.setShowContinueListeningAudiobooks(value);
|
||||||
|
break;
|
||||||
|
case 'discover-audiobooks':
|
||||||
|
_showDiscoverAudiobooks = value;
|
||||||
|
SettingsService.setShowDiscoverAudiobooks(value);
|
||||||
|
break;
|
||||||
|
case 'discover-series':
|
||||||
|
_showDiscoverSeries = value;
|
||||||
|
SettingsService.setShowDiscoverSeries(value);
|
||||||
|
break;
|
||||||
|
case 'favorite-albums':
|
||||||
|
_showFavoriteAlbums = value;
|
||||||
|
SettingsService.setShowFavoriteAlbums(value);
|
||||||
|
break;
|
||||||
|
case 'favorite-artists':
|
||||||
|
_showFavoriteArtists = value;
|
||||||
|
SettingsService.setShowFavoriteArtists(value);
|
||||||
|
break;
|
||||||
|
case 'favorite-tracks':
|
||||||
|
_showFavoriteTracks = value;
|
||||||
|
SettingsService.setShowFavoriteTracks(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _disconnect() async {
|
Future<void> _disconnect() async {
|
||||||
final provider = context.read<MusicAssistantProvider>();
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
await provider.disconnect();
|
await provider.disconnect();
|
||||||
@@ -103,7 +252,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Settings',
|
S.of(context)!.settings,
|
||||||
style: textTheme.titleLarge?.copyWith(
|
style: textTheme.titleLarge?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
@@ -122,7 +271,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
tooltip: 'Debug Logs',
|
tooltip: S.of(context)!.debugLogs,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -178,9 +327,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
child: FilledButton.tonalIcon(
|
child: FilledButton.tonalIcon(
|
||||||
onPressed: _disconnect,
|
onPressed: _disconnect,
|
||||||
icon: const Icon(Icons.logout_rounded),
|
icon: const Icon(Icons.logout_rounded),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'Disconnect',
|
S.of(context)!.disconnect,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -199,7 +348,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
// Theme section
|
// Theme section
|
||||||
Text(
|
Text(
|
||||||
'Theme',
|
S.of(context)!.theme,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -210,14 +359,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
color: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Theme Mode',
|
S.of(context)!.themeMode,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -228,21 +377,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: SegmentedButton<ThemeMode>(
|
child: SegmentedButton<ThemeMode>(
|
||||||
segments: const [
|
segments: [
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.light,
|
value: ThemeMode.light,
|
||||||
label: Text('Light'),
|
label: Text(S.of(context)!.light),
|
||||||
icon: Icon(Icons.light_mode_rounded),
|
icon: const Icon(Icons.light_mode_rounded),
|
||||||
),
|
),
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.dark,
|
value: ThemeMode.dark,
|
||||||
label: Text('Dark'),
|
label: Text(S.of(context)!.dark),
|
||||||
icon: Icon(Icons.dark_mode_rounded),
|
icon: const Icon(Icons.dark_mode_rounded),
|
||||||
),
|
),
|
||||||
ButtonSegment<ThemeMode>(
|
ButtonSegment<ThemeMode>(
|
||||||
value: ThemeMode.system,
|
value: ThemeMode.system,
|
||||||
label: Text('System'),
|
label: Text(S.of(context)!.system),
|
||||||
icon: Icon(Icons.auto_mode_rounded),
|
icon: const Icon(Icons.auto_mode_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {themeProvider.themeMode},
|
selected: {themeProvider.themeMode},
|
||||||
@@ -254,7 +403,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return colorScheme.primaryContainer;
|
return colorScheme.primaryContainer;
|
||||||
}
|
}
|
||||||
return colorScheme.surfaceVariant.withOpacity(0.3);
|
return colorScheme.surfaceVariant.withOpacity(0.5);
|
||||||
}),
|
}),
|
||||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
@@ -263,6 +412,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return colorScheme.onSurfaceVariant;
|
return colorScheme.onSurfaceVariant;
|
||||||
}),
|
}),
|
||||||
side: WidgetStateProperty.all(BorderSide.none),
|
side: WidgetStateProperty.all(BorderSide.none),
|
||||||
|
shape: WidgetStateProperty.all(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -274,6 +426,67 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Accent color picker
|
||||||
|
Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, _) {
|
||||||
|
final isDisabled = themeProvider.useMaterialTheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
S.of(context)!.accentColor,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurface.withOpacity(0.4)
|
||||||
|
: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: accentColorOptions.map((color) {
|
||||||
|
final isSelected = themeProvider.customColor.value == color.value;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: isDisabled ? null : () {
|
||||||
|
themeProvider.setCustomColor(color);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDisabled ? color.withOpacity(0.3) : color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: isSelected && !isDisabled
|
||||||
|
? Border.all(color: colorScheme.onSurface, width: 3)
|
||||||
|
: null,
|
||||||
|
boxShadow: isSelected && !isDisabled
|
||||||
|
? [BoxShadow(color: color.withOpacity(0.5), blurRadius: 8, spreadRadius: 2)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: isSelected && !isDisabled
|
||||||
|
? Icon(Icons.check, color: Colors.white, size: 20)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
Consumer<ThemeProvider>(
|
Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -284,11 +497,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Material You',
|
S.of(context)!.materialYou,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Use system colors (Android 12+)',
|
S.of(context)!.materialYouDescription,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
value: themeProvider.useMaterialTheme,
|
value: themeProvider.useMaterialTheme,
|
||||||
@@ -314,11 +527,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Adaptive Theme',
|
S.of(context)!.adaptiveTheme,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Extract colors from album and artist artwork',
|
S.of(context)!.extractColorsFromArtwork,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
),
|
),
|
||||||
value: themeProvider.adaptiveTheme,
|
value: themeProvider.adaptiveTheme,
|
||||||
@@ -334,9 +547,167 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Home Screen section
|
// Language section
|
||||||
Text(
|
Text(
|
||||||
'Home Screen',
|
S.of(context)!.language,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Consumer<LocaleProvider>(
|
||||||
|
builder: (context, localeProvider, _) {
|
||||||
|
String getLanguageName(String? code) {
|
||||||
|
switch (code) {
|
||||||
|
case 'en':
|
||||||
|
return 'English';
|
||||||
|
case 'de':
|
||||||
|
return 'Deutsch';
|
||||||
|
case 'es':
|
||||||
|
return 'Español';
|
||||||
|
default:
|
||||||
|
return S.of(context)!.system;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
tileColor: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
|
leading: Icon(Icons.language_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
title: Text(S.of(context)!.language),
|
||||||
|
subtitle: Text(
|
||||||
|
getLanguageName(localeProvider.locale?.languageCode),
|
||||||
|
style: TextStyle(color: colorScheme.primary),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
onTap: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(S.of(context)!.language),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RadioListTile<String?>(
|
||||||
|
title: Text(S.of(context)!.system),
|
||||||
|
subtitle: const Text('Auto'),
|
||||||
|
value: null,
|
||||||
|
groupValue: localeProvider.locale?.languageCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
localeProvider.setLocale(null);
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String?>(
|
||||||
|
title: const Text('English'),
|
||||||
|
value: 'en',
|
||||||
|
groupValue: localeProvider.locale?.languageCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
localeProvider.setLocale(const Locale('en'));
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String?>(
|
||||||
|
title: const Text('Deutsch'),
|
||||||
|
value: 'de',
|
||||||
|
groupValue: localeProvider.locale?.languageCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
localeProvider.setLocale(const Locale('de'));
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String?>(
|
||||||
|
title: const Text('Español'),
|
||||||
|
value: 'es',
|
||||||
|
groupValue: localeProvider.locale?.languageCode,
|
||||||
|
onChanged: (value) {
|
||||||
|
localeProvider.setLocale(const Locale('es'));
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Player section
|
||||||
|
Text(
|
||||||
|
S.of(context)!.players,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
S.of(context)!.preferLocalPlayer,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
S.of(context)!.preferLocalPlayerDescription,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _preferLocalPlayer,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _preferLocalPlayer = value);
|
||||||
|
SettingsService.setPreferLocalPlayer(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
S.of(context)!.smartSortPlayers,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
S.of(context)!.smartSortPlayersDescription,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: _smartSortPlayers,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _smartSortPlayers = value);
|
||||||
|
SettingsService.setSmartSortPlayers(value);
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Hints & Tips section
|
||||||
|
Text(
|
||||||
|
S.of(context)!.hintsAndTips,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -344,159 +715,124 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Choose which rows to display on the home screen',
|
S.of(context)!.showHintsDescription,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Main rows section
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: SwitchListTile(
|
||||||
children: [
|
title: Text(
|
||||||
SwitchListTile(
|
S.of(context)!.showHints,
|
||||||
title: Text(
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
'Recently Played',
|
),
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
value: _showHints,
|
||||||
),
|
onChanged: (value) {
|
||||||
subtitle: Text(
|
setState(() => _showHints = value);
|
||||||
'Show recently played albums',
|
SettingsService.setShowHints(value);
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
},
|
||||||
),
|
activeColor: colorScheme.primary,
|
||||||
value: _showRecentAlbums,
|
contentPadding: EdgeInsets.zero,
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showRecentAlbums = value);
|
|
||||||
SettingsService.setShowRecentAlbums(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
|
||||||
SwitchListTile(
|
|
||||||
title: Text(
|
|
||||||
'Discover Artists',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show random artists to discover',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
|
||||||
),
|
|
||||||
value: _showDiscoverArtists,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showDiscoverArtists = value);
|
|
||||||
SettingsService.setShowDiscoverArtists(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
|
||||||
SwitchListTile(
|
|
||||||
title: Text(
|
|
||||||
'Discover Albums',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show random albums to discover',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
|
||||||
),
|
|
||||||
value: _showDiscoverAlbums,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showDiscoverAlbums = value);
|
|
||||||
SettingsService.setShowDiscoverAlbums(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Home Screen section
|
||||||
Text(
|
Text(
|
||||||
'Favorites',
|
S.of(context)!.homeScreen,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.chooseHomeScreenRows,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Favorites rows section
|
// Reorderable home rows list
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
clipBehavior: Clip.antiAlias,
|
||||||
children: [
|
child: ReorderableListView.builder(
|
||||||
SwitchListTile(
|
shrinkWrap: true,
|
||||||
title: Text(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
'Favorite Albums',
|
buildDefaultDragHandles: false,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
itemCount: _homeRowOrder.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
setState(() {
|
||||||
|
if (newIndex > oldIndex) newIndex--;
|
||||||
|
final item = _homeRowOrder.removeAt(oldIndex);
|
||||||
|
_homeRowOrder.insert(newIndex, item);
|
||||||
|
});
|
||||||
|
SettingsService.setHomeRowOrder(_homeRowOrder);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final rowId = _homeRowOrder[index];
|
||||||
|
final rowInfo = _getRowInfo(rowId);
|
||||||
|
final isEnabled = _getRowEnabled(rowId);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(rowId),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: index < _homeRowOrder.length - 1
|
||||||
|
? Border(bottom: BorderSide(color: colorScheme.outline.withOpacity(0.2)))
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
child: Row(
|
||||||
'Show a row of your favorite albums',
|
children: [
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||||
|
child: Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
rowInfo['title']!,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
rowInfo['subtitle']!,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||||
|
),
|
||||||
|
value: isEnabled,
|
||||||
|
onChanged: (value) => _setRowEnabled(rowId, value),
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
contentPadding: const EdgeInsets.only(right: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
value: _showFavoriteAlbums,
|
);
|
||||||
onChanged: (value) {
|
},
|
||||||
setState(() => _showFavoriteAlbums = value);
|
|
||||||
SettingsService.setShowFavoriteAlbums(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
|
||||||
SwitchListTile(
|
|
||||||
title: Text(
|
|
||||||
'Favorite Artists',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show a row of your favorite artists',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
|
||||||
),
|
|
||||||
value: _showFavoriteArtists,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showFavoriteArtists = value);
|
|
||||||
SettingsService.setShowFavoriteArtists(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
|
||||||
SwitchListTile(
|
|
||||||
title: Text(
|
|
||||||
'Favorite Tracks',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show a row of your favorite tracks',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
|
||||||
),
|
|
||||||
value: _showFavoriteTracks,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() => _showFavoriteTracks = value);
|
|
||||||
SettingsService.setShowFavoriteTracks(value);
|
|
||||||
},
|
|
||||||
activeColor: colorScheme.primary,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Metadata APIs (Optional)',
|
S.of(context)!.metadataApis,
|
||||||
style: textTheme.titleMedium?.copyWith(
|
style: textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onBackground,
|
color: colorScheme.onBackground,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -504,7 +840,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Artist images are automatically fetched from Deezer. Add API keys below for artist biographies and album descriptions.',
|
S.of(context)!.metadataApisDescription,
|
||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onBackground.withOpacity(0.6),
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
),
|
),
|
||||||
@@ -515,8 +851,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
controller: _lastFmApiKeyController,
|
controller: _lastFmApiKeyController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Last.fm API Key',
|
labelText: S.of(context)!.lastFmApiKey,
|
||||||
hintText: 'Get free key at last.fm/api',
|
hintText: S.of(context)!.lastFmApiKeyHint,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -552,8 +888,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
controller: _audioDbApiKeyController,
|
controller: _audioDbApiKeyController,
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'TheAudioDB API Key',
|
labelText: S.of(context)!.theAudioDbApiKey,
|
||||||
hintText: 'Use "2" for free tier or premium key',
|
hintText: S.of(context)!.theAudioDbApiKeyHint,
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
hintStyle: TextStyle(color: colorScheme.onSurface.withOpacity(0.38)),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
@@ -585,6 +921,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Audiobook Libraries section
|
||||||
|
if (_discoveredLibraries.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
S.of(context)!.audiobookLibraries,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.chooseAudiobookLibraries,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onBackground.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: _discoveredLibraries.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final library = entry.value;
|
||||||
|
final path = library['path'] ?? '';
|
||||||
|
final name = library['name'] ?? S.of(context)!.unknownLibrary;
|
||||||
|
final isEnabled = _libraryEnabled[path] ?? true;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
value: isEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_libraryEnabled[path] = value;
|
||||||
|
});
|
||||||
|
await SettingsService.toggleAbsLibrary(path, value);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(S.of(context)!.pullToRefresh),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activeColor: colorScheme.primary,
|
||||||
|
),
|
||||||
|
if (index < _discoveredLibraries.length - 1)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
indent: 16,
|
||||||
|
endIndent: 16,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -598,7 +1004,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.bug_report_rounded),
|
icon: const Icon(Icons.bug_report_rounded),
|
||||||
label: const Text('View Debug Logs'),
|
label: Text(S.of(context)!.viewDebugLogs),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
|
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
@@ -647,17 +1053,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getStatusText(MAConnectionState state) {
|
String _getStatusText(MAConnectionState state) {
|
||||||
|
final s = S.of(context)!;
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case MAConnectionState.connected:
|
case MAConnectionState.connected:
|
||||||
case MAConnectionState.authenticated:
|
case MAConnectionState.authenticated:
|
||||||
return 'Connected';
|
return s.connected;
|
||||||
case MAConnectionState.connecting:
|
case MAConnectionState.connecting:
|
||||||
case MAConnectionState.authenticating:
|
case MAConnectionState.authenticating:
|
||||||
return 'Connecting...';
|
return s.connecting;
|
||||||
case MAConnectionState.error:
|
case MAConnectionState.error:
|
||||||
return 'Connection Error';
|
return s.connectionError;
|
||||||
case MAConnectionState.disconnected:
|
case MAConnectionState.disconnected:
|
||||||
return 'Disconnected';
|
return s.disconnected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'auth_strategy.dart';
|
import 'auth_strategy.dart';
|
||||||
import 'no_auth_strategy.dart';
|
import 'no_auth_strategy.dart';
|
||||||
@@ -31,11 +32,42 @@ class AuthManager {
|
|||||||
|
|
||||||
/// Auto-detect authentication requirements for a server
|
/// Auto-detect authentication requirements for a server
|
||||||
/// Returns null if server is unreachable or auth method unknown
|
/// Returns null if server is unreachable or auth method unknown
|
||||||
|
/// Also returns the final URL used (in case of HTTP fallback) via the callback
|
||||||
|
String? _lastSuccessfulUrl;
|
||||||
|
String? get lastSuccessfulUrl => _lastSuccessfulUrl;
|
||||||
|
|
||||||
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
Future<AuthStrategy?> detectAuthStrategy(String serverUrl) async {
|
||||||
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
||||||
|
_lastSuccessfulUrl = null;
|
||||||
|
|
||||||
// Normalize URL
|
// Normalize URL
|
||||||
var baseUrl = serverUrl;
|
var baseUrl = _normalizeUrl(serverUrl);
|
||||||
|
_logger.log('Normalized URL: $baseUrl');
|
||||||
|
|
||||||
|
// Try detection with the initial URL
|
||||||
|
var result = await _tryDetectAuth(baseUrl);
|
||||||
|
|
||||||
|
// If HTTPS failed and we haven't tried HTTP yet, try HTTP fallback
|
||||||
|
if (result == null && baseUrl.startsWith('https://')) {
|
||||||
|
final httpUrl = baseUrl.replaceFirst('https://', 'http://');
|
||||||
|
_logger.log('🔄 HTTPS failed, trying HTTP fallback: $httpUrl');
|
||||||
|
result = await _tryDetectAuth(httpUrl);
|
||||||
|
if (result != null) {
|
||||||
|
baseUrl = httpUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
_lastSuccessfulUrl = baseUrl;
|
||||||
|
_logger.log('✅ Auth detection successful with URL: $baseUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a server URL with appropriate protocol
|
||||||
|
String _normalizeUrl(String url) {
|
||||||
|
var baseUrl = url;
|
||||||
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
// Use http for local IPs, https for domains
|
// Use http for local IPs, https for domains
|
||||||
if (baseUrl.startsWith('192.') ||
|
if (baseUrl.startsWith('192.') ||
|
||||||
@@ -44,13 +76,38 @@ class AuthManager {
|
|||||||
baseUrl == 'localhost' ||
|
baseUrl == 'localhost' ||
|
||||||
baseUrl.startsWith('127.')) {
|
baseUrl.startsWith('127.')) {
|
||||||
baseUrl = 'http://$baseUrl';
|
baseUrl = 'http://$baseUrl';
|
||||||
|
} else if (baseUrl.endsWith('.ts.net') || baseUrl.contains('.ts.net:')) {
|
||||||
|
// Tailscale URLs - default to http:// since VPN tunnel is already encrypted
|
||||||
|
baseUrl = 'http://$baseUrl';
|
||||||
|
_logger.log('🔗 Detected Tailscale URL, using HTTP');
|
||||||
} else {
|
} else {
|
||||||
baseUrl = 'https://$baseUrl';
|
baseUrl = 'https://$baseUrl';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a hostname is a local/private IP address
|
||||||
|
bool _isLocalIpAddress(String host) {
|
||||||
|
return host.startsWith('192.168.') ||
|
||||||
|
host.startsWith('10.') ||
|
||||||
|
host.startsWith('172.16.') ||
|
||||||
|
host.startsWith('172.17.') ||
|
||||||
|
host.startsWith('172.18.') ||
|
||||||
|
host.startsWith('172.19.') ||
|
||||||
|
host.startsWith('172.2') ||
|
||||||
|
host.startsWith('172.30.') ||
|
||||||
|
host.startsWith('172.31.') ||
|
||||||
|
host == 'localhost' ||
|
||||||
|
host.startsWith('127.') ||
|
||||||
|
host.endsWith('.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to detect auth strategy for a given URL
|
||||||
|
/// Returns the strategy if successful, null if connection failed
|
||||||
|
Future<AuthStrategy?> _tryDetectAuth(String baseUrl) async {
|
||||||
// First, check if this is a Music Assistant server with native auth
|
// First, check if this is a Music Assistant server with native auth
|
||||||
_logger.log('Checking for Music Assistant native auth...');
|
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
|
||||||
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||||
if (maAuthResult != null) {
|
if (maAuthResult != null) {
|
||||||
if (maAuthResult == 'required') {
|
if (maAuthResult == 'required') {
|
||||||
@@ -124,7 +181,7 @@ class AuthManager {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('✗ Auth detection error: $e');
|
_logger.log('✗ Auth detection error: $e');
|
||||||
return null; // Return null instead of rethrow to show generic error
|
return null; // Return null to trigger fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('⚠️ Could not determine auth method');
|
_logger.log('⚠️ Could not determine auth method');
|
||||||
@@ -136,24 +193,61 @@ class AuthManager {
|
|||||||
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
|
Future<String?> _checkMusicAssistantAuth(String baseUrl) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(baseUrl);
|
final uri = Uri.parse(baseUrl);
|
||||||
|
// Use the port from URL if specified, otherwise use default ports (80/443)
|
||||||
|
// Don't default to 8095 here - that's only for direct connections without reverse proxy
|
||||||
final apiUrl = Uri(
|
final apiUrl = Uri(
|
||||||
scheme: uri.scheme,
|
scheme: uri.scheme,
|
||||||
host: uri.host,
|
host: uri.host,
|
||||||
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? null : 8095),
|
port: uri.hasPort ? uri.port : null,
|
||||||
path: '/api',
|
path: '/api',
|
||||||
);
|
);
|
||||||
|
|
||||||
_logger.log('Checking MA API at: $apiUrl');
|
_logger.log('Checking MA API at: $apiUrl');
|
||||||
|
|
||||||
// Try to get server info - this tells us if auth is required
|
// Use IOClient to control redirect behavior for local IPs
|
||||||
// Note: The command is just 'info', not 'server/info'
|
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||||
final response = await http.post(
|
http.Response response;
|
||||||
apiUrl,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
if (isLocalIp) {
|
||||||
body: jsonEncode({
|
// For local IPs, don't follow redirects - handle them explicitly
|
||||||
'command': 'info',
|
final client = HttpClient();
|
||||||
}),
|
try {
|
||||||
).timeout(const Duration(seconds: 5));
|
final request = await client.postUrl(apiUrl);
|
||||||
|
request.followRedirects = false;
|
||||||
|
request.headers.set('Content-Type', 'application/json');
|
||||||
|
request.write(jsonEncode({'command': 'info'}));
|
||||||
|
final ioResponse = await request.close().timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
// Check for redirect to HTTPS
|
||||||
|
if (ioResponse.statusCode == 308 || ioResponse.statusCode == 301 || ioResponse.statusCode == 302) {
|
||||||
|
final location = ioResponse.headers.value('location');
|
||||||
|
_logger.log('Local IP redirects to: $location');
|
||||||
|
if (location != null && location.startsWith('https://')) {
|
||||||
|
_logger.log('⚠️ Server redirects HTTP to HTTPS - this may cause certificate issues for local IP');
|
||||||
|
// Don't follow the redirect - it will likely fail with cert error
|
||||||
|
// Instead, return null to trigger fallback logic
|
||||||
|
client.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = await ioResponse.transform(utf8.decoder).join();
|
||||||
|
response = http.Response(body, ioResponse.statusCode);
|
||||||
|
client.close();
|
||||||
|
} catch (e) {
|
||||||
|
client.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-local URLs, use standard http.post which follows redirects
|
||||||
|
response = await http.post(
|
||||||
|
apiUrl,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'command': 'info',
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
|
||||||
_logger.log('MA API response: ${response.statusCode}');
|
_logger.log('MA API response: ${response.statusCode}');
|
||||||
_logger.log('MA API body: ${response.body}');
|
_logger.log('MA API body: ${response.body}');
|
||||||
@@ -213,21 +307,47 @@ class AuthManager {
|
|||||||
/// Test if server is accessible without authentication
|
/// Test if server is accessible without authentication
|
||||||
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
Future<bool> _canConnectWithoutAuth(String serverUrl) async {
|
||||||
try {
|
try {
|
||||||
// Test root endpoint - Music Assistant doesn't have /api/info
|
final uri = Uri.parse(serverUrl);
|
||||||
final testUrl = serverUrl;
|
_logger.log('No-auth test URL: $serverUrl');
|
||||||
_logger.log('No-auth test URL: $testUrl');
|
|
||||||
|
|
||||||
final response = await http.get(
|
final isLocalIp = _isLocalIpAddress(uri.host);
|
||||||
Uri.parse(testUrl),
|
int statusCode;
|
||||||
// Don't follow redirects - we want to see if server requires auth
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
_logger.log('No-auth status: ${response.statusCode}');
|
if (isLocalIp) {
|
||||||
|
// For local IPs, don't follow redirects to avoid HTTPS cert issues
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final request = await client.getUrl(uri);
|
||||||
|
request.followRedirects = false;
|
||||||
|
final response = await request.close().timeout(const Duration(seconds: 10));
|
||||||
|
statusCode = response.statusCode;
|
||||||
|
|
||||||
|
// Check for redirect to HTTPS
|
||||||
|
if (statusCode == 308 || statusCode == 301 || statusCode == 302) {
|
||||||
|
final location = response.headers.value('location');
|
||||||
|
if (location != null && location.startsWith('https://')) {
|
||||||
|
_logger.log('⚠️ Local IP redirects to HTTPS: $location');
|
||||||
|
client.close();
|
||||||
|
return false; // Can't connect - would need HTTPS which has cert issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.close();
|
||||||
|
} catch (e) {
|
||||||
|
client.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-local URLs, use standard http.get
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 10));
|
||||||
|
statusCode = response.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('No-auth status: $statusCode');
|
||||||
|
|
||||||
// 200 = no auth required
|
// 200 = no auth required
|
||||||
// 302/307 = redirect (probably to auth)
|
// 302/307/308 = redirect (probably to auth or HTTPS)
|
||||||
// 401 = auth required
|
// 401 = auth required
|
||||||
if (response.statusCode == 200) {
|
if (statusCode == 200) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
|
import '../models/player.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
|
import '../services/database_service.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
|
|
||||||
/// Centralized cache service for all app data caching
|
/// Centralized cache service for all app data caching
|
||||||
/// This is a non-notifying service - it just stores and retrieves cached data
|
/// This is a non-notifying service - it just stores and retrieves cached data
|
||||||
class CacheService {
|
class CacheService {
|
||||||
final DebugLogger _logger = DebugLogger();
|
final DebugLogger _logger = DebugLogger();
|
||||||
|
bool _homeRowsLoaded = false;
|
||||||
|
|
||||||
// Home screen row caching
|
// Home screen row caching
|
||||||
List<Album>? _cachedRecentAlbums;
|
List<Album>? _cachedRecentAlbums;
|
||||||
@@ -28,6 +32,8 @@ class CacheService {
|
|||||||
final Map<String, DateTime> _searchCacheTime = {};
|
final Map<String, DateTime> _searchCacheTime = {};
|
||||||
|
|
||||||
// Player list caching
|
// Player list caching
|
||||||
|
List<Player>? _cachedPlayers;
|
||||||
|
Player? _cachedSelectedPlayer;
|
||||||
DateTime? _playersLastFetched;
|
DateTime? _playersLastFetched;
|
||||||
|
|
||||||
// Player track cache (for smooth swipe transitions)
|
// Player track cache (for smooth swipe transitions)
|
||||||
@@ -54,6 +60,8 @@ class CacheService {
|
|||||||
_cachedRecentAlbums = albums;
|
_cachedRecentAlbums = albums;
|
||||||
_recentAlbumsLastFetched = DateTime.now();
|
_recentAlbumsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${albums.length} recent albums');
|
_logger.log('✅ Cached ${albums.length} recent albums');
|
||||||
|
// Persist to database for instant load on next launch
|
||||||
|
_persistHomeRowToDatabase('recent_albums', albums.map((a) => a.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if discover artists cache is valid
|
/// Check if discover artists cache is valid
|
||||||
@@ -73,6 +81,8 @@ class CacheService {
|
|||||||
_cachedDiscoverArtists = artists;
|
_cachedDiscoverArtists = artists;
|
||||||
_discoverArtistsLastFetched = DateTime.now();
|
_discoverArtistsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${artists.length} discover artists');
|
_logger.log('✅ Cached ${artists.length} discover artists');
|
||||||
|
// Persist to database for instant load on next launch
|
||||||
|
_persistHomeRowToDatabase('discover_artists', artists.map((a) => a.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if discover albums cache is valid
|
/// Check if discover albums cache is valid
|
||||||
@@ -92,6 +102,8 @@ class CacheService {
|
|||||||
_cachedDiscoverAlbums = albums;
|
_cachedDiscoverAlbums = albums;
|
||||||
_discoverAlbumsLastFetched = DateTime.now();
|
_discoverAlbumsLastFetched = DateTime.now();
|
||||||
_logger.log('✅ Cached ${albums.length} discover albums');
|
_logger.log('✅ Cached ${albums.length} discover albums');
|
||||||
|
// Persist to database for instant load on next launch
|
||||||
|
_persistHomeRowToDatabase('discover_albums', albums.map((a) => a.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate home screen cache (call on pull-to-refresh)
|
/// Invalidate home screen cache (call on pull-to-refresh)
|
||||||
@@ -219,6 +231,26 @@ class CacheService {
|
|||||||
_playersLastFetched = DateTime.now();
|
_playersLastFetched = DateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get cached players list
|
||||||
|
List<Player>? getCachedPlayers() => _cachedPlayers;
|
||||||
|
|
||||||
|
/// Set cached players list
|
||||||
|
void setCachedPlayers(List<Player> players) {
|
||||||
|
_cachedPlayers = players;
|
||||||
|
_playersLastFetched = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached selected player
|
||||||
|
Player? getCachedSelectedPlayer() => _cachedSelectedPlayer;
|
||||||
|
|
||||||
|
/// Set cached selected player
|
||||||
|
void setCachedSelectedPlayer(Player? player) {
|
||||||
|
_cachedSelectedPlayer = player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we have cached players (for instant UI display on app resume)
|
||||||
|
bool get hasCachedPlayers => _cachedPlayers != null && _cachedPlayers!.isNotEmpty;
|
||||||
|
|
||||||
/// Get cached track for a player (used for smooth swipe transitions)
|
/// Get cached track for a player (used for smooth swipe transitions)
|
||||||
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
Track? getCachedTrackForPlayer(String playerId) => _playerTrackCache[playerId];
|
||||||
|
|
||||||
@@ -249,6 +281,82 @@ class CacheService {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
invalidateHomeCache();
|
invalidateHomeCache();
|
||||||
clearAllDetailCaches();
|
clearAllDetailCaches();
|
||||||
|
_cachedPlayers = null;
|
||||||
|
_cachedSelectedPlayer = null;
|
||||||
_playersLastFetched = null;
|
_playersLastFetched = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DATABASE PERSISTENCE FOR HOME ROWS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Load home row data from database for instant display on startup
|
||||||
|
Future<void> loadHomeRowsFromDatabase() async {
|
||||||
|
if (_homeRowsLoaded) return;
|
||||||
|
_homeRowsLoaded = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!DatabaseService.instance.isInitialized) return;
|
||||||
|
|
||||||
|
// Load recent albums
|
||||||
|
final recentData = await DatabaseService.instance.getHomeRowCache('recent_albums');
|
||||||
|
if (recentData != null) {
|
||||||
|
try {
|
||||||
|
final items = (jsonDecode(recentData.itemsJson) as List)
|
||||||
|
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
_cachedRecentAlbums = items;
|
||||||
|
_recentAlbumsLastFetched = recentData.lastUpdated;
|
||||||
|
_logger.log('📦 Loaded ${items.length} recent albums from database');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse recent albums: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load discover artists
|
||||||
|
final artistsData = await DatabaseService.instance.getHomeRowCache('discover_artists');
|
||||||
|
if (artistsData != null) {
|
||||||
|
try {
|
||||||
|
final items = (jsonDecode(artistsData.itemsJson) as List)
|
||||||
|
.map((json) => Artist.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
_cachedDiscoverArtists = items;
|
||||||
|
_discoverArtistsLastFetched = artistsData.lastUpdated;
|
||||||
|
_logger.log('📦 Loaded ${items.length} discover artists from database');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse discover artists: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load discover albums
|
||||||
|
final albumsData = await DatabaseService.instance.getHomeRowCache('discover_albums');
|
||||||
|
if (albumsData != null) {
|
||||||
|
try {
|
||||||
|
final items = (jsonDecode(albumsData.itemsJson) as List)
|
||||||
|
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
_cachedDiscoverAlbums = items;
|
||||||
|
_discoverAlbumsLastFetched = albumsData.lastUpdated;
|
||||||
|
_logger.log('📦 Loaded ${items.length} discover albums from database');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse discover albums: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error loading home rows from database: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist home row data to database (fire-and-forget)
|
||||||
|
void _persistHomeRowToDatabase(String rowType, List<Map<String, dynamic>> items) {
|
||||||
|
() async {
|
||||||
|
try {
|
||||||
|
if (!DatabaseService.instance.isInitialized) return;
|
||||||
|
await DatabaseService.instance.saveHomeRowCache(rowType, jsonEncode(items));
|
||||||
|
_logger.log('💾 Persisted $rowType to database');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to persist $rowType: $e');
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import '../database/database.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Singleton service for database access throughout the app
|
||||||
|
class DatabaseService {
|
||||||
|
static DatabaseService? _instance;
|
||||||
|
static AppDatabase? _database;
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
DatabaseService._();
|
||||||
|
|
||||||
|
/// Get the singleton instance
|
||||||
|
static DatabaseService get instance {
|
||||||
|
_instance ??= DatabaseService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the database instance
|
||||||
|
AppDatabase get db {
|
||||||
|
if (_database == null) {
|
||||||
|
throw StateError('Database not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
return _database!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if database is initialized
|
||||||
|
bool get isInitialized => _database != null;
|
||||||
|
|
||||||
|
/// Initialize the database
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_database != null) {
|
||||||
|
_logger.log('Database already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Initializing database...');
|
||||||
|
_database = AppDatabase();
|
||||||
|
_logger.log('Database initialized successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the database (for cleanup)
|
||||||
|
Future<void> close() async {
|
||||||
|
await _database?.close();
|
||||||
|
_database = null;
|
||||||
|
_logger.log('Database closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Profile Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Get the currently active profile
|
||||||
|
Future<Profile?> getActiveProfile() => db.getActiveProfile();
|
||||||
|
|
||||||
|
/// Set the active profile (creates if doesn't exist)
|
||||||
|
Future<Profile> setActiveProfile({
|
||||||
|
required String username,
|
||||||
|
String? displayName,
|
||||||
|
required String source,
|
||||||
|
}) {
|
||||||
|
_logger.log('Setting active profile: $username (source: $source)');
|
||||||
|
return db.setActiveProfile(
|
||||||
|
username: username,
|
||||||
|
displayName: displayName,
|
||||||
|
source: source,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all profiles
|
||||||
|
Future<List<Profile>> getAllProfiles() => db.getAllProfiles();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Recently Played Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Add an item to recently played
|
||||||
|
Future<void> addRecentlyPlayed({
|
||||||
|
required String mediaId,
|
||||||
|
required String mediaType,
|
||||||
|
required String name,
|
||||||
|
String? artistName,
|
||||||
|
String? imageUrl,
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
}) {
|
||||||
|
_logger.log('Adding to recently played: $name ($mediaType)');
|
||||||
|
return db.addRecentlyPlayed(
|
||||||
|
mediaId: mediaId,
|
||||||
|
mediaType: mediaType,
|
||||||
|
name: name,
|
||||||
|
artistName: artistName,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
metadata: metadata != null ? jsonEncode(metadata) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recently played items
|
||||||
|
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) {
|
||||||
|
return db.getRecentlyPlayed(limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear recently played for current profile
|
||||||
|
Future<void> clearRecentlyPlayed() => db.clearRecentlyPlayed();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Library Cache Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Cache an item with JSON serialization
|
||||||
|
Future<void> cacheItem<T>({
|
||||||
|
required String itemType,
|
||||||
|
required String itemId,
|
||||||
|
required Map<String, dynamic> data,
|
||||||
|
}) {
|
||||||
|
return db.cacheItem(
|
||||||
|
itemType: itemType,
|
||||||
|
itemId: itemId,
|
||||||
|
data: jsonEncode(data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached items with JSON deserialization
|
||||||
|
Future<List<Map<String, dynamic>>> getCachedItems(String itemType) async {
|
||||||
|
final items = await db.getCachedItems(itemType);
|
||||||
|
return items.map((item) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(item.data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error decoding cached item: $e');
|
||||||
|
return <String, dynamic>{};
|
||||||
|
}
|
||||||
|
}).where((item) => item.isNotEmpty).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single cached item
|
||||||
|
Future<Map<String, dynamic>?> getCachedItem(String itemType, String itemId) async {
|
||||||
|
final item = await db.getCachedItem(itemType, itemId);
|
||||||
|
if (item == null) return null;
|
||||||
|
try {
|
||||||
|
return jsonDecode(item.data) as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error decoding cached item: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if sync is needed
|
||||||
|
Future<bool> needsSync(String syncType, {Duration maxAge = const Duration(minutes: 5)}) {
|
||||||
|
return db.needsSync(syncType, maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update sync timestamp
|
||||||
|
Future<void> updateSyncMetadata(String syncType, int itemCount) {
|
||||||
|
return db.updateSyncMetadata(syncType, itemCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached data (useful for logout)
|
||||||
|
Future<void> clearAllCache() => db.clearAllCache();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Player Cache Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save playback state for instant resume
|
||||||
|
Future<void> savePlaybackState({
|
||||||
|
String? playerId,
|
||||||
|
String? playerName,
|
||||||
|
String? currentTrackJson,
|
||||||
|
double positionSeconds = 0.0,
|
||||||
|
bool isPlaying = false,
|
||||||
|
}) {
|
||||||
|
return db.savePlaybackState(
|
||||||
|
playerId: playerId,
|
||||||
|
playerName: playerName,
|
||||||
|
currentTrackJson: currentTrackJson,
|
||||||
|
positionSeconds: positionSeconds,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get saved playback state
|
||||||
|
Future<PlaybackStateData?> getPlaybackState() => db.getPlaybackState();
|
||||||
|
|
||||||
|
/// Cache a player with optional track info
|
||||||
|
Future<void> cachePlayer({
|
||||||
|
required String playerId,
|
||||||
|
required String playerJson,
|
||||||
|
String? currentTrackJson,
|
||||||
|
}) {
|
||||||
|
return db.cachePlayer(
|
||||||
|
playerId: playerId,
|
||||||
|
playerJson: playerJson,
|
||||||
|
currentTrackJson: currentTrackJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache multiple players at once
|
||||||
|
Future<void> cachePlayers(List<Map<String, dynamic>> players) {
|
||||||
|
return db.cachePlayers(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all cached players
|
||||||
|
Future<List<CachedPlayer>> getCachedPlayers() => db.getCachedPlayers();
|
||||||
|
|
||||||
|
/// Update cached track for a player
|
||||||
|
Future<void> updateCachedPlayerTrack(String playerId, String? trackJson) {
|
||||||
|
return db.updateCachedPlayerTrack(playerId, trackJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached players
|
||||||
|
Future<void> clearCachedPlayers() => db.clearCachedPlayers();
|
||||||
|
|
||||||
|
/// Save queue for a player
|
||||||
|
Future<void> saveQueue(String playerId, List<String> itemJsonList) {
|
||||||
|
return db.saveQueue(playerId, itemJsonList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached queue for a player
|
||||||
|
Future<List<CachedQueueData>> getCachedQueue(String playerId) {
|
||||||
|
return db.getCachedQueue(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear queue for a player
|
||||||
|
Future<void> clearCachedQueue(String playerId) => db.clearCachedQueue(playerId);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Home Row Cache Convenience Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/// Save home row data (recent albums, discover artists, etc.)
|
||||||
|
Future<void> saveHomeRowCache(String rowType, String itemsJson) {
|
||||||
|
return db.saveHomeRowCache(rowType, itemsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get cached home row data
|
||||||
|
Future<HomeRowCacheData?> getHomeRowCache(String rowType) {
|
||||||
|
return db.getHomeRowCache(rowType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all home row cache
|
||||||
|
Future<void> clearHomeRowCache() => db.clearHomeRowCache();
|
||||||
|
}
|
||||||
@@ -254,6 +254,9 @@ class MetadataService {
|
|||||||
// Cache for album images
|
// Cache for album images
|
||||||
static final Map<String, String?> _albumImageCache = {};
|
static final Map<String, String?> _albumImageCache = {};
|
||||||
|
|
||||||
|
// Cache for author images (audiobooks)
|
||||||
|
static final Map<String, String?> _authorImageCache = {};
|
||||||
|
|
||||||
/// Fetches album cover URL from Deezer (free, no API key required)
|
/// Fetches album cover URL from Deezer (free, no API key required)
|
||||||
/// Returns the image URL if found, null otherwise
|
/// Returns the image URL if found, null otherwise
|
||||||
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
|
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
|
||||||
@@ -323,10 +326,152 @@ class MetadataService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches author image URL from multiple sources
|
||||||
|
/// Priority: 1. Audnexus, 2. Open Library, 3. Wikipedia
|
||||||
|
/// Returns the image URL if found, null otherwise
|
||||||
|
static Future<String?> getAuthorImageUrl(String authorName) async {
|
||||||
|
// Check cache first
|
||||||
|
final cacheKey = 'authorImage:$authorName';
|
||||||
|
if (_authorImageCache.containsKey(cacheKey)) {
|
||||||
|
return _authorImageCache[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Audnexus (specifically for audiobook authors, uses Audible data)
|
||||||
|
try {
|
||||||
|
final audnexusUri = Uri.https(
|
||||||
|
'api.audnex.us',
|
||||||
|
'/authors',
|
||||||
|
{'name': authorName},
|
||||||
|
);
|
||||||
|
|
||||||
|
final audnexusResponse = await http.get(audnexusUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (audnexusResponse.statusCode == 200) {
|
||||||
|
final data = json.decode(audnexusResponse.body);
|
||||||
|
// Audnexus returns an array of authors
|
||||||
|
if (data is List && data.isNotEmpty) {
|
||||||
|
final author = data[0];
|
||||||
|
final imageUrl = author['image'] as String?;
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
_authorImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Audnexus author image error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Open Library API
|
||||||
|
try {
|
||||||
|
final uri = Uri.https(
|
||||||
|
'openlibrary.org',
|
||||||
|
'/search/authors.json',
|
||||||
|
{
|
||||||
|
'q': authorName,
|
||||||
|
'limit': '1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await http.get(uri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final authors = data['docs'] as List?;
|
||||||
|
if (authors != null && authors.isNotEmpty) {
|
||||||
|
final author = authors[0];
|
||||||
|
final authorKey = author['key'] as String?;
|
||||||
|
if (authorKey != null) {
|
||||||
|
// Open Library author images are available at:
|
||||||
|
// https://covers.openlibrary.org/a/olid/{OLID}-L.jpg
|
||||||
|
final olid = authorKey.replaceAll('/authors/', '');
|
||||||
|
final imageUrl = 'https://covers.openlibrary.org/a/olid/$olid-L.jpg';
|
||||||
|
|
||||||
|
// Verify the image actually exists (Open Library returns 404 for missing images)
|
||||||
|
try {
|
||||||
|
final imageCheck = await http.head(Uri.parse(imageUrl)).timeout(const Duration(seconds: 3));
|
||||||
|
if (imageCheck.statusCode == 200) {
|
||||||
|
_authorImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Image doesn't exist, continue to cache null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Open Library author image error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Wikipedia API
|
||||||
|
try {
|
||||||
|
// Search for author on Wikipedia
|
||||||
|
final searchUri = Uri.https(
|
||||||
|
'en.wikipedia.org',
|
||||||
|
'/w/api.php',
|
||||||
|
{
|
||||||
|
'action': 'query',
|
||||||
|
'list': 'search',
|
||||||
|
'srsearch': '$authorName writer',
|
||||||
|
'srlimit': '1',
|
||||||
|
'format': 'json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final searchResponse = await http.get(searchUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (searchResponse.statusCode == 200) {
|
||||||
|
final searchData = json.decode(searchResponse.body);
|
||||||
|
final searchResults = searchData['query']?['search'] as List?;
|
||||||
|
if (searchResults != null && searchResults.isNotEmpty) {
|
||||||
|
final pageTitle = searchResults[0]['title'] as String?;
|
||||||
|
if (pageTitle != null) {
|
||||||
|
// Get page image
|
||||||
|
final imageUri = Uri.https(
|
||||||
|
'en.wikipedia.org',
|
||||||
|
'/w/api.php',
|
||||||
|
{
|
||||||
|
'action': 'query',
|
||||||
|
'titles': pageTitle,
|
||||||
|
'prop': 'pageimages',
|
||||||
|
'pithumbsize': '500',
|
||||||
|
'format': 'json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final imageResponse = await http.get(imageUri).timeout(const Duration(seconds: 5));
|
||||||
|
|
||||||
|
if (imageResponse.statusCode == 200) {
|
||||||
|
final imageData = json.decode(imageResponse.body);
|
||||||
|
final pages = imageData['query']?['pages'] as Map<String, dynamic>?;
|
||||||
|
if (pages != null && pages.isNotEmpty) {
|
||||||
|
final page = pages.values.first as Map<String, dynamic>?;
|
||||||
|
final thumbnail = page?['thumbnail'] as Map<String, dynamic>?;
|
||||||
|
final imageUrl = thumbnail?['source'] as String?;
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
_authorImageCache[cacheKey] = imageUrl;
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Wikipedia author image error: $e', context: 'Metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the null result to avoid repeated failed lookups
|
||||||
|
_authorImageCache[cacheKey] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Clears the metadata cache
|
/// Clears the metadata cache
|
||||||
static void clearCache() {
|
static void clearCache() {
|
||||||
_cache.clear();
|
_cache.clear();
|
||||||
_artistImageCache.clear();
|
_artistImageCache.clear();
|
||||||
_albumImageCache.clear();
|
_albumImageCache.clear();
|
||||||
|
_authorImageCache.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,6 +498,544 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Audiobook>> getAudiobooks({
|
||||||
|
int? limit,
|
||||||
|
int? offset,
|
||||||
|
String? search,
|
||||||
|
bool? favoriteOnly,
|
||||||
|
String? authorId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Check if library filtering is enabled
|
||||||
|
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
|
||||||
|
|
||||||
|
// If specific libraries are enabled, use browse API for filtering
|
||||||
|
if (enabledLibraries != null && enabledLibraries.isNotEmpty) {
|
||||||
|
_logger.log('📚 Using browse API for library filtering (${enabledLibraries.length} libraries enabled)');
|
||||||
|
return await _getAudiobooksFromBrowse(enabledLibraries, favoriteOnly: favoriteOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use the faster MA API (returns all audiobooks)
|
||||||
|
final args = <String, dynamic>{
|
||||||
|
if (limit != null) 'limit': limit,
|
||||||
|
if (offset != null) 'offset': offset,
|
||||||
|
if (search != null) 'search': search,
|
||||||
|
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||||
|
if (authorId != null) 'author_id': authorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.log('📚 Calling music/audiobooks/library_items with args: $args');
|
||||||
|
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/audiobooks/library_items',
|
||||||
|
args: args,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for error in response
|
||||||
|
if (response.containsKey('error_code')) {
|
||||||
|
_logger.log('📚 ERROR: ${response['error_code']} - ${response['details']}');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = response['result'] as List<dynamic>?;
|
||||||
|
if (items == null) {
|
||||||
|
_logger.log('📚 Audiobooks: result is null');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Audiobooks: found ${items.length} items from API');
|
||||||
|
|
||||||
|
// Log first item's structure including metadata to find chapters/series
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
final firstItem = items.first as Map<String, dynamic>;
|
||||||
|
_logger.log('📚 First item keys: ${firstItem.keys.toList()}');
|
||||||
|
_logger.log('📚 First item name: ${firstItem['name']}, media_type: ${firstItem['media_type']}');
|
||||||
|
|
||||||
|
// Log metadata contents - this is where chapters/series info likely lives
|
||||||
|
if (firstItem.containsKey('metadata')) {
|
||||||
|
final metadata = firstItem['metadata'] as Map<String, dynamic>?;
|
||||||
|
if (metadata != null) {
|
||||||
|
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
|
||||||
|
for (final key in metadata.keys) {
|
||||||
|
final value = metadata[key];
|
||||||
|
if (value is List) {
|
||||||
|
_logger.log('📚 metadata[$key] (List, ${value.length}): ${value.take(2)}');
|
||||||
|
} else if (value is Map) {
|
||||||
|
_logger.log('📚 metadata[$key] (Map): ${(value as Map).keys.toList()}');
|
||||||
|
} else if (value != null) {
|
||||||
|
_logger.log('📚 metadata[$key]: $value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for chapters at top level too
|
||||||
|
if (firstItem.containsKey('chapters')) {
|
||||||
|
_logger.log('📚 CHAPTERS at top level: ${firstItem['chapters']}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final audiobooks = <Audiobook>[];
|
||||||
|
final parseErrors = <String>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < items.length; i++) {
|
||||||
|
try {
|
||||||
|
final book = Audiobook.fromJson(items[i] as Map<String, dynamic>);
|
||||||
|
audiobooks.add(book);
|
||||||
|
} catch (e) {
|
||||||
|
parseErrors.add('Item $i: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseErrors.isNotEmpty) {
|
||||||
|
_logger.log('📚 Parse errors (${parseErrors.length}): ${parseErrors.take(3).join(", ")}');
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Successfully parsed ${audiobooks.length}/${items.length} audiobooks');
|
||||||
|
return audiobooks;
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.log('Error getting audiobooks: $e');
|
||||||
|
_logger.log('Stack: $stack');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audiobooks using the browse API with library filtering
|
||||||
|
Future<List<Audiobook>> _getAudiobooksFromBrowse(
|
||||||
|
List<String> enabledLibraryPaths, {
|
||||||
|
bool? favoriteOnly,
|
||||||
|
}) async {
|
||||||
|
final allAudiobooks = <Audiobook>[];
|
||||||
|
|
||||||
|
for (final libraryPath in enabledLibraryPaths) {
|
||||||
|
try {
|
||||||
|
_logger.log('📚 Browsing library: $libraryPath');
|
||||||
|
|
||||||
|
// Browse the library to find the Audiobooks folder
|
||||||
|
final libraryContents = await browse(libraryPath);
|
||||||
|
|
||||||
|
// Find the Audiobooks folder (path ends with /b)
|
||||||
|
String? audiobooksPath;
|
||||||
|
for (final item in libraryContents) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
final name = (itemMap['name'] as String? ?? '').toLowerCase();
|
||||||
|
|
||||||
|
if (path.endsWith('/b') || name == 'audiobooks') {
|
||||||
|
audiobooksPath = path;
|
||||||
|
_logger.log('📚 Found Audiobooks folder: $audiobooksPath');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audiobooksPath == null) {
|
||||||
|
_logger.log('📚 No Audiobooks folder found in library $libraryPath');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse the Audiobooks folder
|
||||||
|
final audiobookItems = await browse(audiobooksPath);
|
||||||
|
_logger.log('📚 Found ${audiobookItems.length} items in Audiobooks folder');
|
||||||
|
|
||||||
|
for (final item in audiobookItems) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final mediaType = itemMap['media_type'] as String?;
|
||||||
|
final name = itemMap['name'] as String? ?? '';
|
||||||
|
|
||||||
|
// Skip navigation items
|
||||||
|
if (name == '..') continue;
|
||||||
|
|
||||||
|
// Only include audiobooks
|
||||||
|
if (mediaType == 'audiobook') {
|
||||||
|
try {
|
||||||
|
final book = Audiobook.fromJson(itemMap);
|
||||||
|
|
||||||
|
// Apply favorite filter if requested
|
||||||
|
if (favoriteOnly == true && book.favorite != true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allAudiobooks.add(book);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error parsing audiobook "$name": $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error browsing library $libraryPath: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Total audiobooks from browse: ${allAudiobooks.length}');
|
||||||
|
return allAudiobooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play an audiobook
|
||||||
|
Future<void> playAudiobook(String playerId, Audiobook audiobook) async {
|
||||||
|
try {
|
||||||
|
// Build the audiobook URI
|
||||||
|
final uri = audiobook.uri ?? 'library://audiobook/${audiobook.itemId}';
|
||||||
|
_logger.log('📚 Playing audiobook: ${audiobook.name} with URI: $uri');
|
||||||
|
|
||||||
|
await _sendCommand(
|
||||||
|
'player_queues/play_media',
|
||||||
|
args: {
|
||||||
|
'queue_id': playerId,
|
||||||
|
'media': [uri],
|
||||||
|
'option': 'replace',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error playing audiobook: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get full audiobook details including chapters
|
||||||
|
Future<Audiobook?> getAudiobookDetails(String provider, String itemId) async {
|
||||||
|
try {
|
||||||
|
_logger.log('📚 Getting audiobook details: provider=$provider, itemId=$itemId');
|
||||||
|
|
||||||
|
// Try different API patterns to find the correct one
|
||||||
|
final apiPatterns = [
|
||||||
|
{'cmd': 'music/get_item', 'args': {'media_type': 'audiobook', 'item_id': itemId, 'provider_instance_id_or_domain': provider}},
|
||||||
|
{'cmd': 'music/audiobooks/get', 'args': {'item_id': itemId, 'provider_instance_id_or_domain': provider}},
|
||||||
|
{'cmd': 'music/item/audiobook/$itemId', 'args': <String, dynamic>{}},
|
||||||
|
];
|
||||||
|
|
||||||
|
Map<String, dynamic>? response;
|
||||||
|
String? successCmd;
|
||||||
|
|
||||||
|
for (final pattern in apiPatterns) {
|
||||||
|
final cmd = pattern['cmd'] as String;
|
||||||
|
final args = pattern['args'] as Map<String, dynamic>;
|
||||||
|
_logger.log('📚 Trying: $cmd');
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await _sendCommand(cmd, args: args);
|
||||||
|
if (!response.containsKey('error_code')) {
|
||||||
|
successCmd = cmd;
|
||||||
|
_logger.log('📚 SUCCESS with: $cmd');
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
_logger.log('📚 Failed: ${response['error_code']}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Exception with $cmd: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCmd == null || response == null || response.containsKey('error_code')) {
|
||||||
|
_logger.log('📚 All API patterns failed, using list data instead');
|
||||||
|
// Fall back to fetching from list (which includes metadata)
|
||||||
|
final audiobooks = await getAudiobooks(limit: 10000);
|
||||||
|
final match = audiobooks.where((b) => b.itemId == itemId).firstOrNull;
|
||||||
|
if (match != null) {
|
||||||
|
_logger.log('📚 Found in list: ${match.name}');
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = response['result'];
|
||||||
|
if (result == null) {
|
||||||
|
_logger.log('📚 Audiobook details: result is null');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log raw response to understand what MA returns
|
||||||
|
final resultMap = result as Map<String, dynamic>;
|
||||||
|
_logger.log('📚 ============ AUDIOBOOK DETAILS RAW RESPONSE ============');
|
||||||
|
_logger.log('📚 All keys: ${resultMap.keys.toList()}');
|
||||||
|
|
||||||
|
// Log each field for discovery
|
||||||
|
for (final key in resultMap.keys) {
|
||||||
|
final value = resultMap[key];
|
||||||
|
if (value is List) {
|
||||||
|
_logger.log('📚 [$key] (List, ${value.length} items): ${value.take(2)}...');
|
||||||
|
} else if (value is Map) {
|
||||||
|
_logger.log('📚 [$key] (Map): ${(value as Map).keys.toList()}');
|
||||||
|
} else {
|
||||||
|
_logger.log('📚 [$key]: $value');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for chapters specifically
|
||||||
|
if (resultMap.containsKey('chapters')) {
|
||||||
|
final chapters = resultMap['chapters'];
|
||||||
|
_logger.log('📚 CHAPTERS FOUND! Type: ${chapters.runtimeType}');
|
||||||
|
if (chapters is List && chapters.isNotEmpty) {
|
||||||
|
_logger.log('📚 First chapter: ${chapters.first}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.log('📚 NO chapters field! Checking alternatives...');
|
||||||
|
final possibleFields = ['chapters', 'chapter', 'tracks', 'parts', 'segments', 'media_items', 'items', 'content'];
|
||||||
|
for (final field in possibleFields) {
|
||||||
|
if (resultMap.containsKey(field)) {
|
||||||
|
_logger.log('📚 Alternative field "$field" found: ${resultMap[field]}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for series info
|
||||||
|
if (resultMap.containsKey('metadata')) {
|
||||||
|
final metadata = resultMap['metadata'] as Map<String, dynamic>?;
|
||||||
|
if (metadata != null) {
|
||||||
|
_logger.log('📚 METADATA keys: ${metadata.keys.toList()}');
|
||||||
|
for (final key in metadata.keys) {
|
||||||
|
_logger.log('📚 metadata[$key]: ${metadata[key]}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 ============ END RAW RESPONSE ============');
|
||||||
|
|
||||||
|
final audiobook = Audiobook.fromJson(resultMap);
|
||||||
|
_logger.log('📚 Parsed audiobook: ${audiobook.name}, chapters: ${audiobook.chapters?.length ?? 0}');
|
||||||
|
return audiobook;
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.log('📚 Error getting audiobook details: $e');
|
||||||
|
_logger.log('📚 Stack: $stack');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse Music Assistant content by path
|
||||||
|
/// Returns a list of browse items (folders or media items)
|
||||||
|
Future<List<dynamic>> browse(String? path) async {
|
||||||
|
try {
|
||||||
|
_logger.log('📚 Browse: path=$path');
|
||||||
|
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'music/browse',
|
||||||
|
args: path != null ? {'path': path} : <String, dynamic>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.containsKey('error_code')) {
|
||||||
|
_logger.log('📚 Browse error: ${response['error_code']}');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = response['result'] as List<dynamic>?;
|
||||||
|
if (result == null) {
|
||||||
|
_logger.log('📚 Browse: result is null');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Browse: found ${result.length} items');
|
||||||
|
|
||||||
|
// Log first few items for debugging
|
||||||
|
for (var i = 0; i < result.length && i < 3; i++) {
|
||||||
|
final item = result[i] as Map<String, dynamic>;
|
||||||
|
_logger.log('📚 Browse[$i]: ${item.keys.toList()}');
|
||||||
|
_logger.log('📚 Browse[$i]: path=${item['path']}, name=${item['name']}, label=${item['label']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Browse error: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the audiobookshelf provider instance ID
|
||||||
|
Future<String?> getAudiobookshelfProviderId() async {
|
||||||
|
try {
|
||||||
|
// Get an audiobook and extract the provider instance from it
|
||||||
|
final audiobooks = await getAudiobooks(limit: 1);
|
||||||
|
if (audiobooks.isEmpty) return null;
|
||||||
|
|
||||||
|
final book = audiobooks.first;
|
||||||
|
final mapping = book.providerMappings?.firstWhere(
|
||||||
|
(m) => m.providerDomain == 'audiobookshelf',
|
||||||
|
orElse: () => book.providerMappings!.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapping?.providerInstance;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error getting ABS provider ID: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audiobook series from the browse API
|
||||||
|
Future<List<AudiobookSeries>> getAudiobookSeries() async {
|
||||||
|
try {
|
||||||
|
_logger.log('📚 Getting audiobook series...');
|
||||||
|
|
||||||
|
// First, browse the root to discover available providers
|
||||||
|
final rootItems = await browse(null);
|
||||||
|
_logger.log('📚 Browse root has ${rootItems.length} items');
|
||||||
|
|
||||||
|
// Find audiobookshelf provider in browse results
|
||||||
|
String? providerPath;
|
||||||
|
for (final item in rootItems) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
|
||||||
|
if (path.contains('audiobookshelf')) {
|
||||||
|
providerPath = path;
|
||||||
|
_logger.log('📚 Found audiobookshelf provider at: $providerPath');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerPath == null) {
|
||||||
|
_logger.log('📚 No audiobookshelf provider found in browse root');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browse the provider to find libraries
|
||||||
|
final providerRoot = await browse(providerPath);
|
||||||
|
_logger.log('📚 Provider has ${providerRoot.length} items (libraries)');
|
||||||
|
|
||||||
|
// Discover and save libraries
|
||||||
|
final discoveredLibraries = <Map<String, String>>[];
|
||||||
|
for (final item in providerRoot) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
final name = itemMap['name'] as String? ?? '';
|
||||||
|
if (path != 'root' && name != '..') {
|
||||||
|
discoveredLibraries.add({'path': path, 'name': name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (discoveredLibraries.isNotEmpty) {
|
||||||
|
await SettingsService.setDiscoveredAbsLibraries(discoveredLibraries);
|
||||||
|
_logger.log('📚 Saved ${discoveredLibraries.length} discovered libraries');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get enabled libraries (null means all enabled)
|
||||||
|
final enabledLibraries = await SettingsService.getEnabledAbsLibraries();
|
||||||
|
|
||||||
|
// Collect series from enabled libraries only
|
||||||
|
final allSeries = <AudiobookSeries>[];
|
||||||
|
|
||||||
|
for (final libraryItem in providerRoot) {
|
||||||
|
final libraryMap = libraryItem as Map<String, dynamic>;
|
||||||
|
final libraryPath = libraryMap['path'] as String? ?? '';
|
||||||
|
final libraryName = libraryMap['name'] as String? ?? '';
|
||||||
|
|
||||||
|
// Skip "root" (parent navigation) item
|
||||||
|
if (libraryPath == 'root' || libraryName == '..') continue;
|
||||||
|
|
||||||
|
// Check if this library is enabled
|
||||||
|
if (enabledLibraries != null && !enabledLibraries.contains(libraryPath)) {
|
||||||
|
_logger.log('📚 Skipping disabled library: $libraryName');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Checking library: $libraryName at $libraryPath');
|
||||||
|
|
||||||
|
// Browse into the library to find categories
|
||||||
|
final libraryContents = await browse(libraryPath);
|
||||||
|
_logger.log('📚 Library "$libraryName" has ${libraryContents.length} items');
|
||||||
|
|
||||||
|
// Log library contents to find series
|
||||||
|
for (final item in libraryContents) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final name = itemMap['name'] as String? ?? '';
|
||||||
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
_logger.log('📚 Library item: name=$name, path=$path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for "Series" folder in the library
|
||||||
|
for (final item in libraryContents) {
|
||||||
|
final itemMap = item as Map<String, dynamic>;
|
||||||
|
final name = (itemMap['name'] as String? ?? '').toLowerCase();
|
||||||
|
final path = itemMap['path'] as String? ?? '';
|
||||||
|
|
||||||
|
if (name.contains('series') || path.contains('series')) {
|
||||||
|
_logger.log('📚 Found series folder: $path');
|
||||||
|
|
||||||
|
// Browse the series folder
|
||||||
|
final seriesItems = await browse(path);
|
||||||
|
_logger.log('📚 Found ${seriesItems.length} series in library "$libraryName"');
|
||||||
|
|
||||||
|
for (final seriesItem in seriesItems) {
|
||||||
|
final seriesMap = seriesItem as Map<String, dynamic>;
|
||||||
|
// Skip parent navigation
|
||||||
|
if (seriesMap['path'] == 'root' || seriesMap['name'] == '..') continue;
|
||||||
|
// Skip actual audiobook items (we only want series folders)
|
||||||
|
final mediaType = seriesMap['media_type'] as String?;
|
||||||
|
if (mediaType == 'audiobook' || mediaType == 'track') continue;
|
||||||
|
|
||||||
|
_logger.log('📚 Series: name=${seriesMap['name']}, path=${seriesMap['path']}, media_type=$mediaType');
|
||||||
|
allSeries.add(AudiobookSeries.fromJson(seriesMap));
|
||||||
|
}
|
||||||
|
break; // Found series in this library, move to next library
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Total series found: ${allSeries.length}');
|
||||||
|
return allSeries;
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.log('📚 Error getting series: $e');
|
||||||
|
_logger.log('📚 Stack: $stack');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audiobooks in a specific series
|
||||||
|
Future<List<Audiobook>> getSeriesAudiobooks(String seriesPath) async {
|
||||||
|
try {
|
||||||
|
_logger.log('📚 Getting audiobooks for series: $seriesPath');
|
||||||
|
_logger.log('📚 About to call browse...');
|
||||||
|
|
||||||
|
final items = await browse(seriesPath);
|
||||||
|
_logger.log('📚 Browse returned ${items.length} items');
|
||||||
|
|
||||||
|
final audiobooks = <Audiobook>[];
|
||||||
|
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
final item = items[i];
|
||||||
|
_logger.log('📚 Processing item $i: ${item.runtimeType}');
|
||||||
|
|
||||||
|
if (item is! Map<String, dynamic>) {
|
||||||
|
_logger.log('📚 Item $i is not a Map, skipping');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final itemMap = item;
|
||||||
|
final mediaType = itemMap['media_type'];
|
||||||
|
_logger.log('📚 Item $i: media_type=$mediaType, name=${itemMap['name']}');
|
||||||
|
|
||||||
|
// Only include audiobooks (items with media_type)
|
||||||
|
if (mediaType == 'audiobook') {
|
||||||
|
_logger.log('📚 Parsing audiobook $i...');
|
||||||
|
// Debug: Log key fields for sequence detection
|
||||||
|
_logger.log('📚 Raw data $i: position=${itemMap['position']}, sort_name=${itemMap['sort_name']}, metadata.series=${(itemMap['metadata'] as Map?)?['series']}');
|
||||||
|
try {
|
||||||
|
// Inject browse_order as fallback for series sequencing
|
||||||
|
// This preserves the order returned by MA/Audiobookshelf browse API
|
||||||
|
if (itemMap['position'] == null) {
|
||||||
|
itemMap['_browse_order'] = i + 1; // 1-indexed like series numbers
|
||||||
|
}
|
||||||
|
audiobooks.add(Audiobook.fromJson(itemMap));
|
||||||
|
_logger.log('📚 Parsed audiobook $i successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error parsing audiobook $i: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Found ${audiobooks.length} audiobooks in series');
|
||||||
|
return audiobooks;
|
||||||
|
} catch (e, stack) {
|
||||||
|
_logger.log('📚 Error getting series audiobooks: $e');
|
||||||
|
_logger.log('📚 Stack: $stack');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy method - now uses browse API
|
||||||
|
@Deprecated('Use getAudiobookSeries instead')
|
||||||
|
Future<void> exploreSeries() async {
|
||||||
|
_logger.log('📚 exploreSeries is deprecated, use getAudiobookSeries instead');
|
||||||
|
final series = await getAudiobookSeries();
|
||||||
|
_logger.log('📚 Found ${series.length} series via browse API');
|
||||||
|
}
|
||||||
|
|
||||||
/// Get recently played albums
|
/// Get recently played albums
|
||||||
/// Gets recently played tracks, then fetches full track details to extract album info
|
/// Gets recently played tracks, then fetches full track details to extract album info
|
||||||
/// Optimized: batches track lookups to avoid N+1 query problem
|
/// Optimized: batches track lookups to avoid N+1 query problem
|
||||||
@@ -531,16 +1069,14 @@ class MusicAssistantAPI {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch fetch track details using parallel requests (max 5 concurrent)
|
// Step 1: Fetch tracks in batches and collect unique album URIs
|
||||||
final seenAlbumIds = <String>{};
|
final seenAlbumUris = <String>{};
|
||||||
final albums = <Album>[];
|
final albumUris = <String>[];
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the server
|
|
||||||
const batchSize = LibraryConstants.recentAlbumsBatchSize;
|
const batchSize = LibraryConstants.recentAlbumsBatchSize;
|
||||||
for (var i = 0; i < trackUris.length && albums.length < limit; i += batchSize) {
|
for (var i = 0; i < trackUris.length && albumUris.length < limit; i += batchSize) {
|
||||||
final batch = trackUris.skip(i).take(batchSize).toList();
|
final batch = trackUris.skip(i).take(batchSize).toList();
|
||||||
|
|
||||||
// Fetch batch in parallel
|
|
||||||
final futures = batch.map((uri) => _sendCommand(
|
final futures = batch.map((uri) => _sendCommand(
|
||||||
'music/item_by_uri',
|
'music/item_by_uri',
|
||||||
args: {'uri': uri},
|
args: {'uri': uri},
|
||||||
@@ -549,27 +1085,52 @@ class MusicAssistantAPI {
|
|||||||
final results = await Future.wait(futures, eagerError: false);
|
final results = await Future.wait(futures, eagerError: false);
|
||||||
|
|
||||||
for (final trackResponse in results) {
|
for (final trackResponse in results) {
|
||||||
if (albums.length >= limit) break;
|
if (albumUris.length >= limit) break;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
final fullTrack = trackResponse['result'] as Map<String, dynamic>?;
|
||||||
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
final albumData = fullTrack?['album'] as Map<String, dynamic>?;
|
||||||
|
final albumUri = albumData?['uri'] as String?;
|
||||||
|
|
||||||
if (albumData != null) {
|
if (albumUri != null && !seenAlbumUris.contains(albumUri)) {
|
||||||
final albumId = albumData['item_id']?.toString() ?? albumData['uri']?.toString();
|
seenAlbumUris.add(albumUri);
|
||||||
if (albumId != null && !seenAlbumIds.contains(albumId)) {
|
albumUris.add(albumUri);
|
||||||
seenAlbumIds.add(albumId);
|
|
||||||
albums.add(Album.fromJson(albumData));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Skip tracks that fail to parse
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.log('📚 Fetched ${albums.length} recent albums using ${(trackUris.length / batchSize).ceil()} batch requests');
|
if (albumUris.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch full album details (with images) for each album URI
|
||||||
|
final albums = <Album>[];
|
||||||
|
for (var i = 0; i < albumUris.length; i += batchSize) {
|
||||||
|
final batch = albumUris.skip(i).take(batchSize).toList();
|
||||||
|
|
||||||
|
final futures = batch.map((uri) => _sendCommand(
|
||||||
|
'music/item_by_uri',
|
||||||
|
args: {'uri': uri},
|
||||||
|
).timeout(const Duration(seconds: 5), onTimeout: () => <String, dynamic>{'result': null}));
|
||||||
|
|
||||||
|
final results = await Future.wait(futures, eagerError: false);
|
||||||
|
|
||||||
|
for (final albumResponse in results) {
|
||||||
|
try {
|
||||||
|
final albumData = albumResponse['result'] as Map<String, dynamic>?;
|
||||||
|
if (albumData != null) {
|
||||||
|
albums.add(Album.fromJson(albumData));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('📚 Fetched ${albums.length} recent albums with full details');
|
||||||
return albums;
|
return albums;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.log('❌ Error getting recent albums: $e');
|
_logger.log('❌ Error getting recent albums: $e');
|
||||||
@@ -1435,6 +1996,45 @@ class MusicAssistantAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync a player to a leader/group (temporary group)
|
||||||
|
/// The player will join the leader's sync group and play the same audio
|
||||||
|
Future<void> syncPlayerToLeader(String playerId, String leaderPlayerId) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔗 API syncPlayerToLeader called');
|
||||||
|
_logger.log('🔗 player_id: $playerId');
|
||||||
|
_logger.log('🔗 target_player: $leaderPlayerId');
|
||||||
|
|
||||||
|
final response = await _sendCommand(
|
||||||
|
'players/cmd/group',
|
||||||
|
args: {
|
||||||
|
'player_id': playerId,
|
||||||
|
'target_player': leaderPlayerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('✅ Player synced successfully, response: $response');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error syncing player: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a player from its sync group
|
||||||
|
Future<void> unsyncPlayer(String playerId) async {
|
||||||
|
try {
|
||||||
|
_logger.log('🔓 Unsyncing player: $playerId');
|
||||||
|
await _sendCommand(
|
||||||
|
'players/cmd/ungroup',
|
||||||
|
args: {
|
||||||
|
'player_id': playerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('✅ Player unsynced successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('Error unsyncing player: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle shuffle mode for queue
|
/// Toggle shuffle mode for queue
|
||||||
Future<void> toggleShuffle(String queueId) async {
|
Future<void> toggleShuffle(String queueId) async {
|
||||||
try {
|
try {
|
||||||
@@ -1764,89 +2364,15 @@ class MusicAssistantAPI {
|
|||||||
return _eventStreams['sendspin_player']!.stream;
|
return _eventStreams['sendspin_player']!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Sendspin connection info for a player
|
// NOTE: The following Sendspin API methods were removed because they don't exist in MA:
|
||||||
/// Returns connection details including:
|
// - getSendspinConnectionInfo (sendspin/connection_info)
|
||||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
// - sendspinOffer (sendspin/webrtc_offer)
|
||||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
// - sendspinAnswer (sendspin/webrtc_answer)
|
||||||
/// - player_id: The player's ID
|
// - sendspinIceCandidate (sendspin/ice_candidate)
|
||||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
//
|
||||||
try {
|
// Sendspin connection is handled directly via WebSocket to:
|
||||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
// - Local: ws://{server-ip}:8927/sendspin
|
||||||
|
// - External: wss://{server}/sendspin (via MA's proxy with auth)
|
||||||
final response = await _sendCommand(
|
|
||||||
'sendspin/connection_info',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = response['result'] as Map<String, dynamic>?;
|
|
||||||
if (result != null) {
|
|
||||||
_logger.log('Sendspin: Got connection info - local_ws_url: ${result['local_ws_url']}');
|
|
||||||
_logger.log('Sendspin: ICE servers count: ${(result['ice_servers'] as List?)?.length ?? 0}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error getting connection info: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send WebRTC offer to Sendspin server
|
|
||||||
/// Used when establishing WebRTC connection as fallback
|
|
||||||
Future<Map<String, dynamic>?> sendspinOffer(String playerId, String sdp) async {
|
|
||||||
try {
|
|
||||||
_logger.log('Sendspin: Sending WebRTC offer for player $playerId');
|
|
||||||
|
|
||||||
final response = await _sendCommand(
|
|
||||||
'sendspin/webrtc_offer',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'sdp': sdp,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return response['result'] as Map<String, dynamic>?;
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending WebRTC offer: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send WebRTC answer to Sendspin server
|
|
||||||
Future<void> sendspinAnswer(String playerId, String sdp) async {
|
|
||||||
try {
|
|
||||||
_logger.log('Sendspin: Sending WebRTC answer for player $playerId');
|
|
||||||
|
|
||||||
await _sendCommand(
|
|
||||||
'sendspin/webrtc_answer',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'sdp': sdp,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending WebRTC answer: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send ICE candidate to Sendspin server
|
|
||||||
Future<void> sendspinIceCandidate(String playerId, Map<String, dynamic> candidate) async {
|
|
||||||
try {
|
|
||||||
await _sendCommand(
|
|
||||||
'sendspin/ice_candidate',
|
|
||||||
args: {
|
|
||||||
'player_id': playerId,
|
|
||||||
'candidate': candidate,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_logger.log('Sendspin: Error sending ICE candidate: $e');
|
|
||||||
// Don't rethrow - ICE candidate failures are common and non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update Sendspin player state
|
/// Update Sendspin player state
|
||||||
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
|
||||||
|
/// Represents a queued action that will be executed when connection is restored
|
||||||
|
class QueuedAction {
|
||||||
|
final String type;
|
||||||
|
final Map<String, dynamic> params;
|
||||||
|
final DateTime queuedAt;
|
||||||
|
|
||||||
|
QueuedAction({
|
||||||
|
required this.type,
|
||||||
|
required this.params,
|
||||||
|
required this.queuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'type': type,
|
||||||
|
'params': params,
|
||||||
|
'queuedAt': queuedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory QueuedAction.fromJson(Map<String, dynamic> json) => QueuedAction(
|
||||||
|
type: json['type'] as String,
|
||||||
|
params: json['params'] as Map<String, dynamic>,
|
||||||
|
queuedAt: DateTime.parse(json['queuedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for queuing actions when offline and executing them when back online
|
||||||
|
class OfflineActionQueue {
|
||||||
|
static OfflineActionQueue? _instance;
|
||||||
|
static OfflineActionQueue get instance => _instance ??= OfflineActionQueue._();
|
||||||
|
|
||||||
|
OfflineActionQueue._();
|
||||||
|
|
||||||
|
final _logger = DebugLogger();
|
||||||
|
static const _prefsKey = 'offline_action_queue';
|
||||||
|
|
||||||
|
List<QueuedAction> _queue = [];
|
||||||
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
/// Get the current queue (for display)
|
||||||
|
List<QueuedAction> get queue => List.unmodifiable(_queue);
|
||||||
|
|
||||||
|
/// Check if there are pending actions
|
||||||
|
bool get hasPendingActions => _queue.isNotEmpty;
|
||||||
|
|
||||||
|
/// Number of pending actions
|
||||||
|
int get pendingCount => _queue.length;
|
||||||
|
|
||||||
|
/// Initialize and load persisted queue
|
||||||
|
Future<void> initialize() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final queueJson = prefs.getString(_prefsKey);
|
||||||
|
if (queueJson != null) {
|
||||||
|
final List<dynamic> decoded = jsonDecode(queueJson);
|
||||||
|
_queue = decoded.map((j) => QueuedAction.fromJson(j as Map<String, dynamic>)).toList();
|
||||||
|
_logger.log('📋 Loaded ${_queue.length} offline actions from storage');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error loading offline action queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an action for later execution
|
||||||
|
Future<void> queueAction(String type, Map<String, dynamic> params) async {
|
||||||
|
final action = QueuedAction(
|
||||||
|
type: type,
|
||||||
|
params: params,
|
||||||
|
queuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_queue.add(action);
|
||||||
|
await _persistQueue();
|
||||||
|
_logger.log('📋 Queued offline action: $type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process all queued actions (call when connection is restored)
|
||||||
|
Future<void> processQueue(Future<bool> Function(QueuedAction action) executor) async {
|
||||||
|
if (_isProcessing || _queue.isEmpty) return;
|
||||||
|
|
||||||
|
_isProcessing = true;
|
||||||
|
_logger.log('🔄 Processing ${_queue.length} offline actions...');
|
||||||
|
|
||||||
|
final processed = <QueuedAction>[];
|
||||||
|
|
||||||
|
for (final action in _queue) {
|
||||||
|
try {
|
||||||
|
final success = await executor(action);
|
||||||
|
if (success) {
|
||||||
|
processed.add(action);
|
||||||
|
_logger.log('✅ Processed offline action: ${action.type}');
|
||||||
|
} else {
|
||||||
|
_logger.log('⚠️ Failed to process offline action: ${action.type}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Error processing offline action: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove processed actions
|
||||||
|
_queue.removeWhere((a) => processed.contains(a));
|
||||||
|
await _persistQueue();
|
||||||
|
|
||||||
|
_isProcessing = false;
|
||||||
|
_logger.log('📋 ${processed.length} actions processed, ${_queue.length} remaining');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all queued actions
|
||||||
|
Future<void> clearQueue() async {
|
||||||
|
_queue.clear();
|
||||||
|
await _persistQueue();
|
||||||
|
_logger.log('🗑️ Offline action queue cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist queue to storage
|
||||||
|
Future<void> _persistQueue() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (_queue.isEmpty) {
|
||||||
|
await prefs.remove(_prefsKey);
|
||||||
|
} else {
|
||||||
|
final queueJson = jsonEncode(_queue.map((a) => a.toJson()).toList());
|
||||||
|
await prefs.setString(_prefsKey, queueJson);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Error persisting offline action queue: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action types for offline queuing
|
||||||
|
class OfflineActionTypes {
|
||||||
|
static const String toggleFavorite = 'toggle_favorite';
|
||||||
|
static const String addToPlaylist = 'add_to_playlist';
|
||||||
|
static const String removeFromPlaylist = 'remove_from_playlist';
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ class PcmAudioPlayer {
|
|||||||
final List<Uint8List> _audioBuffer = [];
|
final List<Uint8List> _audioBuffer = [];
|
||||||
bool _isFeeding = false;
|
bool _isFeeding = false;
|
||||||
bool _isStarted = false;
|
bool _isStarted = false;
|
||||||
|
bool _isAutoRecovering = false; // Tracks auto-recovery from setup errors
|
||||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||||
|
|
||||||
// Error callback for operation failures
|
// Error callback for operation failures
|
||||||
@@ -202,11 +203,32 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
/// Handle incoming audio data from the stream
|
/// Handle incoming audio data from the stream
|
||||||
void _onAudioData(Uint8List audioData) {
|
void _onAudioData(Uint8List audioData) {
|
||||||
// Ignore data if in error state or if in transitional/paused state
|
// Ignore data if in error state
|
||||||
if (_state == PcmPlayerState.error) return;
|
if (_state == PcmPlayerState.error) return;
|
||||||
|
|
||||||
|
// Handle audio arriving during paused/transitional states
|
||||||
|
// This can happen when audio frames arrive before stream/start message (race condition)
|
||||||
|
// We need to auto-recover in this case
|
||||||
if (_shouldBlockFeeding) {
|
if (_shouldBlockFeeding) {
|
||||||
// Silently ignore audio data when paused or transitioning
|
// If we're paused and audio is arriving, this is likely a new stream
|
||||||
// This prevents buffer accumulation during pause and any race conditions
|
// starting before the stream/start message. Queue for auto-recovery.
|
||||||
|
if (_state == PcmPlayerState.paused && !_isAutoRecovering) {
|
||||||
|
_logger.log('PcmAudioPlayer: Audio arriving while paused - initiating auto-recovery');
|
||||||
|
_isAutoRecovering = true;
|
||||||
|
_audioBuffer.add(audioData);
|
||||||
|
|
||||||
|
// Trigger async recovery
|
||||||
|
_autoRecoverFromPause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already recovering or in transitional state, buffer the data
|
||||||
|
if (_isAutoRecovering) {
|
||||||
|
_audioBuffer.add(audioData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other blocking states (pausing, stopping, resuming), ignore
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,15 +240,44 @@ class PcmAudioPlayer {
|
|||||||
_startPlayback();
|
_startPlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Auto-resume from paused state has been removed to prevent race conditions.
|
|
||||||
// The provider should explicitly call play() when ready to resume.
|
|
||||||
|
|
||||||
// Feed data if not currently feeding
|
// Feed data if not currently feeding
|
||||||
if (!_isFeeding && _isStarted) {
|
if (!_isFeeding && _isStarted) {
|
||||||
_feedNextChunk();
|
_feedNextChunk();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-recover from paused state when audio arrives unexpectedly
|
||||||
|
Future<void> _autoRecoverFromPause() async {
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovering from pause');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-initialize the native player
|
||||||
|
await pcm.FlutterPcmSound.setup(
|
||||||
|
sampleRate: _format.sampleRate,
|
||||||
|
channelCount: _format.channels,
|
||||||
|
);
|
||||||
|
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||||
|
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||||
|
await pcm.FlutterPcmSound.start();
|
||||||
|
|
||||||
|
_isStarted = true;
|
||||||
|
_state = PcmPlayerState.playing;
|
||||||
|
_startElapsedTimeTimer();
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovery from pause successful');
|
||||||
|
|
||||||
|
// Start feeding buffered data
|
||||||
|
_isAutoRecovering = false;
|
||||||
|
if (_audioBuffer.isNotEmpty && !_isFeeding) {
|
||||||
|
_feedNextChunk();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovery from pause failed: $e');
|
||||||
|
_isAutoRecovering = false;
|
||||||
|
_state = PcmPlayerState.error;
|
||||||
|
_emitError(PcmPlayerError.resumeFailed, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start audio playback
|
/// Start audio playback
|
||||||
Future<void> _startPlayback() async {
|
Future<void> _startPlayback() async {
|
||||||
if (_isStarted) return;
|
if (_isStarted) return;
|
||||||
@@ -298,7 +349,41 @@ class PcmAudioPlayer {
|
|||||||
|
|
||||||
// Check state again before the async feed call
|
// Check state again before the async feed call
|
||||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
try {
|
||||||
|
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||||
|
} catch (feedError) {
|
||||||
|
// Auto-recover from "must call setup first" error
|
||||||
|
// This happens when audio frames arrive before stream/start message
|
||||||
|
if (feedError.toString().contains('must call setup first') && !_isAutoRecovering) {
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovering from setup error');
|
||||||
|
_isAutoRecovering = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Re-initialize the native player
|
||||||
|
await pcm.FlutterPcmSound.setup(
|
||||||
|
sampleRate: _format.sampleRate,
|
||||||
|
channelCount: _format.channels,
|
||||||
|
);
|
||||||
|
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||||
|
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||||
|
await pcm.FlutterPcmSound.start();
|
||||||
|
_isStarted = true;
|
||||||
|
_state = PcmPlayerState.playing;
|
||||||
|
_startElapsedTimeTimer();
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovery successful');
|
||||||
|
|
||||||
|
// Retry the feed with the current chunk
|
||||||
|
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||||
|
} catch (recoveryError) {
|
||||||
|
_logger.log('PcmAudioPlayer: Auto-recovery failed: $recoveryError');
|
||||||
|
_isAutoRecovering = false;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
_isAutoRecovering = false;
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't update stats if we got paused during the feed
|
// Don't update stats if we got paused during the feed
|
||||||
if (!_shouldBlockFeeding) {
|
if (!_shouldBlockFeeding) {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'database_service.dart';
|
||||||
|
import 'settings_service.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
import '../database/database.dart';
|
||||||
|
|
||||||
|
/// Service for managing user profiles
|
||||||
|
/// Profiles are auto-created from MA authentication or manual name entry
|
||||||
|
class ProfileService {
|
||||||
|
static ProfileService? _instance;
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
ProfileService._();
|
||||||
|
|
||||||
|
/// Get the singleton instance
|
||||||
|
static ProfileService get instance {
|
||||||
|
_instance ??= ProfileService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseService get _db => DatabaseService.instance;
|
||||||
|
|
||||||
|
/// Get the currently active profile
|
||||||
|
Future<Profile?> getActiveProfile() async {
|
||||||
|
if (!_db.isInitialized) return null;
|
||||||
|
return _db.getActiveProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the active profile's username/name
|
||||||
|
Future<String?> getActiveProfileName() async {
|
||||||
|
final profile = await getActiveProfile();
|
||||||
|
return profile?.displayName ?? profile?.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called after successful MA authentication
|
||||||
|
/// Creates or activates profile based on MA user info
|
||||||
|
Future<Profile> onMaAuthenticated({
|
||||||
|
required String username,
|
||||||
|
String? displayName,
|
||||||
|
}) async {
|
||||||
|
_logger.log('MA authenticated: username=$username, displayName=$displayName');
|
||||||
|
|
||||||
|
final profile = await _db.setActiveProfile(
|
||||||
|
username: username,
|
||||||
|
displayName: displayName,
|
||||||
|
source: 'ma_auth',
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('Profile activated: ${profile.username} (${profile.displayName})');
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when user manually enters their name (no MA auth)
|
||||||
|
/// Creates or activates profile based on entered name
|
||||||
|
Future<Profile> onManualNameEntered(String name) async {
|
||||||
|
_logger.log('Manual name entered: $name');
|
||||||
|
|
||||||
|
// Use name as both username and display name for manual profiles
|
||||||
|
final profile = await _db.setActiveProfile(
|
||||||
|
username: name.toLowerCase().replaceAll(' ', '_'),
|
||||||
|
displayName: name,
|
||||||
|
source: 'manual',
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('Profile activated: ${profile.username} (${profile.displayName})');
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate existing ownerName to profile (one-time migration)
|
||||||
|
Future<void> migrateFromOwnerName() async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
// Check if we already have any profiles
|
||||||
|
final profiles = await _db.getAllProfiles();
|
||||||
|
if (profiles.isNotEmpty) {
|
||||||
|
_logger.log('Migration skipped - profiles already exist');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing ownerName in settings
|
||||||
|
final ownerName = await SettingsService.getOwnerName();
|
||||||
|
if (ownerName == null || ownerName.isEmpty) {
|
||||||
|
_logger.log('Migration skipped - no ownerName to migrate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.log('Migrating ownerName "$ownerName" to profile...');
|
||||||
|
|
||||||
|
// Create profile from ownerName
|
||||||
|
// We don't know if it was MA auth or manual, so default to manual
|
||||||
|
await _db.setActiveProfile(
|
||||||
|
username: ownerName.toLowerCase().replaceAll(' ', '_'),
|
||||||
|
displayName: ownerName,
|
||||||
|
source: 'migrated',
|
||||||
|
);
|
||||||
|
|
||||||
|
_logger.log('Migration complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all profiles (for potential profile switcher UI)
|
||||||
|
Future<List<Profile>> getAllProfiles() async {
|
||||||
|
if (!_db.isInitialized) return [];
|
||||||
|
return _db.getAllProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any profile exists
|
||||||
|
Future<bool> hasAnyProfile() async {
|
||||||
|
final profiles = await getAllProfiles();
|
||||||
|
return profiles.isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'database_service.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
import '../database/database.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
|
||||||
|
/// Service for tracking recently played items locally, per-profile
|
||||||
|
/// This supplements MA's server-side tracking with instant local data
|
||||||
|
class RecentlyPlayedService {
|
||||||
|
static RecentlyPlayedService? _instance;
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
RecentlyPlayedService._();
|
||||||
|
|
||||||
|
/// Get the singleton instance
|
||||||
|
static RecentlyPlayedService get instance {
|
||||||
|
_instance ??= RecentlyPlayedService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseService get _db => DatabaseService.instance;
|
||||||
|
|
||||||
|
/// Record an album being played
|
||||||
|
Future<void> recordAlbumPlayed(Album album) async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Store images metadata for instant artwork display
|
||||||
|
final images = album.metadata?['images'];
|
||||||
|
await _db.addRecentlyPlayed(
|
||||||
|
mediaId: album.itemId,
|
||||||
|
mediaType: 'album',
|
||||||
|
name: album.name,
|
||||||
|
artistName: album.artistsString,
|
||||||
|
metadata: {
|
||||||
|
'provider': album.provider,
|
||||||
|
'uri': album.uri,
|
||||||
|
if (images != null) 'images': images,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('📝 Recorded album play: ${album.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to record album play: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an artist being played (e.g., artist radio)
|
||||||
|
Future<void> recordArtistPlayed(Artist artist) async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _db.addRecentlyPlayed(
|
||||||
|
mediaId: artist.itemId,
|
||||||
|
mediaType: 'artist',
|
||||||
|
name: artist.name,
|
||||||
|
metadata: {
|
||||||
|
'provider': artist.provider,
|
||||||
|
'uri': artist.uri,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('📝 Recorded artist play: ${artist.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to record artist play: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a track being played
|
||||||
|
Future<void> recordTrackPlayed(Track track) async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _db.addRecentlyPlayed(
|
||||||
|
mediaId: track.itemId,
|
||||||
|
mediaType: 'track',
|
||||||
|
name: track.name,
|
||||||
|
artistName: track.artistsString,
|
||||||
|
metadata: {
|
||||||
|
'provider': track.provider,
|
||||||
|
'uri': track.uri,
|
||||||
|
'albumName': track.album?.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('📝 Recorded track play: ${track.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to record track play: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a playlist being played
|
||||||
|
Future<void> recordPlaylistPlayed(Playlist playlist) async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _db.addRecentlyPlayed(
|
||||||
|
mediaId: playlist.itemId,
|
||||||
|
mediaType: 'playlist',
|
||||||
|
name: playlist.name,
|
||||||
|
metadata: {
|
||||||
|
'provider': playlist.provider,
|
||||||
|
'uri': playlist.uri,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('📝 Recorded playlist play: ${playlist.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to record playlist play: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an audiobook being played
|
||||||
|
Future<void> recordAudiobookPlayed(Audiobook audiobook) async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _db.addRecentlyPlayed(
|
||||||
|
mediaId: audiobook.itemId,
|
||||||
|
mediaType: 'audiobook',
|
||||||
|
name: audiobook.name,
|
||||||
|
artistName: audiobook.authorsString,
|
||||||
|
metadata: {
|
||||||
|
'provider': audiobook.provider,
|
||||||
|
'uri': audiobook.uri,
|
||||||
|
'narrators': audiobook.narratorsString,
|
||||||
|
'duration': audiobook.duration?.inSeconds,
|
||||||
|
'resumePositionMs': audiobook.resumePositionMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_logger.log('📝 Recorded audiobook play: ${audiobook.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to record audiobook play: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recently played albums for the current profile
|
||||||
|
/// Returns Album objects reconstructed from local data
|
||||||
|
Future<List<Album>> getRecentAlbums({int limit = 20}) async {
|
||||||
|
if (!_db.isInitialized) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
|
||||||
|
|
||||||
|
// Filter for albums and convert to Album objects
|
||||||
|
final albums = <Album>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.mediaType != 'album') continue;
|
||||||
|
if (seenIds.contains(item.mediaId)) continue;
|
||||||
|
seenIds.add(item.mediaId);
|
||||||
|
|
||||||
|
Map<String, dynamic>? metadata;
|
||||||
|
if (item.metadata != null) {
|
||||||
|
try {
|
||||||
|
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata with images if available (for instant artwork display)
|
||||||
|
Map<String, dynamic>? albumMetadata;
|
||||||
|
if (metadata?['images'] != null) {
|
||||||
|
albumMetadata = {'images': metadata!['images']};
|
||||||
|
}
|
||||||
|
|
||||||
|
albums.add(Album(
|
||||||
|
itemId: item.mediaId,
|
||||||
|
provider: metadata?['provider'] ?? 'library',
|
||||||
|
name: item.name,
|
||||||
|
uri: metadata?['uri'],
|
||||||
|
// Create minimal artist info from stored name
|
||||||
|
artists: item.artistName != null
|
||||||
|
? [Artist(itemId: '', provider: '', name: item.artistName!)]
|
||||||
|
: null,
|
||||||
|
metadata: albumMetadata,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (albums.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return albums;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to get recent albums: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get recently played audiobooks for the current profile
|
||||||
|
/// Returns Audiobook objects reconstructed from local data
|
||||||
|
Future<List<Audiobook>> getRecentAudiobooks({int limit = 20}) async {
|
||||||
|
if (!_db.isInitialized) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final items = await _db.getRecentlyPlayed(limit: limit * 2); // Get more to filter
|
||||||
|
|
||||||
|
// Filter for audiobooks and convert to Audiobook objects
|
||||||
|
final audiobooks = <Audiobook>[];
|
||||||
|
final seenIds = <String>{};
|
||||||
|
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.mediaType != 'audiobook') continue;
|
||||||
|
if (seenIds.contains(item.mediaId)) continue;
|
||||||
|
seenIds.add(item.mediaId);
|
||||||
|
|
||||||
|
Map<String, dynamic>? metadata;
|
||||||
|
if (item.metadata != null) {
|
||||||
|
try {
|
||||||
|
metadata = jsonDecode(item.metadata!) as Map<String, dynamic>;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
audiobooks.add(Audiobook(
|
||||||
|
itemId: item.mediaId,
|
||||||
|
provider: metadata?['provider'] ?? 'library',
|
||||||
|
name: item.name,
|
||||||
|
uri: metadata?['uri'],
|
||||||
|
// Create minimal author info from stored name
|
||||||
|
authors: item.artistName != null
|
||||||
|
? [Artist(itemId: '', provider: '', name: item.artistName!)]
|
||||||
|
: null,
|
||||||
|
duration: metadata?['duration'] != null
|
||||||
|
? Duration(seconds: metadata!['duration'] as int)
|
||||||
|
: null,
|
||||||
|
resumePositionMs: metadata?['resumePositionMs'] as int?,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (audiobooks.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return audiobooks;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to get recent audiobooks: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all recently played items for the current profile
|
||||||
|
Future<List<RecentlyPlayedData>> getRecentlyPlayed({int limit = 20}) async {
|
||||||
|
if (!_db.isInitialized) return [];
|
||||||
|
return _db.getRecentlyPlayed(limit: limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we have local recently played data
|
||||||
|
Future<bool> hasLocalData() async {
|
||||||
|
if (!_db.isInitialized) return false;
|
||||||
|
final items = await _db.getRecentlyPlayed(limit: 1);
|
||||||
|
return items.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear recently played for current profile
|
||||||
|
Future<void> clear() async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
await _db.clearRecentlyPlayed();
|
||||||
|
_logger.log('🗑️ Cleared recently played');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,8 +84,16 @@ class SendspinService {
|
|||||||
|
|
||||||
bool _isDisposed = false;
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
// Auth token for proxy authentication (MA 2.7.1+)
|
||||||
|
String? _authToken;
|
||||||
|
|
||||||
SendspinService(this.serverUrl);
|
SendspinService(this.serverUrl);
|
||||||
|
|
||||||
|
/// Set the MA auth token for proxy authentication
|
||||||
|
void setAuthToken(String? token) {
|
||||||
|
_authToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize and connect to Sendspin server
|
/// Initialize and connect to Sendspin server
|
||||||
/// Uses persistent player ID and username-based naming
|
/// Uses persistent player ID and username-based naming
|
||||||
Future<bool> connect() async {
|
Future<bool> connect() async {
|
||||||
@@ -105,18 +113,18 @@ class SendspinService {
|
|||||||
bool connected = false;
|
bool connected = false;
|
||||||
|
|
||||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||||
|
// Use proxy auth for MA 2.7.1+ - the proxy requires authentication before hello
|
||||||
if (_isHttpsServer()) {
|
if (_isHttpsServer()) {
|
||||||
final externalUrl = _buildExternalSendspinUrl();
|
final externalUrl = _buildExternalSendspinUrl();
|
||||||
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
||||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
|
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 5), useProxyAuth: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
// Note: Local network connections (ws://ip:8927/sendspin) are handled
|
||||||
// Note: We would need local_ws_url from getSendspinConnectionInfo API
|
// by the provider calling connectWithUrl() directly, not by this method.
|
||||||
// For now, skip this step and fall back to WebRTC (handled by provider)
|
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
|
_logger.log('Sendspin: External proxy connection failed');
|
||||||
_updateState(SendspinConnectionState.error);
|
_updateState(SendspinConnectionState.error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -158,10 +166,11 @@ class SendspinService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to connect to a specific WebSocket URL
|
/// Attempt to connect to a specific WebSocket URL
|
||||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
|
/// If useProxyAuth is true and we have an auth token, authenticate first (for MA 2.7.1+ proxy)
|
||||||
|
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5), bool useProxyAuth = false}) async {
|
||||||
try {
|
try {
|
||||||
// Connect to the base URL without query params - we send player info in hello message
|
// Connect to the base URL without query params - we send player info in hello message
|
||||||
_logger.log('Sendspin: Connecting to $url');
|
_logger.log('Sendspin: Connecting to $url${useProxyAuth ? ' (with proxy auth)' : ''}');
|
||||||
|
|
||||||
// Create WebSocket connection
|
// Create WebSocket connection
|
||||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||||
@@ -176,6 +185,29 @@ class SendspinService {
|
|||||||
onDone: _handleDone,
|
onDone: _handleDone,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If using proxy auth (MA 2.7.1+), authenticate first
|
||||||
|
if (useProxyAuth && _authToken != null) {
|
||||||
|
_logger.log('Sendspin: Sending proxy auth with token');
|
||||||
|
_sendMessage({
|
||||||
|
'type': 'auth',
|
||||||
|
'token': _authToken,
|
||||||
|
}, allowDuringHandshake: true);
|
||||||
|
|
||||||
|
// Wait for auth_ok response
|
||||||
|
final authOk = await _waitForAuth().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authOk) {
|
||||||
|
_logger.log('Sendspin: Proxy authentication failed or timed out');
|
||||||
|
await _channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_logger.log('Sendspin: Proxy auth successful, proceeding with hello');
|
||||||
|
}
|
||||||
|
|
||||||
// Send client/hello message immediately after connecting
|
// Send client/hello message immediately after connecting
|
||||||
// This is required by the Sendspin protocol - server waits for this
|
// This is required by the Sendspin protocol - server waits for this
|
||||||
// Message format follows aiosendspin protocol spec
|
// Message format follows aiosendspin protocol spec
|
||||||
@@ -228,12 +260,18 @@ class SendspinService {
|
|||||||
|
|
||||||
/// Wait for server acknowledgment after connection
|
/// Wait for server acknowledgment after connection
|
||||||
Completer<bool>? _ackCompleter;
|
Completer<bool>? _ackCompleter;
|
||||||
|
Completer<bool>? _authCompleter;
|
||||||
|
|
||||||
Future<bool> _waitForAck() async {
|
Future<bool> _waitForAck() async {
|
||||||
_ackCompleter = Completer<bool>();
|
_ackCompleter = Completer<bool>();
|
||||||
return _ackCompleter!.future;
|
return _ackCompleter!.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _waitForAuth() async {
|
||||||
|
_authCompleter = Completer<bool>();
|
||||||
|
return _authCompleter!.future;
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle incoming WebSocket messages
|
/// Handle incoming WebSocket messages
|
||||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||||
void _handleMessage(dynamic message) {
|
void _handleMessage(dynamic message) {
|
||||||
@@ -310,6 +348,14 @@ class SendspinService {
|
|||||||
onStreamEnd?.call();
|
onStreamEnd?.call();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'auth_ok':
|
||||||
|
// Proxy authentication successful (MA 2.7.1+)
|
||||||
|
_logger.log('Sendspin: Proxy authentication successful');
|
||||||
|
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||||
|
_authCompleter!.complete(true);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'ack':
|
case 'ack':
|
||||||
case 'connected':
|
case 'connected':
|
||||||
case 'registered':
|
case 'registered':
|
||||||
@@ -472,8 +518,9 @@ class SendspinService {
|
|||||||
if (muted != null) _isMuted = muted;
|
if (muted != null) _isMuted = muted;
|
||||||
|
|
||||||
// Determine player state for Sendspin protocol
|
// Determine player state for Sendspin protocol
|
||||||
// 'synchronized' = ready/playing, 'idle' = not playing
|
// Valid values: 'synchronized', 'error', 'external_source'
|
||||||
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
|
// When connected, we're always synchronized (ready to receive audio)
|
||||||
|
const playerState = 'synchronized';
|
||||||
|
|
||||||
_sendMessage({
|
_sendMessage({
|
||||||
'type': 'client/state',
|
'type': 'client/state',
|
||||||
@@ -534,7 +581,7 @@ class SendspinService {
|
|||||||
_sendMessage({
|
_sendMessage({
|
||||||
'type': 'client/time',
|
'type': 'client/time',
|
||||||
'payload': {
|
'payload': {
|
||||||
'timestamp': timestampMicroseconds,
|
'client_transmitted': timestampMicroseconds,
|
||||||
},
|
},
|
||||||
}, allowDuringHandshake: allowDuringHandshake);
|
}, allowDuringHandshake: allowDuringHandshake);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,69 @@ class SettingsService {
|
|||||||
static const String _keyUseMaterialTheme = 'use_material_theme';
|
static const String _keyUseMaterialTheme = 'use_material_theme';
|
||||||
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
static const String _keyAdaptiveTheme = 'adaptive_theme';
|
||||||
static const String _keyCustomColor = 'custom_color';
|
static const String _keyCustomColor = 'custom_color';
|
||||||
|
static const String _keyLocale = 'locale';
|
||||||
static const String _keyLastFmApiKey = 'lastfm_api_key';
|
static const String _keyLastFmApiKey = 'lastfm_api_key';
|
||||||
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
|
static const String _keyTheAudioDbApiKey = 'theaudiodb_api_key';
|
||||||
static const String _keyEnableLocalPlayback = 'enable_local_playback';
|
static const String _keyEnableLocalPlayback = 'enable_local_playback';
|
||||||
static const String _keyLocalPlayerName = 'local_player_name';
|
static const String _keyLocalPlayerName = 'local_player_name';
|
||||||
static const String _keyOwnerName = 'owner_name';
|
static const String _keyOwnerName = 'owner_name';
|
||||||
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
static const String _keyLastSelectedPlayerId = 'last_selected_player_id';
|
||||||
|
static const String _keyPreferLocalPlayer = 'prefer_local_player';
|
||||||
|
static const String _keySmartSortPlayers = 'smart_sort_players';
|
||||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||||
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
static const String _keyShowDiscoverAlbums = 'show_discover_albums';
|
||||||
|
static const String _keyShowContinueListeningAudiobooks = 'show_continue_listening_audiobooks';
|
||||||
|
static const String _keyShowDiscoverAudiobooks = 'show_discover_audiobooks';
|
||||||
|
static const String _keyShowDiscoverSeries = 'show_discover_series';
|
||||||
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||||
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||||
|
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||||
|
|
||||||
|
// Default row order
|
||||||
|
static const List<String> defaultHomeRowOrder = [
|
||||||
|
'recent-albums',
|
||||||
|
'discover-artists',
|
||||||
|
'discover-albums',
|
||||||
|
'continue-listening',
|
||||||
|
'discover-audiobooks',
|
||||||
|
'discover-series',
|
||||||
|
'favorite-albums',
|
||||||
|
'favorite-artists',
|
||||||
|
'favorite-tracks',
|
||||||
|
];
|
||||||
|
|
||||||
|
// View Mode Settings
|
||||||
|
static const String _keyArtistAlbumsSortOrder = 'artist_albums_sort_order'; // 'alpha' or 'year'
|
||||||
|
static const String _keyArtistAlbumsViewMode = 'artist_albums_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryArtistsViewMode = 'library_artists_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryAlbumsViewMode = 'library_albums_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryPlaylistsViewMode = 'library_playlists_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
|
// Audiobook View Mode Settings
|
||||||
|
static const String _keyAuthorAudiobooksSortOrder = 'author_audiobooks_sort_order'; // 'alpha' or 'year'
|
||||||
|
static const String _keyAuthorAudiobooksViewMode = 'author_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryAuthorsViewMode = 'library_authors_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryAudiobooksViewMode = 'library_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keyLibraryAudiobooksSortOrder = 'library_audiobooks_sort_order'; // 'alpha' or 'year'
|
||||||
|
static const String _keyLibrarySeriesViewMode = 'library_series_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
static const String _keySeriesAudiobooksSortOrder = 'series_audiobooks_sort_order'; // 'alpha' or 'year'
|
||||||
|
static const String _keySeriesAudiobooksViewMode = 'series_audiobooks_view_mode'; // 'grid2', 'grid3', 'list'
|
||||||
|
|
||||||
|
// Audiobookshelf Direct Integration Settings
|
||||||
|
static const String _keyAbsServerUrl = 'abs_server_url';
|
||||||
|
static const String _keyAbsApiToken = 'abs_api_token';
|
||||||
|
static const String _keyAbsEnabled = 'abs_enabled';
|
||||||
|
|
||||||
|
// Audiobookshelf Library Settings (via MA browse)
|
||||||
|
static const String _keyEnabledAbsLibraries = 'enabled_abs_libraries'; // JSON list of library paths
|
||||||
|
static const String _keyDiscoveredAbsLibraries = 'discovered_abs_libraries'; // JSON list of {path, name}
|
||||||
|
|
||||||
|
// Hint System Settings
|
||||||
|
static const String _keyShowHints = 'show_hints'; // Master toggle for hints
|
||||||
|
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||||
|
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||||
|
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -222,6 +273,21 @@ class SettingsService {
|
|||||||
await prefs.setString(_keyCustomColor, color);
|
await prefs.setString(_keyCustomColor, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Locale (null = system default)
|
||||||
|
static Future<String?> getLocale() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> saveLocale(String? locale) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (locale == null) {
|
||||||
|
await prefs.remove(_keyLocale);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_keyLocale, locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metadata API Keys
|
// Metadata API Keys
|
||||||
static Future<String?> getLastFmApiKey() async {
|
static Future<String?> getLastFmApiKey() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -305,6 +371,28 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer Local Player - always select local player first when available
|
||||||
|
static Future<bool> getPreferLocalPlayer() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyPreferLocalPlayer) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setPreferLocalPlayer(bool prefer) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyPreferLocalPlayer, prefer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart Sort Players - sort by status (playing > on > off) instead of alphabetically
|
||||||
|
static Future<bool> getSmartSortPlayers() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keySmartSortPlayers) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setSmartSortPlayers(bool smartSort) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keySmartSortPlayers, smartSort);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create player name with possessive apostrophe
|
// Helper to create player name with possessive apostrophe
|
||||||
static String _makePlayerName(String ownerName) {
|
static String _makePlayerName(String ownerName) {
|
||||||
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
// Handle possessive: "Chris" -> "Chris' Phone", "Mom" -> "Mom's Phone"
|
||||||
@@ -351,6 +439,37 @@ class SettingsService {
|
|||||||
await prefs.setBool(_keyShowDiscoverAlbums, show);
|
await prefs.setBool(_keyShowDiscoverAlbums, show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home Screen Audiobook Rows (default off - optional)
|
||||||
|
static Future<bool> getShowContinueListeningAudiobooks() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowContinueListeningAudiobooks) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowContinueListeningAudiobooks(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowContinueListeningAudiobooks, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverAudiobooks() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverAudiobooks) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverAudiobooks(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverAudiobooks, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getShowDiscoverSeries() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowDiscoverSeries) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setShowDiscoverSeries(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowDiscoverSeries, show);
|
||||||
|
}
|
||||||
|
|
||||||
// Home Screen Favorites Settings (default off)
|
// Home Screen Favorites Settings (default off)
|
||||||
static Future<bool> getShowFavoriteAlbums() async {
|
static Future<bool> getShowFavoriteAlbums() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -381,4 +500,309 @@ class SettingsService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Home Row Order
|
||||||
|
static Future<List<String>> getHomeRowOrder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_keyHomeRowOrder);
|
||||||
|
if (json != null) {
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(json);
|
||||||
|
return decoded.cast<String>();
|
||||||
|
} catch (_) {
|
||||||
|
return List.from(defaultHomeRowOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.from(defaultHomeRowOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setHomeRowOrder(List<String> order) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyHomeRowOrder, jsonEncode(order));
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Artist Albums
|
||||||
|
static Future<String> getArtistAlbumsSortOrder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyArtistAlbumsSortOrder) ?? 'alpha';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setArtistAlbumsSortOrder(String order) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyArtistAlbumsSortOrder, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getArtistAlbumsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyArtistAlbumsViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setArtistAlbumsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyArtistAlbumsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Library Artists
|
||||||
|
static Future<String> getLibraryArtistsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryArtistsViewMode) ?? 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryArtistsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryArtistsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Library Albums
|
||||||
|
static Future<String> getLibraryAlbumsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryAlbumsViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryAlbumsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryAlbumsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Library Playlists
|
||||||
|
static Future<String> getLibraryPlaylistsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryPlaylistsViewMode) ?? 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryPlaylistsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryPlaylistsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Author Audiobooks (author detail screen)
|
||||||
|
static Future<String> getAuthorAudiobooksSortOrder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyAuthorAudiobooksSortOrder) ?? 'alpha';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAuthorAudiobooksSortOrder(String order) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyAuthorAudiobooksSortOrder, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getAuthorAudiobooksViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyAuthorAudiobooksViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAuthorAudiobooksViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyAuthorAudiobooksViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Library Authors
|
||||||
|
static Future<String> getLibraryAuthorsViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryAuthorsViewMode) ?? 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryAuthorsViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryAuthorsViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Library Audiobooks
|
||||||
|
static Future<String> getLibraryAudiobooksViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryAudiobooksViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryAudiobooksViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryAudiobooksViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getLibraryAudiobooksSortOrder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibraryAudiobooksSortOrder) ?? 'alpha';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibraryAudiobooksSortOrder(String order) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibraryAudiobooksSortOrder, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getLibrarySeriesViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyLibrarySeriesViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setLibrarySeriesViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyLibrarySeriesViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Mode Settings - Series Audiobooks (series detail screen)
|
||||||
|
static Future<String> getSeriesAudiobooksSortOrder() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keySeriesAudiobooksSortOrder) ?? 'series';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setSeriesAudiobooksSortOrder(String order) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keySeriesAudiobooksSortOrder, order);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getSeriesAudiobooksViewMode() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keySeriesAudiobooksViewMode) ?? 'grid2';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setSeriesAudiobooksViewMode(String mode) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keySeriesAudiobooksViewMode, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audiobookshelf Settings
|
||||||
|
static Future<String?> getAbsServerUrl() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyAbsServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAbsServerUrl(String url) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyAbsServerUrl, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> getAbsApiToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyAbsApiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAbsApiToken(String token) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyAbsApiToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getAbsEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyAbsEnabled) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAbsEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyAbsEnabled, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearAbsSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyAbsServerUrl);
|
||||||
|
await prefs.remove(_keyAbsApiToken);
|
||||||
|
await prefs.remove(_keyAbsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audiobookshelf Library Settings (via MA browse API)
|
||||||
|
|
||||||
|
/// Get list of enabled library paths (all enabled by default if not set)
|
||||||
|
static Future<List<String>?> getEnabledAbsLibraries() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_keyEnabledAbsLibraries);
|
||||||
|
if (json == null) return null; // null means all libraries are enabled
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list.cast<String>();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set list of enabled library paths
|
||||||
|
static Future<void> setEnabledAbsLibraries(List<String> paths) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyEnabledAbsLibraries, jsonEncode(paths));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear enabled libraries (reverts to all enabled)
|
||||||
|
static Future<void> clearEnabledAbsLibraries() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyEnabledAbsLibraries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get discovered libraries [{path: String, name: String}]
|
||||||
|
static Future<List<Map<String, String>>?> getDiscoveredAbsLibraries() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final json = prefs.getString(_keyDiscoveredAbsLibraries);
|
||||||
|
if (json == null) return null;
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
|
return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save discovered libraries
|
||||||
|
static Future<void> setDiscoveredAbsLibraries(List<Map<String, String>> libraries) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyDiscoveredAbsLibraries, jsonEncode(libraries));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific library is enabled
|
||||||
|
static Future<bool> isAbsLibraryEnabled(String libraryPath) async {
|
||||||
|
final enabled = await getEnabledAbsLibraries();
|
||||||
|
if (enabled == null) return true; // All enabled by default
|
||||||
|
return enabled.contains(libraryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a specific library
|
||||||
|
static Future<void> toggleAbsLibrary(String libraryPath, bool enable) async {
|
||||||
|
final discovered = await getDiscoveredAbsLibraries();
|
||||||
|
if (discovered == null) return;
|
||||||
|
|
||||||
|
var enabled = await getEnabledAbsLibraries();
|
||||||
|
// If null (all enabled), initialize with all library paths
|
||||||
|
enabled ??= discovered.map((lib) => lib['path']!).toList();
|
||||||
|
|
||||||
|
if (enable && !enabled.contains(libraryPath)) {
|
||||||
|
enabled.add(libraryPath);
|
||||||
|
} else if (!enable && enabled.contains(libraryPath)) {
|
||||||
|
enabled.remove(libraryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setEnabledAbsLibraries(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint System Settings
|
||||||
|
|
||||||
|
/// Get whether hints are enabled (default: true)
|
||||||
|
static Future<bool> getShowHints() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyShowHints) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set whether hints are enabled
|
||||||
|
static Future<void> setShowHints(bool show) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyShowHints, show);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user has ever used the player reveal gesture
|
||||||
|
static Future<bool> getHasUsedPlayerReveal() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyHasUsedPlayerReveal) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark that user has used the player reveal gesture
|
||||||
|
static Future<void> setHasUsedPlayerReveal(bool hasUsed) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyHasUsedPlayerReveal, hasUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if user has completed onboarding (seen welcome screen)
|
||||||
|
static Future<bool> getHasCompletedOnboarding() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_keyHasCompletedOnboarding) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark that user has completed onboarding
|
||||||
|
static Future<void> setHasCompletedOnboarding(bool completed) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import 'database_service.dart';
|
||||||
|
import 'debug_logger.dart';
|
||||||
|
import 'music_assistant_api.dart';
|
||||||
|
|
||||||
|
/// Sync status for UI indicators
|
||||||
|
enum SyncStatus {
|
||||||
|
idle,
|
||||||
|
syncing,
|
||||||
|
completed,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for background library synchronization
|
||||||
|
/// Loads from database cache first, then syncs from MA API in background
|
||||||
|
class SyncService with ChangeNotifier {
|
||||||
|
static SyncService? _instance;
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
SyncService._();
|
||||||
|
|
||||||
|
static SyncService get instance {
|
||||||
|
_instance ??= SyncService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseService get _db => DatabaseService.instance;
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
SyncStatus _status = SyncStatus.idle;
|
||||||
|
String? _lastError;
|
||||||
|
DateTime? _lastSyncTime;
|
||||||
|
bool _isSyncing = false;
|
||||||
|
|
||||||
|
// Cached data (loaded from DB, updated after sync)
|
||||||
|
List<Album> _cachedAlbums = [];
|
||||||
|
List<Artist> _cachedArtists = [];
|
||||||
|
List<Audiobook> _cachedAudiobooks = [];
|
||||||
|
List<Playlist> _cachedPlaylists = [];
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
SyncStatus get status => _status;
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
DateTime? get lastSyncTime => _lastSyncTime;
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
List<Album> get cachedAlbums => _cachedAlbums;
|
||||||
|
List<Artist> get cachedArtists => _cachedArtists;
|
||||||
|
List<Audiobook> get cachedAudiobooks => _cachedAudiobooks;
|
||||||
|
List<Playlist> get cachedPlaylists => _cachedPlaylists;
|
||||||
|
bool get hasCache => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||||
|
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||||
|
|
||||||
|
/// Load library data from database cache (instant)
|
||||||
|
/// Call this on app startup for immediate data
|
||||||
|
Future<void> loadFromCache() async {
|
||||||
|
if (!_db.isInitialized) {
|
||||||
|
_logger.log('⚠️ Database not initialized, skipping cache load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('📦 Loading library from database cache...');
|
||||||
|
|
||||||
|
// Load albums from cache
|
||||||
|
final albumData = await _db.getCachedItems('album');
|
||||||
|
_cachedAlbums = albumData.map((data) {
|
||||||
|
try {
|
||||||
|
return Album.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse cached album: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).whereType<Album>().toList();
|
||||||
|
|
||||||
|
// Load artists from cache
|
||||||
|
final artistData = await _db.getCachedItems('artist');
|
||||||
|
_cachedArtists = artistData.map((data) {
|
||||||
|
try {
|
||||||
|
return Artist.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse cached artist: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).whereType<Artist>().toList();
|
||||||
|
|
||||||
|
// Load audiobooks from cache
|
||||||
|
final audiobookData = await _db.getCachedItems('audiobook');
|
||||||
|
_cachedAudiobooks = audiobookData.map((data) {
|
||||||
|
try {
|
||||||
|
return Audiobook.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse cached audiobook: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).whereType<Audiobook>().toList();
|
||||||
|
|
||||||
|
// Load playlists from cache
|
||||||
|
final playlistData = await _db.getCachedItems('playlist');
|
||||||
|
_cachedPlaylists = playlistData.map((data) {
|
||||||
|
try {
|
||||||
|
return Playlist.fromJson(data);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to parse cached playlist: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}).whereType<Playlist>().toList();
|
||||||
|
|
||||||
|
_logger.log('📦 Loaded ${_cachedAlbums.length} albums, ${_cachedArtists.length} artists, '
|
||||||
|
'${_cachedAudiobooks.length} audiobooks, ${_cachedPlaylists.length} playlists from cache');
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Failed to load from cache: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync library data from MA API in background
|
||||||
|
/// Updates database cache and notifies listeners when complete
|
||||||
|
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
|
||||||
|
if (_isSyncing) {
|
||||||
|
_logger.log('🔄 Sync already in progress, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_db.isInitialized) {
|
||||||
|
_logger.log('⚠️ Database not initialized, skipping sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if sync is needed (default: every 5 minutes)
|
||||||
|
if (!force) {
|
||||||
|
final albumsNeedSync = await _db.needsSync('albums', maxAge: const Duration(minutes: 5));
|
||||||
|
final artistsNeedSync = await _db.needsSync('artists', maxAge: const Duration(minutes: 5));
|
||||||
|
final audiobooksNeedSync = await _db.needsSync('audiobooks', maxAge: const Duration(minutes: 5));
|
||||||
|
final playlistsNeedSync = await _db.needsSync('playlists', maxAge: const Duration(minutes: 5));
|
||||||
|
|
||||||
|
if (!albumsNeedSync && !artistsNeedSync && !audiobooksNeedSync && !playlistsNeedSync) {
|
||||||
|
_logger.log('✅ Cache is fresh, skipping sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isSyncing = true;
|
||||||
|
_status = SyncStatus.syncing;
|
||||||
|
_lastError = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
_logger.log('🔄 Starting background library sync...');
|
||||||
|
|
||||||
|
// Fetch fresh data from MA API (in parallel for speed)
|
||||||
|
final results = await Future.wait([
|
||||||
|
api.getAlbums(limit: 1000),
|
||||||
|
api.getArtists(limit: 1000),
|
||||||
|
api.getAudiobooks(limit: 1000),
|
||||||
|
api.getPlaylists(limit: 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final albums = results[0] as List<Album>;
|
||||||
|
final artists = results[1] as List<Artist>;
|
||||||
|
final audiobooks = results[2] as List<Audiobook>;
|
||||||
|
final playlists = results[3] as List<Playlist>;
|
||||||
|
|
||||||
|
_logger.log('📥 Fetched ${albums.length} albums, ${artists.length} artists, '
|
||||||
|
'${audiobooks.length} audiobooks, ${playlists.length} playlists from MA');
|
||||||
|
|
||||||
|
// Save to database cache
|
||||||
|
await _saveAlbumsToCache(albums);
|
||||||
|
await _saveArtistsToCache(artists);
|
||||||
|
await _saveAudiobooksToCache(audiobooks);
|
||||||
|
await _savePlaylistsToCache(playlists);
|
||||||
|
|
||||||
|
// Update sync metadata
|
||||||
|
await _db.updateSyncMetadata('albums', albums.length);
|
||||||
|
await _db.updateSyncMetadata('artists', artists.length);
|
||||||
|
await _db.updateSyncMetadata('audiobooks', audiobooks.length);
|
||||||
|
await _db.updateSyncMetadata('playlists', playlists.length);
|
||||||
|
|
||||||
|
// Update in-memory cache
|
||||||
|
_cachedAlbums = albums;
|
||||||
|
_cachedArtists = artists;
|
||||||
|
_cachedAudiobooks = audiobooks;
|
||||||
|
_cachedPlaylists = playlists;
|
||||||
|
_lastSyncTime = DateTime.now();
|
||||||
|
_status = SyncStatus.completed;
|
||||||
|
|
||||||
|
_logger.log('✅ Library sync complete');
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('❌ Library sync failed: $e');
|
||||||
|
_status = SyncStatus.error;
|
||||||
|
_lastError = e.toString();
|
||||||
|
} finally {
|
||||||
|
_isSyncing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save albums to database cache
|
||||||
|
Future<void> _saveAlbumsToCache(List<Album> albums) async {
|
||||||
|
for (final album in albums) {
|
||||||
|
try {
|
||||||
|
await _db.cacheItem(
|
||||||
|
itemType: 'album',
|
||||||
|
itemId: album.itemId,
|
||||||
|
data: album.toJson(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to cache album ${album.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('💾 Saved ${albums.length} albums to cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save artists to database cache
|
||||||
|
Future<void> _saveArtistsToCache(List<Artist> artists) async {
|
||||||
|
for (final artist in artists) {
|
||||||
|
try {
|
||||||
|
await _db.cacheItem(
|
||||||
|
itemType: 'artist',
|
||||||
|
itemId: artist.itemId,
|
||||||
|
data: artist.toJson(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to cache artist ${artist.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('💾 Saved ${artists.length} artists to cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save audiobooks to database cache
|
||||||
|
Future<void> _saveAudiobooksToCache(List<Audiobook> audiobooks) async {
|
||||||
|
for (final audiobook in audiobooks) {
|
||||||
|
try {
|
||||||
|
await _db.cacheItem(
|
||||||
|
itemType: 'audiobook',
|
||||||
|
itemId: audiobook.itemId,
|
||||||
|
data: audiobook.toJson(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to cache audiobook ${audiobook.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('💾 Saved ${audiobooks.length} audiobooks to cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save playlists to database cache
|
||||||
|
Future<void> _savePlaylistsToCache(List<Playlist> playlists) async {
|
||||||
|
for (final playlist in playlists) {
|
||||||
|
try {
|
||||||
|
await _db.cacheItem(
|
||||||
|
itemType: 'playlist',
|
||||||
|
itemId: playlist.itemId,
|
||||||
|
data: playlist.toJson(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('⚠️ Failed to cache playlist ${playlist.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.log('💾 Saved ${playlists.length} playlists to cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force a fresh sync (for pull-to-refresh)
|
||||||
|
Future<void> forceSync(MusicAssistantAPI api) async {
|
||||||
|
await syncFromApi(api, force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached data
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
if (!_db.isInitialized) return;
|
||||||
|
|
||||||
|
await _db.clearAllCache();
|
||||||
|
_cachedAlbums = [];
|
||||||
|
_cachedArtists = [];
|
||||||
|
_cachedAudiobooks = [];
|
||||||
|
_cachedPlaylists = [];
|
||||||
|
_lastSyncTime = null;
|
||||||
|
_status = SyncStatus.idle;
|
||||||
|
notifyListeners();
|
||||||
|
_logger.log('🗑️ Library cache cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get albums (from cache or empty if not loaded)
|
||||||
|
List<Album> getAlbums() => _cachedAlbums;
|
||||||
|
|
||||||
|
/// Get artists (from cache or empty if not loaded)
|
||||||
|
List<Artist> getArtists() => _cachedArtists;
|
||||||
|
|
||||||
|
/// Get audiobooks (from cache or empty if not loaded)
|
||||||
|
List<Audiobook> getAudiobooks() => _cachedAudiobooks;
|
||||||
|
|
||||||
|
/// Get playlists (from cache or empty if not loaded)
|
||||||
|
List<Playlist> getPlaylists() => _cachedPlaylists;
|
||||||
|
|
||||||
|
/// Check if we have data available (from cache or sync)
|
||||||
|
bool get hasData => _cachedAlbums.isNotEmpty || _cachedArtists.isNotEmpty ||
|
||||||
|
_cachedAudiobooks.isNotEmpty || _cachedPlaylists.isNotEmpty;
|
||||||
|
}
|
||||||
@@ -4,7 +4,17 @@ import 'package:flutter/material.dart';
|
|||||||
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
const Color brandPrimaryColor = Color(0xFF1a1a1a);
|
||||||
const Color brandAccentColor = Color(0xFF604CEC);
|
const Color brandAccentColor = Color(0xFF604CEC);
|
||||||
|
|
||||||
// Brand color schemes
|
// Predefined accent colors for custom theme selection
|
||||||
|
const List<Color> accentColorOptions = [
|
||||||
|
Color(0xFF604CEC), // Purple (default)
|
||||||
|
Color(0xFF2196F3), // Blue
|
||||||
|
Color(0xFF009688), // Teal
|
||||||
|
Color(0xFFFF5722), // Deep Orange
|
||||||
|
Color(0xFFE91E63), // Pink
|
||||||
|
Color(0xFF4CAF50), // Green
|
||||||
|
];
|
||||||
|
|
||||||
|
// Brand color schemes (default)
|
||||||
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
final ColorScheme brandLightColorScheme = ColorScheme.fromSeed(
|
||||||
seedColor: brandAccentColor,
|
seedColor: brandAccentColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
@@ -21,6 +31,27 @@ final ColorScheme brandDarkColorScheme = ColorScheme.fromSeed(
|
|||||||
background: const Color(0xFF1a1a1a),
|
background: const Color(0xFF1a1a1a),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Generate color schemes from a custom seed color
|
||||||
|
ColorScheme generateLightColorScheme(Color seedColor) {
|
||||||
|
return ColorScheme.fromSeed(
|
||||||
|
seedColor: seedColor,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
background: const Color(0xFFFAFAFA),
|
||||||
|
onBackground: const Color(0xFF1a1a1a),
|
||||||
|
surface: const Color(0xFFFFFFFF),
|
||||||
|
onSurface: const Color(0xFF1a1a1a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorScheme generateDarkColorScheme(Color seedColor) {
|
||||||
|
return ColorScheme.fromSeed(
|
||||||
|
seedColor: seedColor,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
surface: const Color(0xFF2a2a2a),
|
||||||
|
background: const Color(0xFF1a1a1a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// Generate light theme
|
// Generate light theme
|
||||||
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
static ThemeData lightTheme({ColorScheme? colorScheme}) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
child: AlbumDetailsScreen(
|
child: AlbumDetailsScreen(
|
||||||
album: widget.album,
|
album: widget.album,
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -119,7 +120,8 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: 256,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: 256,
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
@@ -147,13 +149,13 @@ class _AlbumCardState extends State<AlbumCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album title
|
// Album title with year
|
||||||
Hero(
|
Hero(
|
||||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.album.name,
|
widget.album.nameWithYear,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: textTheme.titleSmall?.copyWith(
|
style: textTheme.titleSmall?.copyWith(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -10,6 +11,8 @@ class AlbumRow extends StatefulWidget {
|
|||||||
final Future<List<Album>> Function() loadAlbums;
|
final Future<List<Album>> Function() loadAlbums;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Album>? Function()? getCachedAlbums;
|
||||||
|
|
||||||
const AlbumRow({
|
const AlbumRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,6 +20,7 @@ class AlbumRow extends StatefulWidget {
|
|||||||
required this.loadAlbums,
|
required this.loadAlbums,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedAlbums,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,8 +28,8 @@ class AlbumRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Album>> _albumsFuture;
|
List<Album> _albums = [];
|
||||||
List<Album>? _cachedAlbums; // Keep last loaded data to prevent flash
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,21 +38,117 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAlbumsOnce();
|
_loadAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadAlbumsOnce() {
|
Future<void> _loadAlbums() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_albumsFuture = widget.loadAlbums().then((albums) {
|
_hasLoaded = true;
|
||||||
_cachedAlbums = albums;
|
|
||||||
return albums;
|
// 1. Try to get cached data synchronously for instant display
|
||||||
});
|
final cachedAlbums = widget.getCachedAlbums?.call();
|
||||||
_hasLoaded = true;
|
if (cachedAlbums != null && cachedAlbums.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_albums = cachedAlbums;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshAlbums = await widget.loadAlbums();
|
||||||
|
if (mounted && freshAlbums.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_albums = freshAlbums;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheAlbumImages(freshAlbums);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache album images so hero animations are smooth on first tap
|
||||||
|
void _precacheAlbumImages(List<Album> albums) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final albumsToCache = albums.take(10);
|
||||||
|
|
||||||
|
for (final album in albumsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(album, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_albums.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_albums.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No albums found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: square artwork + text below
|
||||||
|
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize; // Card width = artwork width (square)
|
||||||
|
final itemExtent = cardWidth + 12; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _albums.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final album = _albums[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(album.uri ?? album.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: AlbumCard(
|
||||||
|
album: album,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('AlbumRow:${widget.title}');
|
_logger.startBuild('AlbumRow:${widget.title}');
|
||||||
@@ -78,65 +178,7 @@ class _AlbumRowState extends State<AlbumRow> with AutomaticKeepAliveClientMixin
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Album>>(
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
future: _albumsFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final albums = snapshot.data ?? _cachedAlbums;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
|
||||||
if (albums == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && albums == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albums == null || albums.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No albums found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card layout: square artwork + text below
|
|
||||||
// Text area: 8px gap + ~18px title + ~18px artist = ~44px
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final artworkSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = artworkSize; // Card width = artwork width (square)
|
|
||||||
final itemExtent = cardWidth + 12; // width + horizontal margins
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: albums.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
|
||||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
|
||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final album = albums[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(album.uri ?? album.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
|
||||||
child: AlbumCard(
|
|
||||||
album: album,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class _ArtistAvatarState extends State<ArtistAvatar> {
|
|||||||
width: widget.radius * 2,
|
width: widget.radius * 2,
|
||||||
height: widget.radius * 2,
|
height: widget.radius * 2,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: colorScheme.surfaceVariant,
|
color: colorScheme.surfaceVariant,
|
||||||
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
child: Icon(Icons.person_rounded, color: colorScheme.onSurfaceVariant),
|
||||||
|
|||||||
@@ -100,49 +100,50 @@ class _ArtistCardState extends State<ArtistCard> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Artist image - circular, constrained to be a proper circle
|
// Artist image - circular
|
||||||
Expanded(
|
// PERF: Use AspectRatio instead of LayoutBuilder to provide fixed geometry
|
||||||
child: Center(
|
// before Hero animation starts. This prevents grey icon flash caused by
|
||||||
child: LayoutBuilder(
|
// dynamic sizing inside the Hero widget.
|
||||||
builder: (context, constraints) {
|
AspectRatio(
|
||||||
// Use the smaller dimension to ensure a circle
|
aspectRatio: 1.0,
|
||||||
final size = constraints.maxWidth < constraints.maxHeight
|
child: Hero(
|
||||||
? constraints.maxWidth
|
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
||||||
: constraints.maxHeight;
|
child: ClipOval(
|
||||||
return Hero(
|
child: Container(
|
||||||
tag: HeroTags.artistImage + (widget.artist.uri ?? widget.artist.itemId) + suffix,
|
color: colorScheme.surfaceVariant,
|
||||||
child: ClipOval(
|
child: imageUrl != null
|
||||||
child: Container(
|
? CachedNetworkImage(
|
||||||
width: size,
|
imageUrl: imageUrl,
|
||||||
height: size,
|
fit: BoxFit.cover,
|
||||||
color: colorScheme.surfaceVariant,
|
memCacheWidth: 256,
|
||||||
child: imageUrl != null
|
memCacheHeight: 256,
|
||||||
? CachedNetworkImage(
|
fadeInDuration: Duration.zero,
|
||||||
imageUrl: imageUrl,
|
fadeOutDuration: Duration.zero,
|
||||||
fit: BoxFit.cover,
|
placeholder: (context, url) => const SizedBox(),
|
||||||
memCacheWidth: 256,
|
errorWidget: (context, url, error) {
|
||||||
memCacheHeight: 256,
|
// Try fallback on error (only for MA URLs, not fallback URLs)
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
if (!_maImageFailed && url == maImageUrl) {
|
||||||
placeholder: (context, url) => const SizedBox(),
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
errorWidget: (context, url, error) {
|
_onImageError();
|
||||||
// Try fallback on error (only for MA URLs, not fallback URLs)
|
});
|
||||||
if (!_maImageFailed && url == maImageUrl) {
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
return Center(
|
||||||
_onImageError();
|
child: Icon(
|
||||||
});
|
Icons.person_rounded,
|
||||||
}
|
size: 64,
|
||||||
return Icon(
|
color: colorScheme.onSurfaceVariant,
|
||||||
Icons.person_rounded,
|
),
|
||||||
size: size * 0.55,
|
);
|
||||||
color: colorScheme.onSurfaceVariant,
|
},
|
||||||
);
|
)
|
||||||
},
|
: Center(
|
||||||
)
|
child: Icon(
|
||||||
: Icon(Icons.person_rounded, size: size * 0.55, color: colorScheme.onSurfaceVariant),
|
Icons.person_rounded,
|
||||||
),
|
size: 64,
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../models/media_item.dart';
|
import '../models/media_item.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../services/debug_logger.dart';
|
import '../services/debug_logger.dart';
|
||||||
@@ -10,6 +11,8 @@ class ArtistRow extends StatefulWidget {
|
|||||||
final Future<List<Artist>> Function() loadArtists;
|
final Future<List<Artist>> Function() loadArtists;
|
||||||
final String? heroTagSuffix;
|
final String? heroTagSuffix;
|
||||||
final double? rowHeight;
|
final double? rowHeight;
|
||||||
|
/// Optional: synchronous getter for cached data (for instant display)
|
||||||
|
final List<Artist>? Function()? getCachedArtists;
|
||||||
|
|
||||||
const ArtistRow({
|
const ArtistRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -17,6 +20,7 @@ class ArtistRow extends StatefulWidget {
|
|||||||
required this.loadArtists,
|
required this.loadArtists,
|
||||||
this.heroTagSuffix,
|
this.heroTagSuffix,
|
||||||
this.rowHeight,
|
this.rowHeight,
|
||||||
|
this.getCachedArtists,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,8 +28,8 @@ class ArtistRow extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixin {
|
||||||
late Future<List<Artist>> _artistsFuture;
|
List<Artist> _artists = [];
|
||||||
List<Artist>? _cachedArtists; // Keep last loaded data to prevent flash
|
bool _isLoading = true;
|
||||||
bool _hasLoaded = false;
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,21 +38,117 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadArtistsOnce();
|
_loadArtists();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadArtistsOnce() {
|
Future<void> _loadArtists() async {
|
||||||
if (!_hasLoaded) {
|
if (_hasLoaded) return;
|
||||||
_artistsFuture = widget.loadArtists().then((artists) {
|
_hasLoaded = true;
|
||||||
_cachedArtists = artists;
|
|
||||||
return artists;
|
// 1. Try to get cached data synchronously for instant display
|
||||||
});
|
final cachedArtists = widget.getCachedArtists?.call();
|
||||||
_hasLoaded = true;
|
if (cachedArtists != null && cachedArtists.isNotEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_artists = cachedArtists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load fresh data (always update - fresh data may have images that cached data lacks)
|
||||||
|
try {
|
||||||
|
final freshArtists = await widget.loadArtists();
|
||||||
|
if (mounted && freshArtists.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_artists = freshArtists;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
// Pre-cache images for smooth hero animations
|
||||||
|
_precacheArtistImages(freshArtists);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent failure - keep showing cached data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted && _isLoading) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-cache artist images so hero animations are smooth on first tap
|
||||||
|
void _precacheArtistImages(List<Artist> artists) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
// Only precache first ~10 visible items to avoid excessive network/memory use
|
||||||
|
final artistsToCache = artists.take(10);
|
||||||
|
|
||||||
|
for (final artist in artistsToCache) {
|
||||||
|
final imageUrl = maProvider.api?.getImageUrl(artist, size: 256);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
// Use CachedNetworkImageProvider to warm the cache
|
||||||
|
precacheImage(
|
||||||
|
CachedNetworkImageProvider(imageUrl),
|
||||||
|
context,
|
||||||
|
).catchError((_) {
|
||||||
|
// Silently ignore precache errors
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static final _logger = DebugLogger();
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
Widget _buildContent(double contentHeight, ColorScheme colorScheme) {
|
||||||
|
// Only show loading if we have no data at all
|
||||||
|
if (_artists.isEmpty && _isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_artists.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No artists found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card layout: circle image + name below
|
||||||
|
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final imageSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = imageSize; // Card width = image width (circle)
|
||||||
|
final itemExtent = cardWidth + 16; // width + horizontal margins
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: _artists.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
||||||
|
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
||||||
|
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final artist = _artists[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(artist.uri ?? artist.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: ArtistCard(
|
||||||
|
artist: artist,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_logger.startBuild('ArtistRow:${widget.title}');
|
_logger.startBuild('ArtistRow:${widget.title}');
|
||||||
@@ -78,65 +178,7 @@ class _ArtistRowState extends State<ArtistRow> with AutomaticKeepAliveClientMixi
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<Artist>>(
|
child: _buildContent(contentHeight, colorScheme),
|
||||||
future: _artistsFuture,
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// Use cached data immediately if available (prevents flash on rebuild)
|
|
||||||
final artists = snapshot.data ?? _cachedArtists;
|
|
||||||
|
|
||||||
// Only show loading if we have no data at all
|
|
||||||
if (artists == null && snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError && artists == null) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artists == null || artists.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No artists found',
|
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card layout: circle image + name below
|
|
||||||
// Text area: 8px gap + ~36px for 2-line name = ~44px
|
|
||||||
const textAreaHeight = 44.0;
|
|
||||||
final imageSize = contentHeight - textAreaHeight;
|
|
||||||
final cardWidth = imageSize; // Card width = image width (circle)
|
|
||||||
final itemExtent = cardWidth + 16; // width + horizontal margins
|
|
||||||
|
|
||||||
return ScrollConfiguration(
|
|
||||||
behavior: const _StretchScrollBehavior(),
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
||||||
itemCount: artists.length,
|
|
||||||
itemExtent: itemExtent,
|
|
||||||
cacheExtent: 500, // Preload ~3 items ahead for smoother scrolling
|
|
||||||
addAutomaticKeepAlives: false, // Row already uses AutomaticKeepAliveClientMixin
|
|
||||||
addRepaintBoundaries: false, // Cards already have RepaintBoundary
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final artist = artists[index];
|
|
||||||
return Container(
|
|
||||||
key: ValueKey(artist.uri ?? artist.itemId),
|
|
||||||
width: cardWidth,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: ArtistCard(
|
|
||||||
artist: artist,
|
|
||||||
heroTagSuffix: widget.heroTagSuffix,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
import '../screens/audiobook_detail_screen.dart';
|
||||||
|
import '../constants/hero_tags.dart';
|
||||||
|
|
||||||
|
class AudiobookRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<Audiobook>> Function() loadAudiobooks;
|
||||||
|
final String? heroTagSuffix;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
|
const AudiobookRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadAudiobooks,
|
||||||
|
this.heroTagSuffix,
|
||||||
|
this.rowHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudiobookRow> createState() => _AudiobookRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudiobookRowState extends State<AudiobookRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
late Future<List<Audiobook>> _audiobooksFuture;
|
||||||
|
List<Audiobook>? _cachedAudiobooks;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAudiobooksOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadAudiobooksOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_audiobooksFuture = widget.loadAudiobooks().then((audiobooks) {
|
||||||
|
_cachedAudiobooks = audiobooks;
|
||||||
|
return audiobooks;
|
||||||
|
});
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('AudiobookRow:${widget.title}');
|
||||||
|
super.build(context);
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
final totalHeight = widget.rowHeight ?? 237.0;
|
||||||
|
const titleHeight = 44.0;
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<Audiobook>>(
|
||||||
|
future: _audiobooksFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final audiobooks = snapshot.data ?? _cachedAudiobooks;
|
||||||
|
|
||||||
|
if (audiobooks == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError && audiobooks == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text('Error: ${snapshot.error}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audiobooks == null || audiobooks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No audiobooks found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: audiobooks.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final audiobook = audiobooks[index];
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(audiobook.uri ?? audiobook.itemId),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _AudiobookCard(
|
||||||
|
audiobook: audiobook,
|
||||||
|
heroTagSuffix: widget.heroTagSuffix ?? 'home_${widget.title.replaceAll(' ', '_').toLowerCase()}',
|
||||||
|
maProvider: maProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('AudiobookRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudiobookCard extends StatelessWidget {
|
||||||
|
final Audiobook audiobook;
|
||||||
|
final String heroTagSuffix;
|
||||||
|
final MusicAssistantProvider maProvider;
|
||||||
|
|
||||||
|
const _AudiobookCard({
|
||||||
|
required this.audiobook,
|
||||||
|
required this.heroTagSuffix,
|
||||||
|
required this.maProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final imageUrl = maProvider.getImageUrl(audiobook, size: 256);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: AudiobookDetailScreen(
|
||||||
|
audiobook: audiobook,
|
||||||
|
heroTagSuffix: heroTagSuffix,
|
||||||
|
initialImageUrl: imageUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookCover + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
// PERF: Duration.zero for hero-wrapped images
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
placeholder: (_, __) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(
|
||||||
|
MdiIcons.bookOutline,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Progress indicator
|
||||||
|
if (audiobook.progress > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
bottomLeft: Radius.circular(8),
|
||||||
|
bottomRight: Radius.circular(8),
|
||||||
|
),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: audiobook.progress,
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
|
minHeight: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Hero(
|
||||||
|
tag: HeroTags.audiobookTitle + (audiobook.uri ?? audiobook.itemId) + '_$heroTagSuffix',
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Text(
|
||||||
|
audiobook.name,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
audiobook.authorsString,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// A reusable widget for displaying disconnected/offline state.
|
/// A reusable widget for displaying disconnected/offline state.
|
||||||
@@ -21,29 +22,31 @@ class DisconnectedState extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Simple disconnected state without action button
|
/// Simple disconnected state without action button
|
||||||
factory DisconnectedState.simple() => const DisconnectedState(
|
factory DisconnectedState.simple(BuildContext context) => DisconnectedState(
|
||||||
title: 'Not connected to Music Assistant',
|
title: S.of(context)!.notConnected,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Disconnected state with settings button for navigation
|
/// Disconnected state with settings button for navigation
|
||||||
factory DisconnectedState.withSettingsAction({
|
factory DisconnectedState.withSettingsAction({
|
||||||
|
required BuildContext context,
|
||||||
required VoidCallback onSettings,
|
required VoidCallback onSettings,
|
||||||
}) =>
|
}) =>
|
||||||
DisconnectedState(
|
DisconnectedState(
|
||||||
title: 'Not connected to Music Assistant',
|
title: S.of(context)!.notConnected,
|
||||||
actionLabel: 'Configure Server',
|
actionLabel: S.of(context)!.configureServer,
|
||||||
actionIcon: Icons.settings_rounded,
|
actionIcon: Icons.settings_rounded,
|
||||||
onAction: onSettings,
|
onAction: onSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Full disconnected state with title, subtitle, and action (for home screen)
|
/// Full disconnected state with title, subtitle, and action (for home screen)
|
||||||
factory DisconnectedState.full({
|
factory DisconnectedState.full({
|
||||||
|
required BuildContext context,
|
||||||
required VoidCallback onSettings,
|
required VoidCallback onSettings,
|
||||||
}) =>
|
}) =>
|
||||||
DisconnectedState(
|
DisconnectedState(
|
||||||
title: 'Not Connected',
|
title: S.of(context)!.notConnectedTitle,
|
||||||
subtitle: 'Connect to your Music Assistant server to start listening',
|
subtitle: S.of(context)!.connectHint,
|
||||||
actionLabel: 'Configure Server',
|
actionLabel: S.of(context)!.configureServer,
|
||||||
actionIcon: Icons.settings_rounded,
|
actionIcon: Icons.settings_rounded,
|
||||||
onAction: onSettings,
|
onAction: onSettings,
|
||||||
iconSize: 80,
|
iconSize: 80,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// A reusable empty state widget for displaying when no data is available.
|
/// A reusable empty state widget for displaying when no data is available.
|
||||||
@@ -21,48 +22,66 @@ class EmptyState extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Creates an empty state for artists
|
/// Creates an empty state for artists
|
||||||
factory EmptyState.artists({VoidCallback? onRefresh}) => EmptyState(
|
factory EmptyState.artists({VoidCallback? onRefresh, required BuildContext context}) {
|
||||||
icon: Icons.person_outline_rounded,
|
final s = S.of(context)!;
|
||||||
message: 'No artists found',
|
return EmptyState(
|
||||||
actionLabel: onRefresh != null ? 'Refresh' : null,
|
icon: Icons.person_outline_rounded,
|
||||||
onAction: onRefresh,
|
message: s.noArtistsFound,
|
||||||
);
|
actionLabel: onRefresh != null ? s.refresh : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an empty state for albums
|
/// Creates an empty state for albums
|
||||||
factory EmptyState.albums({VoidCallback? onRefresh}) => EmptyState(
|
factory EmptyState.albums({VoidCallback? onRefresh, required BuildContext context}) {
|
||||||
icon: Icons.album_outlined,
|
final s = S.of(context)!;
|
||||||
message: 'No albums found',
|
return EmptyState(
|
||||||
actionLabel: onRefresh != null ? 'Refresh' : null,
|
icon: Icons.album_outlined,
|
||||||
onAction: onRefresh,
|
message: s.noAlbumsFound,
|
||||||
);
|
actionLabel: onRefresh != null ? s.refresh : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an empty state for tracks
|
/// Creates an empty state for tracks
|
||||||
factory EmptyState.tracks({VoidCallback? onRefresh}) => EmptyState(
|
factory EmptyState.tracks({VoidCallback? onRefresh, required BuildContext context}) {
|
||||||
icon: Icons.music_note_outlined,
|
final s = S.of(context)!;
|
||||||
message: 'No tracks found',
|
return EmptyState(
|
||||||
actionLabel: onRefresh != null ? 'Refresh' : null,
|
icon: Icons.music_note_outlined,
|
||||||
onAction: onRefresh,
|
message: s.noTracksFound,
|
||||||
);
|
actionLabel: onRefresh != null ? s.refresh : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an empty state for playlists
|
/// Creates an empty state for playlists
|
||||||
factory EmptyState.playlists({VoidCallback? onRefresh}) => EmptyState(
|
factory EmptyState.playlists({VoidCallback? onRefresh, required BuildContext context}) {
|
||||||
icon: Icons.playlist_play_rounded,
|
final s = S.of(context)!;
|
||||||
message: 'No playlists found',
|
return EmptyState(
|
||||||
actionLabel: onRefresh != null ? 'Refresh' : null,
|
icon: Icons.playlist_play_rounded,
|
||||||
onAction: onRefresh,
|
message: s.noPlaylistsFound,
|
||||||
);
|
actionLabel: onRefresh != null ? s.refresh : null,
|
||||||
|
onAction: onRefresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an empty state for queue
|
/// Creates an empty state for queue
|
||||||
factory EmptyState.queue() => const EmptyState(
|
factory EmptyState.queue({required BuildContext context}) {
|
||||||
icon: Icons.queue_music,
|
final s = S.of(context)!;
|
||||||
message: 'Queue is empty',
|
return EmptyState(
|
||||||
);
|
icon: Icons.queue_music,
|
||||||
|
message: s.queueIsEmpty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an empty state for search results
|
/// Creates an empty state for search results
|
||||||
factory EmptyState.search() => const EmptyState(
|
factory EmptyState.search({required BuildContext context}) {
|
||||||
icon: Icons.search_off_rounded,
|
final s = S.of(context)!;
|
||||||
message: 'No results found',
|
return EmptyState(
|
||||||
);
|
icon: Icons.search_off_rounded,
|
||||||
|
message: s.noResultsFound,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a custom empty state with title and subtitle
|
/// Creates a custom empty state with title and subtitle
|
||||||
factory EmptyState.custom({
|
factory EmptyState.custom({
|
||||||
@@ -70,12 +89,14 @@ class EmptyState extends StatelessWidget {
|
|||||||
required String title,
|
required String title,
|
||||||
String? subtitle,
|
String? subtitle,
|
||||||
VoidCallback? onRefresh,
|
VoidCallback? onRefresh,
|
||||||
|
required BuildContext context,
|
||||||
}) =>
|
}) =>
|
||||||
_CustomEmptyState(
|
_CustomEmptyState(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
|
context: context,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -126,17 +147,20 @@ class _CustomEmptyState extends EmptyState {
|
|||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final VoidCallback? onRefresh;
|
final VoidCallback? onRefresh;
|
||||||
|
final BuildContext context;
|
||||||
|
|
||||||
const _CustomEmptyState({
|
const _CustomEmptyState({
|
||||||
required super.icon,
|
required super.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
|
required this.context,
|
||||||
}) : super(message: title);
|
}) : super(message: title);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final s = S.of(context)!;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -175,7 +199,7 @@ class _CustomEmptyState extends EmptyState {
|
|||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: onRefresh,
|
onPressed: onRefresh,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
label: const Text('Refresh'),
|
label: Text(s.refresh),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colorScheme.surfaceVariant,
|
backgroundColor: colorScheme.surfaceVariant,
|
||||||
foregroundColor: colorScheme.onSurfaceVariant,
|
foregroundColor: colorScheme.onSurfaceVariant,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import '../providers/navigation_provider.dart';
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../theme/theme_provider.dart';
|
import '../theme/theme_provider.dart';
|
||||||
import 'expandable_player.dart';
|
import 'expandable_player.dart';
|
||||||
|
import 'player/player_reveal_overlay.dart';
|
||||||
|
|
||||||
/// Cached color with contrast adjustment
|
/// Cached color with contrast adjustment
|
||||||
/// Avoids expensive HSL conversions during scroll
|
/// Avoids expensive HSL conversions during scroll
|
||||||
@@ -110,13 +116,68 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
|||||||
static void showPlayer() {
|
static void showPlayer() {
|
||||||
_overlayStateKey.currentState?._setHidden(false);
|
_overlayStateKey.currentState?._setHidden(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the player reveal overlay with bounce animation
|
||||||
|
static void showPlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._showPlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the player reveal overlay (no animation - used as callback from overlay)
|
||||||
|
static void hidePlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._hidePlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss the player reveal overlay with animation (for back gesture)
|
||||||
|
static void dismissPlayerReveal() {
|
||||||
|
_overlayStateKey.currentState?._dismissPlayerReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||||
|
static void triggerBounce() {
|
||||||
|
_overlayStateKey.currentState?._triggerBounce();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the player reveal is currently visible
|
||||||
|
static bool get isPlayerRevealVisible =>
|
||||||
|
_overlayStateKey.currentState?._isRevealVisible ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||||
with SingleTickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
late Animation<double> _slideAnimation;
|
late Animation<double> _slideAnimation;
|
||||||
|
|
||||||
|
// Single bounce controller for device selector expand/collapse
|
||||||
|
late AnimationController _singleBounceController;
|
||||||
|
|
||||||
|
// Double bounce controller for hint
|
||||||
|
late AnimationController _doubleBounceController;
|
||||||
|
|
||||||
|
// State for player reveal overlay
|
||||||
|
bool _isRevealVisible = false;
|
||||||
|
|
||||||
|
// State for interactive hint mode (blur backdrop + wait for user action)
|
||||||
|
bool _isHintModeActive = false;
|
||||||
|
Timer? _hintBounceTimer;
|
||||||
|
|
||||||
|
// Track if player reveal was triggered from onboarding (for showing extra hints)
|
||||||
|
bool _isOnboardingReveal = false;
|
||||||
|
|
||||||
|
// Bounce offset for mini player (used by both single and double bounce)
|
||||||
|
final _bounceOffsetNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
|
// Hint system state
|
||||||
|
bool _showHints = true;
|
||||||
|
bool _hasCompletedOnboarding = false; // First-use welcome screen
|
||||||
|
bool _hintTriggered = false; // Prevent multiple triggers per session
|
||||||
|
final _hintOpacityNotifier = ValueNotifier<double>(0.0);
|
||||||
|
|
||||||
|
// Welcome content fade-in animation
|
||||||
|
late AnimationController _welcomeFadeController;
|
||||||
|
|
||||||
|
// Key for the reveal overlay
|
||||||
|
final _revealKey = GlobalKey<PlayerRevealOverlayState>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -129,11 +190,95 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Single bounce for device selector expand/collapse
|
||||||
|
_singleBounceController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_singleBounceController.addListener(() {
|
||||||
|
final t = Curves.easeOut.transform(_singleBounceController.value);
|
||||||
|
// Single bounce: up to 10px then back to 0
|
||||||
|
if (t < 0.5) {
|
||||||
|
_bounceOffsetNotifier.value = 10.0 * (t * 2); // 0 -> 10
|
||||||
|
} else {
|
||||||
|
_bounceOffsetNotifier.value = 10.0 * ((1.0 - t) * 2); // 10 -> 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hint bounce - single gentle bounce to draw attention
|
||||||
|
_doubleBounceController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_doubleBounceController.addListener(() {
|
||||||
|
final t = Curves.easeOut.transform(_doubleBounceController.value);
|
||||||
|
// Single bounce: up 20px then back down
|
||||||
|
if (t < 0.5) {
|
||||||
|
_bounceOffsetNotifier.value = 20.0 * (t * 2); // 0 -> 20
|
||||||
|
} else {
|
||||||
|
_bounceOffsetNotifier.value = 20.0 * ((1.0 - t) * 2); // 20 -> 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Welcome content fade-in
|
||||||
|
_welcomeFadeController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 800),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load hint settings and start welcome immediately if needed
|
||||||
|
_loadHintSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHintSettings() async {
|
||||||
|
_showHints = await SettingsService.getShowHints();
|
||||||
|
_hasCompletedOnboarding = await SettingsService.getHasCompletedOnboarding();
|
||||||
|
// Show welcome screen only on first use (not completed onboarding yet)
|
||||||
|
if (!_hasCompletedOnboarding && mounted && !_hintTriggered) {
|
||||||
|
_startWelcomeScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the welcome screen with fade-in animation
|
||||||
|
void _startWelcomeScreen() {
|
||||||
|
if (_hintTriggered) return;
|
||||||
|
_hintTriggered = true;
|
||||||
|
|
||||||
|
// Activate hint mode immediately (blur backdrop visible)
|
||||||
|
setState(() {
|
||||||
|
_isHintModeActive = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade in welcome content
|
||||||
|
_welcomeFadeController.forward();
|
||||||
|
|
||||||
|
// Start bounce animation after fade completes
|
||||||
|
_welcomeFadeController.addStatusListener((status) {
|
||||||
|
if (status == AnimationStatus.completed && mounted) {
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
|
||||||
|
// Repeat bounce every 2 seconds
|
||||||
|
_hintBounceTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
if (_isHintModeActive && mounted) {
|
||||||
|
_doubleBounceController.reset();
|
||||||
|
_doubleBounceController.forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
|
_singleBounceController.dispose();
|
||||||
|
_doubleBounceController.dispose();
|
||||||
|
_welcomeFadeController.dispose();
|
||||||
|
_bounceOffsetNotifier.dispose();
|
||||||
|
_hintOpacityNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,12 +290,91 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPlayerReveal() {
|
||||||
|
if (_isRevealVisible) return;
|
||||||
|
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||||
|
GlobalPlayerOverlay.collapsePlayer();
|
||||||
|
}
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// Track if coming from hint mode (for onboarding hints in player selector)
|
||||||
|
final wasInHintMode = _isHintModeActive;
|
||||||
|
|
||||||
|
// End hint mode if active (user learned the gesture!)
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = null;
|
||||||
|
_hintOpacityNotifier.value = 0.0;
|
||||||
|
_isHintModeActive = false;
|
||||||
|
|
||||||
|
// Mark onboarding as completed if coming from welcome screen
|
||||||
|
if (wasInHintMode) {
|
||||||
|
SettingsService.setHasCompletedOnboarding(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger single bounce on expand
|
||||||
|
_singleBounceController.reset();
|
||||||
|
_singleBounceController.forward();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRevealVisible = true;
|
||||||
|
_isOnboardingReveal = wasInHintMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hidePlayerReveal() {
|
||||||
|
if (!_isRevealVisible) return;
|
||||||
|
_bounceOffsetNotifier.value = 0;
|
||||||
|
setState(() {
|
||||||
|
_isRevealVisible = false;
|
||||||
|
_isOnboardingReveal = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss with animation (for back gesture) - calls overlay's dismiss method
|
||||||
|
void _dismissPlayerReveal() {
|
||||||
|
if (!_isRevealVisible) return;
|
||||||
|
// Call the overlay's dismiss method which has the slide animation
|
||||||
|
_revealKey.currentState?.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger single bounce on mini player (called when device selector collapses)
|
||||||
|
void _triggerBounce() {
|
||||||
|
_singleBounceController.reset();
|
||||||
|
_singleBounceController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End hint mode (called when user taps skip button)
|
||||||
|
void _endHintMode() {
|
||||||
|
if (!_isHintModeActive) return;
|
||||||
|
_hintBounceTimer?.cancel();
|
||||||
|
_hintBounceTimer = null;
|
||||||
|
_hintOpacityNotifier.value = 0.0;
|
||||||
|
_bounceOffsetNotifier.value = 0.0;
|
||||||
|
// Mark onboarding as completed (first use only)
|
||||||
|
SettingsService.setHasCompletedOnboarding(true);
|
||||||
|
setState(() {
|
||||||
|
_isHintModeActive = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Stack(
|
// Handle back gesture at top level - dismiss hint mode or device list if visible
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isRevealVisible && !_isHintModeActive,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop) {
|
||||||
|
if (_isHintModeActive) {
|
||||||
|
_endHintMode();
|
||||||
|
} else if (_isRevealVisible) {
|
||||||
|
_dismissPlayerReveal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// The main app content (Navigator, screens, etc.)
|
// The main app content (Navigator, screens, etc.)
|
||||||
// Add bottom padding to account for bottom nav + mini player
|
// Add bottom padding to account for bottom nav + mini player
|
||||||
@@ -206,26 +430,26 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
selectedFontSize: 12,
|
selectedFontSize: 12,
|
||||||
unselectedFontSize: 12,
|
unselectedFontSize: 12,
|
||||||
items: const [
|
items: [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
activeIcon: Icon(Icons.home_rounded),
|
activeIcon: const Icon(Icons.home_rounded),
|
||||||
label: 'Home',
|
label: S.of(context)!.home,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.library_music_outlined),
|
icon: const Icon(Icons.library_music_outlined),
|
||||||
activeIcon: Icon(Icons.library_music_rounded),
|
activeIcon: const Icon(Icons.library_music_rounded),
|
||||||
label: 'Library',
|
label: S.of(context)!.library,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.search_rounded),
|
icon: const Icon(Icons.search_rounded),
|
||||||
activeIcon: Icon(Icons.search_rounded),
|
activeIcon: const Icon(Icons.search_rounded),
|
||||||
label: 'Search',
|
label: S.of(context)!.search,
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
activeIcon: Icon(Icons.settings_rounded),
|
activeIcon: const Icon(Icons.settings_rounded),
|
||||||
label: 'Settings',
|
label: S.of(context)!.settings,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -258,32 +482,172 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Global player overlay - slides down when hidden, renders ON TOP of bottom nav
|
// Blur backdrop for device selector (reveal mode) - static, no animation
|
||||||
|
if (_isRevealVisible && !_isHintModeActive)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _dismissPlayerReveal,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Blur backdrop for hint/welcome mode - animated fade from solid to semi-transparent
|
||||||
|
if (_isHintModeActive)
|
||||||
|
Positioned.fill(
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
// Hold solid for 2s, then fade to 0.5 over 1s
|
||||||
|
tween: Tween<double>(begin: 1.0, end: 0.5),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
curve: const Interval(0.67, 1.0, curve: Curves.easeOut),
|
||||||
|
builder: (context, opacity, child) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
|
child: Container(
|
||||||
|
color: colorScheme.surface.withOpacity(opacity),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Welcome message during hint mode - two positioned sections with fade-in
|
||||||
|
// Top section: Logo and Welcome title (stays fixed at top area)
|
||||||
|
if (_isHintModeActive)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
// Position logo section high up - about 1/3 from top
|
||||||
|
top: MediaQuery.of(context).size.height * 0.15,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: _welcomeFadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Ensemble logo - same as settings screen
|
||||||
|
Image.asset(
|
||||||
|
'assets/images/ensemble_icon_transparent.png',
|
||||||
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Welcome title
|
||||||
|
Text(
|
||||||
|
S.of(context)!.welcomeToEnsemble,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom section: Hint text and Skip button (positioned relative to mini player)
|
||||||
|
if (_isHintModeActive)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
// Position so skip button is ~32px above mini player, matching skip-to-miniplayer gap
|
||||||
|
bottom: BottomSpacing.navBarHeight + BottomSpacing.miniPlayerHeight + MediaQuery.of(context).padding.bottom + 32,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: _welcomeFadeController,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Hint text
|
||||||
|
Text(
|
||||||
|
S.of(context)!.welcomeMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
// Skip button - same gap above as below to mini player
|
||||||
|
TextButton(
|
||||||
|
onPressed: _endHintMode,
|
||||||
|
child: Text(
|
||||||
|
S.of(context)!.skip,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player reveal overlay - renders BELOW mini player so cards slide behind it
|
||||||
|
if (_isRevealVisible)
|
||||||
|
PlayerRevealOverlay(
|
||||||
|
key: _revealKey,
|
||||||
|
onDismiss: _hidePlayerReveal,
|
||||||
|
miniPlayerBottom: BottomSpacing.navBarHeight + MediaQuery.of(context).padding.bottom + 12,
|
||||||
|
miniPlayerHeight: 64,
|
||||||
|
showOnboardingHints: _isOnboardingReveal,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Global player overlay - renders ON TOP so cards slide behind it
|
||||||
// Use Selector instead of Consumer to avoid rebuilds during animation
|
// Use Selector instead of Consumer to avoid rebuilds during animation
|
||||||
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer})>(
|
Selector<MusicAssistantProvider, ({bool isConnected, bool hasPlayer, bool hasTrack})>(
|
||||||
selector: (_, provider) => (
|
selector: (_, provider) => (
|
||||||
isConnected: provider.isConnected,
|
isConnected: provider.isConnected,
|
||||||
hasPlayer: provider.selectedPlayer != null,
|
hasPlayer: provider.selectedPlayer != null,
|
||||||
|
hasTrack: provider.currentTrack != null,
|
||||||
),
|
),
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
// Only show player if connected and has a selected player
|
// Only show player if connected and has a selected player
|
||||||
if (!state.isConnected || !state.hasPlayer) {
|
if (!state.isConnected || !state.hasPlayer) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _slideAnimation,
|
// Combine slide, bounce, and hint animations with ValueListenableBuilders
|
||||||
child: ExpandablePlayer(key: globalPlayerKey, slideOffset: 0),
|
// This prevents full widget tree rebuilds - only ExpandablePlayer updates
|
||||||
builder: (context, staticChild) {
|
return ValueListenableBuilder<double>(
|
||||||
// Only slideOffset changes during animation, player widget stays same
|
valueListenable: _bounceOffsetNotifier,
|
||||||
return ExpandablePlayer(
|
builder: (context, bounceOffset, _) {
|
||||||
key: globalPlayerKey,
|
return ValueListenableBuilder<double>(
|
||||||
slideOffset: _slideAnimation.value,
|
valueListenable: _hintOpacityNotifier,
|
||||||
|
builder: (context, hintOpacity, _) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _slideAnimation,
|
||||||
|
builder: (context, _) {
|
||||||
|
return ExpandablePlayer(
|
||||||
|
key: globalPlayerKey,
|
||||||
|
slideOffset: _slideAnimation.value,
|
||||||
|
bounceOffset: bounceOffset,
|
||||||
|
onRevealPlayers: _showPlayerReveal,
|
||||||
|
isDeviceRevealVisible: _isRevealVisible,
|
||||||
|
isHintVisible: hintOpacity > 0,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../models/media_item.dart' show Audiobook, Chapter;
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../common/empty_state.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// Panel that displays audiobook chapters for navigation
|
||||||
|
class ChaptersPanel extends StatelessWidget {
|
||||||
|
final MusicAssistantProvider maProvider;
|
||||||
|
final Audiobook? audiobook;
|
||||||
|
final Color textColor;
|
||||||
|
final Color primaryColor;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double topPadding;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const ChaptersPanel({
|
||||||
|
super.key,
|
||||||
|
required this.maProvider,
|
||||||
|
required this.audiobook,
|
||||||
|
required this.textColor,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.topPadding,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final duration = Duration(seconds: seconds);
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes % 60;
|
||||||
|
final secs = duration.inSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
return '${minutes}:${secs.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final chapters = audiobook?.chapters ?? [];
|
||||||
|
final currentChapterIndex = maProvider.getCurrentChapterIndex();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: backgroundColor,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(top: topPadding + 4, left: 4, right: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back_rounded, color: textColor, size: IconSizes.md),
|
||||||
|
onPressed: onClose,
|
||||||
|
padding: Spacing.paddingAll12,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
S.of(context)!.chapters,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Placeholder for symmetry
|
||||||
|
const SizedBox(width: 48),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Chapters list
|
||||||
|
Expanded(
|
||||||
|
child: chapters.isEmpty
|
||||||
|
? _buildEmptyState(context)
|
||||||
|
: _buildChaptersList(chapters, currentChapterIndex),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
|
return EmptyState.custom(
|
||||||
|
context: context,
|
||||||
|
icon: Icons.bookmark_outline_rounded,
|
||||||
|
title: S.of(context)!.noChapters,
|
||||||
|
subtitle: S.of(context)!.noChapterInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChaptersList(List<Chapter> chapters, int currentChapterIndex) {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: Spacing.paddingH8,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemCount: chapters.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final chapter = chapters[index];
|
||||||
|
final isCurrentChapter = index == currentChapterIndex;
|
||||||
|
final isPastChapter = index < currentChapterIndex;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: isPastChapter ? 0.5 : 1.0,
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrentChapter ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(Radii.md),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCurrentChapter
|
||||||
|
? primaryColor.withOpacity(0.2)
|
||||||
|
: textColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(Radii.sm),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentChapter ? primaryColor : textColor.withOpacity(0.6),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
chapter.title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentChapter ? primaryColor : textColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: isCurrentChapter ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_formatDuration(chapter.positionMs ~/ 1000),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.5),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isCurrentChapter
|
||||||
|
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
||||||
|
: null,
|
||||||
|
onTap: () => _seekToChapter(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seekToChapter(int chapterIndex) {
|
||||||
|
final player = maProvider.selectedPlayer;
|
||||||
|
if (player == null || audiobook == null) return;
|
||||||
|
|
||||||
|
final chapters = audiobook!.chapters;
|
||||||
|
if (chapters == null || chapterIndex >= chapters.length) return;
|
||||||
|
|
||||||
|
final chapter = chapters[chapterIndex];
|
||||||
|
// Seek to the chapter's start position (positionMs is in milliseconds)
|
||||||
|
maProvider.seek(player.playerId, (chapter.positionMs / 1000).round());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import 'mini_player_content.dart';
|
||||||
|
|
||||||
/// A compact device selector bar shown when no track is playing
|
/// A compact device selector bar shown when no track is playing
|
||||||
class DeviceSelectorBar extends StatelessWidget {
|
class DeviceSelectorBar extends StatelessWidget {
|
||||||
@@ -34,6 +35,8 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final swipeHint = hasMultiplePlayers ? S.of(context)!.swipeToSwitchDevice : null;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
onHorizontalDragStart: hasMultiplePlayers ? onHorizontalDragStart : null,
|
||||||
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
onHorizontalDragUpdate: hasMultiplePlayers ? onHorizontalDragUpdate : null,
|
||||||
@@ -52,50 +55,19 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Peek player content (shows when dragging)
|
// Peek player content (shows when dragging)
|
||||||
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
if (slideOffset.abs() > 0.01 && peekPlayer != null)
|
||||||
_buildPeekContent(),
|
_buildPeekContent(context, swipeHint),
|
||||||
|
|
||||||
// Current player content
|
// Current player content
|
||||||
Transform.translate(
|
MiniPlayerContent(
|
||||||
offset: Offset(slideOffset * width, 0),
|
primaryText: selectedPlayer.name,
|
||||||
child: Padding(
|
secondaryText: swipeHint,
|
||||||
padding: Spacing.paddingH16,
|
imageUrl: null,
|
||||||
child: Row(
|
playerName: selectedPlayer.name,
|
||||||
children: [
|
backgroundColor: backgroundColor,
|
||||||
Icon(
|
textColor: textColor,
|
||||||
_getPlayerIcon(selectedPlayer.name),
|
width: width,
|
||||||
color: textColor,
|
slideOffset: slideOffset,
|
||||||
size: IconSizes.md,
|
isHint: swipeHint != null,
|
||||||
),
|
|
||||||
Spacing.hGap12,
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
selectedPlayer.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (hasMultiplePlayers)
|
|
||||||
Text(
|
|
||||||
'Swipe to switch device',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -106,7 +78,7 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build peek player content that slides in from the edge
|
/// Build peek player content that slides in from the edge
|
||||||
Widget _buildPeekContent() {
|
Widget _buildPeekContent(BuildContext context, String? swipeHint) {
|
||||||
final isFromRight = slideOffset < 0;
|
final isFromRight = slideOffset < 0;
|
||||||
final peekProgress = slideOffset.abs();
|
final peekProgress = slideOffset.abs();
|
||||||
|
|
||||||
@@ -117,61 +89,17 @@ class DeviceSelectorBar extends StatelessWidget {
|
|||||||
|
|
||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(peekBaseOffset, 0),
|
offset: Offset(peekBaseOffset, 0),
|
||||||
child: Padding(
|
child: MiniPlayerContent(
|
||||||
padding: Spacing.paddingH16,
|
primaryText: peekPlayer.name,
|
||||||
child: Row(
|
secondaryText: swipeHint,
|
||||||
children: [
|
imageUrl: null,
|
||||||
Icon(
|
playerName: peekPlayer.name,
|
||||||
_getPlayerIcon(peekPlayer.name),
|
backgroundColor: backgroundColor,
|
||||||
color: textColor,
|
textColor: textColor,
|
||||||
size: IconSizes.md,
|
width: width,
|
||||||
),
|
slideOffset: 0, // No additional slide - Transform handles positioning
|
||||||
Spacing.hGap12,
|
isHint: swipeHint != null,
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
peekPlayer.name,
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
if (hasMultiplePlayers)
|
|
||||||
Text(
|
|
||||||
'Swipe to switch device',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor.withOpacity(0.6),
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get appropriate icon for player based on name
|
|
||||||
IconData _getPlayerIcon(String playerName) {
|
|
||||||
final nameLower = playerName.toLowerCase();
|
|
||||||
if (nameLower.contains('phone') || nameLower.contains('ensemble')) {
|
|
||||||
return Icons.phone_android_rounded;
|
|
||||||
} else if (nameLower.contains('group')) {
|
|
||||||
return Icons.speaker_group_rounded;
|
|
||||||
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
|
||||||
return Icons.tv_rounded;
|
|
||||||
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
|
||||||
return Icons.cast_rounded;
|
|
||||||
} else {
|
|
||||||
return Icons.speaker_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
|
/// Shared constants for mini player layout
|
||||||
|
class MiniPlayerLayout {
|
||||||
|
static const double height = 64.0;
|
||||||
|
static const double artSize = 64.0;
|
||||||
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
||||||
|
static const double textLeft = artSize + textLeftOffset; // 74px
|
||||||
|
static const double primaryTop = 13.0;
|
||||||
|
static const double secondaryTop = 33.0;
|
||||||
|
static const double textRightPadding = 12.0;
|
||||||
|
static const double iconSize = 28.0;
|
||||||
|
static const double iconOpacity = 0.4;
|
||||||
|
static const double secondaryTextOpacity = 0.6;
|
||||||
|
static const double primaryFontSize = 16.0;
|
||||||
|
static const double secondaryFontSize = 14.0;
|
||||||
|
static const FontWeight primaryFontWeight = FontWeight.w500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified mini player content widget used by:
|
||||||
|
/// - DeviceSelectorBar (non-playing state)
|
||||||
|
/// - ExpandablePlayer collapsed state (playing state)
|
||||||
|
/// - Peek content during swipe gestures
|
||||||
|
class MiniPlayerContent extends StatelessWidget {
|
||||||
|
/// Primary text line (track name or device name)
|
||||||
|
final String primaryText;
|
||||||
|
|
||||||
|
/// Secondary text line (artist name or "Swipe to switch device")
|
||||||
|
/// If null, primary text will be vertically centered
|
||||||
|
final String? secondaryText;
|
||||||
|
|
||||||
|
/// Album art URL - if null, shows device icon
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
/// Player name for icon selection when no image
|
||||||
|
final String playerName;
|
||||||
|
|
||||||
|
/// Background color for the content area
|
||||||
|
final Color backgroundColor;
|
||||||
|
|
||||||
|
/// Text color
|
||||||
|
final Color textColor;
|
||||||
|
|
||||||
|
/// Width of the content area
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// Horizontal slide offset for swipe animation (-1 to 1)
|
||||||
|
final double slideOffset;
|
||||||
|
|
||||||
|
/// Whether secondary text is a hint (shows lightbulb icon)
|
||||||
|
final bool isHint;
|
||||||
|
|
||||||
|
/// Whether to show the progress bar overlay (for playing state)
|
||||||
|
final bool showProgress;
|
||||||
|
|
||||||
|
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
const MiniPlayerContent({
|
||||||
|
super.key,
|
||||||
|
required this.primaryText,
|
||||||
|
this.secondaryText,
|
||||||
|
this.imageUrl,
|
||||||
|
required this.playerName,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.width,
|
||||||
|
this.slideOffset = 0.0,
|
||||||
|
this.isHint = false,
|
||||||
|
this.showProgress = false,
|
||||||
|
this.progress = 0.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
||||||
|
final slidePixels = slideOffset * width;
|
||||||
|
|
||||||
|
// Calculate text width (leaves room for right padding)
|
||||||
|
final textWidth = width - MiniPlayerLayout.textLeft - MiniPlayerLayout.textRightPadding;
|
||||||
|
|
||||||
|
// Darkened background for icon area (matches DeviceSelectorBar)
|
||||||
|
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: MiniPlayerLayout.height,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// Progress bar background (only for playing state)
|
||||||
|
if (showProgress && progress > 0)
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.artSize + slidePixels,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: (width - MiniPlayerLayout.artSize) * progress,
|
||||||
|
child: Container(color: backgroundColor),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Art / Icon area
|
||||||
|
Positioned(
|
||||||
|
left: slidePixels,
|
||||||
|
top: 0,
|
||||||
|
child: SizedBox(
|
||||||
|
width: MiniPlayerLayout.artSize,
|
||||||
|
height: MiniPlayerLayout.artSize,
|
||||||
|
child: imageUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 256,
|
||||||
|
memCacheHeight: 256,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
|
||||||
|
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
|
||||||
|
)
|
||||||
|
: _buildIconArea(iconAreaBackground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Primary text line
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
|
top: hasSecondaryLine
|
||||||
|
? MiniPlayerLayout.primaryTop
|
||||||
|
: (MiniPlayerLayout.height - MiniPlayerLayout.primaryFontSize) / 2,
|
||||||
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
|
child: Text(
|
||||||
|
primaryText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: MiniPlayerLayout.primaryFontSize,
|
||||||
|
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Secondary text line (with optional hint icon)
|
||||||
|
if (hasSecondaryLine)
|
||||||
|
Positioned(
|
||||||
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
||||||
|
top: MiniPlayerLayout.secondaryTop,
|
||||||
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
||||||
|
child: isHint
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lightbulb_outline,
|
||||||
|
size: 14,
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
secondaryText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
secondaryText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
||||||
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the icon area with device-appropriate icon
|
||||||
|
Widget _buildIconArea(Color background) {
|
||||||
|
return Container(
|
||||||
|
color: background,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
_getPlayerIcon(playerName),
|
||||||
|
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
|
||||||
|
size: MiniPlayerLayout.iconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get appropriate icon based on player name
|
||||||
|
static IconData _getPlayerIcon(String playerName) {
|
||||||
|
final nameLower = playerName.toLowerCase();
|
||||||
|
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
|
||||||
|
return Icons.phone_android_rounded;
|
||||||
|
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
|
||||||
|
return Icons.speaker_group_rounded;
|
||||||
|
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
||||||
|
return Icons.tv_rounded;
|
||||||
|
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
||||||
|
return Icons.cast_rounded;
|
||||||
|
} else {
|
||||||
|
return Icons.speaker_rounded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
/// A player card that matches the mini player's visual style.
|
||||||
|
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||||
|
class PlayerCard extends StatelessWidget {
|
||||||
|
final dynamic player;
|
||||||
|
final dynamic trackInfo;
|
||||||
|
final String? albumArtUrl;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool isGrouped;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color textColor;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
final VoidCallback? onPlayPause;
|
||||||
|
final VoidCallback? onSkipNext;
|
||||||
|
final VoidCallback? onPower;
|
||||||
|
|
||||||
|
// Pastel yellow for grouped players
|
||||||
|
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
|
const PlayerCard({
|
||||||
|
super.key,
|
||||||
|
required this.player,
|
||||||
|
this.trackInfo,
|
||||||
|
this.albumArtUrl,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isPlaying,
|
||||||
|
this.isGrouped = false,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.onPlayPause,
|
||||||
|
this.onSkipNext,
|
||||||
|
this.onPower,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Match mini player dimensions
|
||||||
|
const double cardHeight = Dimensions.miniPlayerHeight; // 64px
|
||||||
|
const double artSize = cardHeight;
|
||||||
|
const double borderRadius = Radii.xl; // 16px - same as mini player
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Container(
|
||||||
|
height: cardHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: isGrouped
|
||||||
|
? Border.all(color: groupBorderColor, width: 1.5)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Album art or speaker icon - same size for consistent text alignment
|
||||||
|
SizedBox(
|
||||||
|
width: artSize,
|
||||||
|
height: artSize,
|
||||||
|
child: albumArtUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: albumArtUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 128,
|
||||||
|
memCacheHeight: 128,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => _buildSpeakerIcon(),
|
||||||
|
errorWidget: (_, __, ___) => _buildSpeakerIcon(),
|
||||||
|
)
|
||||||
|
: _buildSpeakerIcon(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player info
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Player name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Status indicator
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _getStatusColor(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
player.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Track name or status
|
||||||
|
Text(
|
||||||
|
_getSubtitle(context),
|
||||||
|
style: TextStyle(
|
||||||
|
color: textColor.withOpacity(0.6),
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
decoration: TextDecoration.none,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Transport controls - compact sizing to align with mini player
|
||||||
|
// Play/Pause and Next only shown when powered with content
|
||||||
|
if (player.available && player.powered && trackInfo != null) ...[
|
||||||
|
// Play/Pause - slight nudge right
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(3, 0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: onPlayPause,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Skip next - nudged right to close gap with power
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(6, 0),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.skip_next_rounded,
|
||||||
|
color: textColor,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
onPressed: onSkipNext,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Power button - smallest
|
||||||
|
if (player.available)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.power_settings_new_rounded,
|
||||||
|
color: player.powered ? textColor : textColor.withOpacity(0.5),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: onPower,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSpeakerIcon() {
|
||||||
|
// Darker shade of the card background to match album art square
|
||||||
|
final iconBgColor = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
||||||
|
return Container(
|
||||||
|
color: iconBgColor,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.speaker_rounded,
|
||||||
|
color: textColor.withOpacity(0.4),
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getStatusColor() {
|
||||||
|
if (!player.available) {
|
||||||
|
return Colors.grey.withOpacity(0.5);
|
||||||
|
}
|
||||||
|
if (!player.powered) {
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
if (isPlaying) {
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
if (trackInfo != null) {
|
||||||
|
return Colors.orange; // Has content but paused
|
||||||
|
}
|
||||||
|
return Colors.grey.shade400; // Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getSubtitle(BuildContext context) {
|
||||||
|
if (!player.available) {
|
||||||
|
return S.of(context)!.playerStateUnavailable;
|
||||||
|
}
|
||||||
|
if (!player.powered) {
|
||||||
|
return S.of(context)!.playerStateOff;
|
||||||
|
}
|
||||||
|
if (trackInfo != null) {
|
||||||
|
return trackInfo.name ?? S.of(context)!.playerStateIdle;
|
||||||
|
}
|
||||||
|
return S.of(context)!.playerStateIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,14 +55,13 @@ class PlayerControls extends StatelessWidget {
|
|||||||
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
mainAxisAlignment: isExpanded ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Shuffle (expanded only)
|
// Shuffle (expanded only)
|
||||||
if (isExpanded)
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||||
Opacity(
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
opacity: expandedElementsOpacity,
|
_buildSecondaryButton(
|
||||||
child: _buildSecondaryButton(
|
icon: Icons.shuffle_rounded,
|
||||||
icon: Icons.shuffle_rounded,
|
color: (shuffle == true ? primaryColor : textColor.withOpacity(0.5))
|
||||||
color: shuffle == true ? primaryColor : textColor.withOpacity(0.5),
|
.withOpacity(expandedElementsOpacity),
|
||||||
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
onPressed: isLoadingQueue ? null : onToggleShuffle,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
|
|
||||||
@@ -90,17 +89,16 @@ class PlayerControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Repeat (expanded only)
|
// Repeat (expanded only)
|
||||||
|
// GPU PERF: Use color alpha instead of Opacity to avoid saveLayer
|
||||||
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
if (isExpanded) SizedBox(width: _lerpDouble(0, 20, t)),
|
||||||
if (isExpanded)
|
if (isExpanded && expandedElementsOpacity > 0.1)
|
||||||
Opacity(
|
_buildSecondaryButton(
|
||||||
opacity: expandedElementsOpacity,
|
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
||||||
child: _buildSecondaryButton(
|
color: (repeatMode != null && repeatMode != 'off'
|
||||||
icon: repeatMode == 'one' ? Icons.repeat_one_rounded : Icons.repeat_rounded,
|
? primaryColor
|
||||||
color: repeatMode != null && repeatMode != 'off'
|
: textColor.withOpacity(0.5))
|
||||||
? primaryColor
|
.withOpacity(expandedElementsOpacity),
|
||||||
: textColor.withOpacity(0.5),
|
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
||||||
onPressed: isLoadingQueue ? null : onCycleRepeat,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../providers/music_assistant_provider.dart';
|
||||||
|
import '../../services/settings_service.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../theme/theme_provider.dart';
|
||||||
|
import '../../theme/palette_helper.dart';
|
||||||
|
import '../../constants/timings.dart';
|
||||||
|
import '../global_player_overlay.dart';
|
||||||
|
import 'player_card.dart';
|
||||||
|
|
||||||
|
/// Overlay that reveals all available players with a staggered slide-up animation.
|
||||||
|
/// Triggered by swiping down on the mini player or tapping the device button.
|
||||||
|
class PlayerRevealOverlay extends StatefulWidget {
|
||||||
|
final VoidCallback onDismiss;
|
||||||
|
final double miniPlayerBottom; // Bottom position of mini player
|
||||||
|
final double miniPlayerHeight;
|
||||||
|
final bool showOnboardingHints; // Show additional hints for first-time users
|
||||||
|
|
||||||
|
const PlayerRevealOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.onDismiss,
|
||||||
|
required this.miniPlayerBottom,
|
||||||
|
required this.miniPlayerHeight,
|
||||||
|
this.showOnboardingHints = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerRevealOverlay> createState() => PlayerRevealOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _revealController;
|
||||||
|
late Animation<double> _revealAnimation;
|
||||||
|
late Animation<double> _backdropAnimation;
|
||||||
|
|
||||||
|
Timer? _refreshTimer;
|
||||||
|
|
||||||
|
// Track vertical drag for dismissal
|
||||||
|
double _dragOffset = 0;
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
// Cache extracted colors per player for per-device accent colors
|
||||||
|
final Map<String, ColorScheme?> _playerColors = {};
|
||||||
|
|
||||||
|
// Hint system
|
||||||
|
bool _showHints = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Load hint settings
|
||||||
|
SettingsService.getShowHints().then((value) {
|
||||||
|
if (mounted) setState(() => _showHints = value);
|
||||||
|
});
|
||||||
|
|
||||||
|
_revealController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Quick smooth animation with subtle overshoot
|
||||||
|
_revealAnimation = CurvedAnimation(
|
||||||
|
parent: _revealController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
|
||||||
|
_backdropAnimation = CurvedAnimation(
|
||||||
|
parent: _revealController,
|
||||||
|
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preload all player track info and extract colors
|
||||||
|
_preloadColorsForPlayers();
|
||||||
|
|
||||||
|
// Start reveal animation
|
||||||
|
_revealController.duration = const Duration(milliseconds: 200);
|
||||||
|
_revealController.forward();
|
||||||
|
|
||||||
|
// Auto-refresh player data
|
||||||
|
_refreshTimer = Timer.periodic(Timings.playerPollingInterval, (_) {
|
||||||
|
if (mounted) {
|
||||||
|
final provider = context.read<MusicAssistantProvider>();
|
||||||
|
provider.refreshPlayers();
|
||||||
|
provider.preloadAllPlayerTracks().then((_) => _preloadColorsForPlayers());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract accent colors from album art for each player
|
||||||
|
Future<void> _preloadColorsForPlayers() async {
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
await maProvider.preloadAllPlayerTracks();
|
||||||
|
|
||||||
|
final players = maProvider.availablePlayers;
|
||||||
|
for (final player in players) {
|
||||||
|
if (_playerColors.containsKey(player.playerId)) continue;
|
||||||
|
|
||||||
|
final track = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
if (track == null) continue;
|
||||||
|
|
||||||
|
final imageUrl = maProvider.getImageUrl(track, size: 128);
|
||||||
|
if (imageUrl == null) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final imageProvider = NetworkImage(imageUrl);
|
||||||
|
final schemes = await PaletteHelper.extractColorSchemes(imageProvider);
|
||||||
|
if (mounted && schemes != null) {
|
||||||
|
final colorScheme = isDark ? schemes.$2 : schemes.$1;
|
||||||
|
setState(() {
|
||||||
|
_playerColors[player.playerId] = colorScheme;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore color extraction errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_revealController.dispose();
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate reveal (called from parent)
|
||||||
|
void reveal() {
|
||||||
|
_revealController.duration = const Duration(milliseconds: 200);
|
||||||
|
_revealController.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animate dismissal and call callback when done
|
||||||
|
void dismiss() {
|
||||||
|
// Trigger single bounce on mini player when cards are ~70% collapsed
|
||||||
|
// This creates the effect of cards "landing" on the mini player
|
||||||
|
bool bounceFired = false;
|
||||||
|
void checkBounce() {
|
||||||
|
if (!bounceFired && _revealController.value < 0.3) {
|
||||||
|
bounceFired = true;
|
||||||
|
GlobalPlayerOverlay.triggerBounce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_revealController.duration = const Duration(milliseconds: 150);
|
||||||
|
_revealController.addListener(checkBounce);
|
||||||
|
_revealController.reverse().then((_) {
|
||||||
|
_revealController.removeListener(checkBounce);
|
||||||
|
widget.onDismiss();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleVerticalDragUpdate(DragUpdateDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isDragging = true;
|
||||||
|
// Negate delta so swipe down = cards move down (toward dismissal)
|
||||||
|
_dragOffset -= details.primaryDelta ?? 0;
|
||||||
|
// Clamp: allow moving down (positive) freely, limit moving up to 20px
|
||||||
|
_dragOffset = _dragOffset.clamp(-20.0, double.infinity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleVerticalDragEnd(DragEndDetails details) {
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
|
||||||
|
// Dismiss if dragged down enough or with enough velocity
|
||||||
|
if (_dragOffset > 100 || velocity > 500) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
// Reset drag offset before dismiss so animation starts from correct position
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
dismiss();
|
||||||
|
} else {
|
||||||
|
// Spring back
|
||||||
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
_dragOffset = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
// Back gesture is handled at GlobalPlayerOverlay level
|
||||||
|
return Consumer<MusicAssistantProvider>(
|
||||||
|
builder: (context, maProvider, child) {
|
||||||
|
final allPlayers = maProvider.availablePlayers;
|
||||||
|
final selectedPlayer = maProvider.selectedPlayer;
|
||||||
|
final currentTrack = maProvider.currentTrack;
|
||||||
|
|
||||||
|
// Filter out the selected player - it's already shown in the mini player
|
||||||
|
final players = allPlayers
|
||||||
|
.where((p) => p.playerId != selectedPlayer?.playerId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Default card colors - used when player doesn't have extracted colors
|
||||||
|
final defaultBgColor = colorScheme.primaryContainer;
|
||||||
|
final defaultTextColor = colorScheme.onPrimaryContainer;
|
||||||
|
|
||||||
|
// Calculate total stack height for animation
|
||||||
|
const cardHeight = 64.0;
|
||||||
|
const cardSpacing = 12.0;
|
||||||
|
final totalStackHeight = players.length * cardHeight + (players.length - 1) * cardSpacing;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _revealAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final t = _revealAnimation.value;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Backdrop - tap to dismiss (darkness handled by GlobalPlayerOverlay)
|
||||||
|
Positioned.fill(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: dismiss,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Player cards - positioned above mini player
|
||||||
|
Positioned(
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: widget.miniPlayerBottom + widget.miniPlayerHeight + 8 - _dragOffset,
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragUpdate: _handleVerticalDragUpdate,
|
||||||
|
onVerticalDragEnd: _handleVerticalDragEnd,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Hint above player list - different for onboarding vs regular use
|
||||||
|
// Onboarding: show first-time hint (how to select)
|
||||||
|
// Regular: show sync hint (tap and hold to sync)
|
||||||
|
if (players.isNotEmpty && (_showHints || widget.showOnboardingHints))
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
// Calculate slide offset to match the topmost card
|
||||||
|
const baseOffset = 80.0;
|
||||||
|
final hintDistanceToTravel = baseOffset + (players.length * (cardHeight + cardSpacing));
|
||||||
|
final hintSlideOffset = hintDistanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
// Show different hint for onboarding vs regular use
|
||||||
|
final isOnboarding = widget.showOnboardingHints;
|
||||||
|
final hintText = isOnboarding
|
||||||
|
? S.of(context)!.selectPlayerHint
|
||||||
|
: S.of(context)!.holdToSync;
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, hintSlideOffset),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isOnboarding ? Icons.touch_app_outlined : Icons.lightbulb_outline,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
hintText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Build player cards - all slide from behind mini player
|
||||||
|
...List.generate(players.length, (index) {
|
||||||
|
final player = players[index];
|
||||||
|
final isPlaying = player.state == 'playing';
|
||||||
|
|
||||||
|
// Get track info for this player
|
||||||
|
final playerTrack = maProvider.getCachedTrackForPlayer(player.playerId);
|
||||||
|
|
||||||
|
// Get album art URL - use track directly like mini player does
|
||||||
|
String? albumArtUrl;
|
||||||
|
if (playerTrack != null && player.available && player.powered) {
|
||||||
|
albumArtUrl = maProvider.getImageUrl(playerTrack, size: 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use per-player colors if available, otherwise use defaults
|
||||||
|
final playerColorScheme = _playerColors[player.playerId];
|
||||||
|
final cardBgColor = playerColorScheme?.primaryContainer ?? defaultBgColor;
|
||||||
|
final cardTextColor = playerColorScheme?.onPrimaryContainer ?? defaultTextColor;
|
||||||
|
|
||||||
|
// Animation: all cards start hidden behind mini player
|
||||||
|
// and fan out to their final positions with spring physics
|
||||||
|
const baseOffset = 80.0;
|
||||||
|
final reverseIndex = players.length - 1 - index;
|
||||||
|
final distanceToTravel = baseOffset + (reverseIndex * (cardHeight + cardSpacing));
|
||||||
|
|
||||||
|
// Use the elastic animation value directly (already has bounce)
|
||||||
|
final slideOffset = distanceToTravel * (1.0 - t);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, slideOffset),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: PlayerCard(
|
||||||
|
player: player,
|
||||||
|
trackInfo: playerTrack,
|
||||||
|
albumArtUrl: albumArtUrl,
|
||||||
|
isSelected: false,
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
isGrouped: player.isGrouped,
|
||||||
|
backgroundColor: cardBgColor,
|
||||||
|
textColor: cardTextColor,
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
maProvider.selectPlayer(player);
|
||||||
|
dismiss();
|
||||||
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Syncing ${player.name}...'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
|
},
|
||||||
|
onPlayPause: () {
|
||||||
|
if (isPlaying) {
|
||||||
|
maProvider.pausePlayer(player.playerId);
|
||||||
|
} else {
|
||||||
|
maProvider.resumePlayer(player.playerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkipNext: () {
|
||||||
|
maProvider.nextTrack(player.playerId);
|
||||||
|
},
|
||||||
|
onPower: () {
|
||||||
|
maProvider.togglePower(player.playerId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/// Player widget components extracted from expandable_player.dart
|
/// Player widget components extracted from expandable_player.dart
|
||||||
library player_widgets;
|
library player_widgets;
|
||||||
|
|
||||||
|
export 'chapters_panel.dart';
|
||||||
export 'device_selector_bar.dart';
|
export 'device_selector_bar.dart';
|
||||||
export 'player_controls.dart';
|
export 'player_controls.dart';
|
||||||
export 'queue_panel.dart';
|
export 'queue_panel.dart';
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class QueuePanel extends StatelessWidget {
|
|||||||
child: isLoading
|
child: isLoading
|
||||||
? Center(child: CircularProgressIndicator(color: primaryColor))
|
? Center(child: CircularProgressIndicator(color: primaryColor))
|
||||||
: queue == null || queue!.items.isEmpty
|
: queue == null || queue!.items.isEmpty
|
||||||
? _buildEmptyState()
|
? _buildEmptyState(context)
|
||||||
: _buildQueueList(),
|
: _buildQueueList(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -78,8 +78,8 @@ class QueuePanel extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return EmptyState.queue();
|
return EmptyState.queue(context: context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQueueList() {
|
Widget _buildQueueList() {
|
||||||
@@ -118,6 +118,11 @@ class QueuePanel extends StatelessWidget {
|
|||||||
? CachedNetworkImage(
|
? CachedNetworkImage(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
|
||||||
|
memCacheWidth: 176,
|
||||||
|
memCacheHeight: 176,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: textColor.withOpacity(0.1),
|
color: textColor.withOpacity(0.1),
|
||||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../constants/timings.dart';
|
import '../constants/timings.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/music_assistant_provider.dart';
|
import '../providers/music_assistant_provider.dart';
|
||||||
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
|
import 'global_player_overlay.dart'; // For isPlayerExpanded and collapsePlayer
|
||||||
|
|
||||||
@@ -17,40 +19,60 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
final availablePlayers = maProvider.availablePlayers;
|
final availablePlayers = maProvider.availablePlayers;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
// Count players that are currently playing (excluding selected player)
|
||||||
|
// User already sees if selected player is playing, badge shows "other" activity
|
||||||
|
final selectedPlayerId = selectedPlayer?.playerId;
|
||||||
|
final playingCount = availablePlayers
|
||||||
|
.where((p) => p.state == 'playing' && p.playerId != selectedPlayerId)
|
||||||
|
.length;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: Material(
|
child: Badge(
|
||||||
color: colorScheme.primaryContainer,
|
isLabelVisible: playingCount > 0,
|
||||||
borderRadius: BorderRadius.circular(8),
|
backgroundColor: colorScheme.tertiary,
|
||||||
child: InkWell(
|
textColor: colorScheme.onTertiary,
|
||||||
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
label: Text(
|
||||||
|
playingCount.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 11,
|
||||||
|
color: colorScheme.onTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
onTap: () => _showPlayerSelector(context, maProvider, availablePlayers),
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
||||||
if (selectedPlayer != null) ...[
|
child: Row(
|
||||||
ConstrainedBox(
|
mainAxisSize: MainAxisSize.min,
|
||||||
constraints: const BoxConstraints(maxWidth: 120),
|
children: [
|
||||||
child: Text(
|
if (selectedPlayer != null) ...[
|
||||||
selectedPlayer.name,
|
ConstrainedBox(
|
||||||
style: TextStyle(
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
color: colorScheme.onPrimaryContainer,
|
child: Text(
|
||||||
fontWeight: FontWeight.w600,
|
selectedPlayer.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Icon(
|
||||||
|
MdiIcons.castAudio,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
],
|
||||||
Icon(
|
),
|
||||||
MdiIcons.castAudio,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -63,27 +85,8 @@ class PlayerSelector extends StatelessWidget {
|
|||||||
MusicAssistantProvider provider,
|
MusicAssistantProvider provider,
|
||||||
List players,
|
List players,
|
||||||
) {
|
) {
|
||||||
// Collapse player if expanded before showing sheet
|
// Use the new player reveal animation
|
||||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
GlobalPlayerOverlay.showPlayerReveal();
|
||||||
GlobalPlayerOverlay.collapsePlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slide mini player down out of the way
|
|
||||||
GlobalPlayerOverlay.hidePlayer();
|
|
||||||
|
|
||||||
// Show the bottom sheet
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
isScrollControlled: true,
|
|
||||||
isDismissible: true,
|
|
||||||
builder: (context) {
|
|
||||||
return _PlayerSelectorSheet();
|
|
||||||
},
|
|
||||||
).whenComplete(() {
|
|
||||||
// Slide mini player back up when sheet is dismissed
|
|
||||||
GlobalPlayerOverlay.showPlayer();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +185,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
child: currentPlayers.isEmpty
|
child: currentPlayers.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No players available',
|
S.of(context)!.noPlayersAvailable,
|
||||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.54)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -216,6 +219,11 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this player is part of a sync group
|
||||||
|
final isGrouped = player.isGrouped;
|
||||||
|
// Pastel yellow for grouped players
|
||||||
|
const groupBorderColor = Color(0xFFFFF59D);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -223,6 +231,23 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
maProvider.selectPlayer(player);
|
maProvider.selectPlayer(player);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
// Haptic feedback for sync action
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
// Show snackbar for visual feedback
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Syncing ${player.name}...'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Long-press to sync/unsync player
|
||||||
|
debugPrint('🔗 Long-press on ${player.name} (${player.playerId})');
|
||||||
|
debugPrint('🔗 Player isGrouped: ${player.isGrouped}, groupMembers: ${player.groupMembers}');
|
||||||
|
maProvider.togglePlayerSync(player.playerId);
|
||||||
|
},
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 72,
|
height: 72,
|
||||||
@@ -231,6 +256,9 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
? colorScheme.primary.withOpacity(0.15)
|
? colorScheme.primary.withOpacity(0.15)
|
||||||
: colorScheme.surfaceVariant.withOpacity(0.3),
|
: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: isGrouped
|
||||||
|
? Border.all(color: groupBorderColor, width: 1.5)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -304,12 +332,12 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
!player.available
|
!player.available
|
||||||
? 'Unavailable'
|
? S.of(context)!.playerStateUnavailable
|
||||||
: !isOn
|
: !isOn
|
||||||
? 'Off'
|
? S.of(context)!.playerStateOff
|
||||||
: hasContent && playerTrack != null
|
: hasContent && playerTrack != null
|
||||||
? playerTrack.name
|
? playerTrack.name
|
||||||
: 'Idle',
|
: S.of(context)!.playerStateIdle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: player.available
|
color: player.available
|
||||||
? colorScheme.onSurfaceVariant
|
? colorScheme.onSurfaceVariant
|
||||||
@@ -320,6 +348,35 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isGrouped) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: groupBorderColor.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.link_rounded,
|
||||||
|
size: 10,
|
||||||
|
color: Colors.amber.shade800,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
'Synced',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.amber.shade800,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
if (isSelected) ...[
|
if (isSelected) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
@@ -329,7 +386,7 @@ class _PlayerSelectorSheetState extends State<_PlayerSelectorSheet> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Selected',
|
S.of(context)!.playerSelected,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import '../models/media_item.dart';
|
||||||
|
import '../providers/music_assistant_provider.dart';
|
||||||
|
import '../services/debug_logger.dart';
|
||||||
|
import '../screens/audiobook_series_screen.dart';
|
||||||
|
import '../utils/page_transitions.dart';
|
||||||
|
|
||||||
|
class SeriesRow extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final Future<List<AudiobookSeries>> Function() loadSeries;
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
|
const SeriesRow({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.loadSeries,
|
||||||
|
this.rowHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SeriesRow> createState() => _SeriesRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeriesRowState extends State<SeriesRow> with AutomaticKeepAliveClientMixin {
|
||||||
|
late Future<List<AudiobookSeries>> _seriesFuture;
|
||||||
|
List<AudiobookSeries>? _cachedSeries;
|
||||||
|
bool _hasLoaded = false;
|
||||||
|
|
||||||
|
// Cache for series cover images
|
||||||
|
final Map<String, List<String>> _seriesCovers = {};
|
||||||
|
final Set<String> _loadingCovers = {};
|
||||||
|
// Cache for series book counts
|
||||||
|
final Map<String, int> _seriesBookCounts = {};
|
||||||
|
// Cache for extracted colors from covers
|
||||||
|
final Map<String, List<Color>> _seriesExtractedColors = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSeriesOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadSeriesOnce() {
|
||||||
|
if (!_hasLoaded) {
|
||||||
|
_seriesFuture = widget.loadSeries().then((series) {
|
||||||
|
_cachedSeries = series;
|
||||||
|
return series;
|
||||||
|
});
|
||||||
|
_hasLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSeriesCovers(String seriesId, MusicAssistantProvider maProvider) async {
|
||||||
|
if (_loadingCovers.contains(seriesId) || _seriesCovers.containsKey(seriesId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadingCovers.add(seriesId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final books = await maProvider.api?.getSeriesAudiobooks(seriesId);
|
||||||
|
if (books != null && books.isNotEmpty && mounted) {
|
||||||
|
final covers = <String>[];
|
||||||
|
for (final book in books.take(9)) {
|
||||||
|
final imageUrl = maProvider.getImageUrl(book);
|
||||||
|
if (imageUrl != null) {
|
||||||
|
covers.add(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_seriesCovers[seriesId] = covers;
|
||||||
|
_seriesBookCounts[seriesId] = books.length;
|
||||||
|
});
|
||||||
|
// Extract colors asynchronously
|
||||||
|
_extractSeriesColors(seriesId, covers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_loadingCovers.remove(seriesId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract colors from series book covers for empty cell backgrounds
|
||||||
|
Future<void> _extractSeriesColors(String seriesId, List<String> covers) async {
|
||||||
|
if (_seriesExtractedColors.containsKey(seriesId) || covers.isEmpty) return;
|
||||||
|
|
||||||
|
final extractedColors = <Color>[];
|
||||||
|
|
||||||
|
for (final url in covers.take(4)) {
|
||||||
|
try {
|
||||||
|
final palette = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(url),
|
||||||
|
maximumColorCount: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (palette.darkMutedColor != null) {
|
||||||
|
extractedColors.add(palette.darkMutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.mutedColor != null) {
|
||||||
|
extractedColors.add(palette.mutedColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.darkVibrantColor != null) {
|
||||||
|
extractedColors.add(palette.darkVibrantColor!.color);
|
||||||
|
}
|
||||||
|
if (palette.dominantColor != null) {
|
||||||
|
final hsl = HSLColor.fromColor(palette.dominantColor!.color);
|
||||||
|
extractedColors.add(hsl.withLightness((hsl.lightness * 0.4).clamp(0.1, 0.3)).toColor());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_logger.log('📚 Error extracting colors: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractedColors.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_seriesExtractedColors[seriesId] = extractedColors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _logger = DebugLogger();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
_logger.startBuild('SeriesRow:${widget.title}');
|
||||||
|
super.build(context);
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final maProvider = context.read<MusicAssistantProvider>();
|
||||||
|
|
||||||
|
final totalHeight = widget.rowHeight ?? 237.0;
|
||||||
|
const titleHeight = 44.0;
|
||||||
|
final contentHeight = totalHeight - titleHeight;
|
||||||
|
|
||||||
|
final result = RepaintBoundary(
|
||||||
|
child: SizedBox(
|
||||||
|
height: totalHeight,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<AudiobookSeries>>(
|
||||||
|
future: _seriesFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final series = snapshot.data ?? _cachedSeries;
|
||||||
|
|
||||||
|
if (series == null && snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError && series == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text('Error: ${snapshot.error}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (series == null || series.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No series found',
|
||||||
|
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textAreaHeight = 44.0;
|
||||||
|
final artworkSize = contentHeight - textAreaHeight;
|
||||||
|
final cardWidth = artworkSize;
|
||||||
|
final itemExtent = cardWidth + 12;
|
||||||
|
|
||||||
|
return ScrollConfiguration(
|
||||||
|
behavior: const _StretchScrollBehavior(),
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
itemCount: series.length,
|
||||||
|
itemExtent: itemExtent,
|
||||||
|
cacheExtent: 500,
|
||||||
|
addAutomaticKeepAlives: false,
|
||||||
|
addRepaintBoundaries: false,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final s = series[index];
|
||||||
|
|
||||||
|
// Load covers if not cached
|
||||||
|
if (!_seriesCovers.containsKey(s.id) && !_loadingCovers.contains(s.id)) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadSeriesCovers(s.id, maProvider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: ValueKey(s.id),
|
||||||
|
width: cardWidth,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: _SeriesCard(
|
||||||
|
series: s,
|
||||||
|
covers: _seriesCovers[s.id],
|
||||||
|
cachedBookCount: _seriesBookCounts[s.id],
|
||||||
|
extractedColors: _seriesExtractedColors[s.id],
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
textTheme: textTheme,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_logger.endBuild('SeriesRow:${widget.title}');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SeriesCard extends StatelessWidget {
|
||||||
|
final AudiobookSeries series;
|
||||||
|
final List<String>? covers;
|
||||||
|
final int? cachedBookCount;
|
||||||
|
final List<Color>? extractedColors;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final TextTheme textTheme;
|
||||||
|
|
||||||
|
// Fallback colors when no extracted colors available
|
||||||
|
static const _fallbackColors = [
|
||||||
|
Color(0xFF2D3436), // Dark slate
|
||||||
|
Color(0xFF34495E), // Dark blue-grey
|
||||||
|
Color(0xFF4A3728), // Dark brown
|
||||||
|
Color(0xFF2C3E50), // Midnight blue
|
||||||
|
Color(0xFF3D3D3D), // Charcoal
|
||||||
|
Color(0xFF4A4458), // Dark purple-grey
|
||||||
|
Color(0xFF3E4A47), // Dark teal-grey
|
||||||
|
Color(0xFF4A3F35), // Dark warm grey
|
||||||
|
];
|
||||||
|
|
||||||
|
const _SeriesCard({
|
||||||
|
required this.series,
|
||||||
|
required this.covers,
|
||||||
|
this.cachedBookCount,
|
||||||
|
this.extractedColors,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.textTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final heroTag = 'series_cover_home_${series.id}';
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
// PERF: Use FadeSlidePageRoute for consistent hero animation timing
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
FadeSlidePageRoute(
|
||||||
|
child: AudiobookSeriesScreen(
|
||||||
|
series: series,
|
||||||
|
heroTag: heroTag,
|
||||||
|
initialCovers: covers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Use AspectRatio to guarantee square cover
|
||||||
|
Hero(
|
||||||
|
tag: heroTag,
|
||||||
|
// PERF: Use flightShuttleBuilder to avoid animating complex grid
|
||||||
|
// during hero flight. Show cached first cover image instead.
|
||||||
|
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
|
||||||
|
// Use first cover as simple placeholder during flight
|
||||||
|
final heroChild = toContext.widget as Hero;
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8 + 4 * animation.value),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: covers != null && covers!.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: covers!.first,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: _buildCoverGrid(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
series.name,
|
||||||
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final count = series.bookCount ?? cachedBookCount;
|
||||||
|
if (count == null) return const SizedBox.shrink();
|
||||||
|
return Text(
|
||||||
|
'$count ${count == 1 ? 'book' : 'books'}',
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCoverGrid() {
|
||||||
|
if (covers == null || covers!.isEmpty) {
|
||||||
|
// Static placeholder
|
||||||
|
return Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.collections_bookmark_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine grid size
|
||||||
|
int gridSize;
|
||||||
|
if (covers!.length == 1) {
|
||||||
|
gridSize = 1;
|
||||||
|
} else if (covers!.length <= 4) {
|
||||||
|
gridSize = 2;
|
||||||
|
} else {
|
||||||
|
gridSize = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad covers to fill grid
|
||||||
|
final displayCovers = List<String?>.filled(gridSize * gridSize, null);
|
||||||
|
for (var i = 0; i < covers!.length && i < displayCovers.length; i++) {
|
||||||
|
displayCovers[i] = covers![i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use series ID to pick consistent colors
|
||||||
|
final colorSeed = series.id.hashCode;
|
||||||
|
|
||||||
|
// Get colors for empty cells
|
||||||
|
final emptyColors = (extractedColors != null && extractedColors!.isNotEmpty)
|
||||||
|
? extractedColors!
|
||||||
|
: _fallbackColors;
|
||||||
|
|
||||||
|
// Build grid using Column/Row (parent AspectRatio ensures square)
|
||||||
|
return Column(
|
||||||
|
children: List.generate(gridSize, (row) {
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(gridSize, (col) {
|
||||||
|
final index = row * gridSize + col;
|
||||||
|
final coverUrl = displayCovers[index];
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: coverUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildEmptyCell(colorSeed, index, emptyColors),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an empty cell with either a solid color or a nested grid
|
||||||
|
Widget _buildEmptyCell(int colorSeed, int cellIndex, List<Color> emptyColors) {
|
||||||
|
// Tone down colors
|
||||||
|
final colors = emptyColors.map((c) {
|
||||||
|
final hsl = HSLColor.fromColor(c);
|
||||||
|
return hsl
|
||||||
|
.withSaturation((hsl.saturation * 0.5).clamp(0.05, 0.25))
|
||||||
|
.withLightness((hsl.lightness * 0.7).clamp(0.08, 0.20))
|
||||||
|
.toColor();
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Use combined seed for deterministic but varied patterns
|
||||||
|
final seed = colorSeed + cellIndex * 17;
|
||||||
|
|
||||||
|
// Determine nested grid size: 1 (solid), 2 (2x2), or 3 (3x3)
|
||||||
|
// Distribution: ~50% solid, ~30% 2x2, ~20% 3x3
|
||||||
|
final sizeRoll = seed.abs() % 100;
|
||||||
|
int nestedSize;
|
||||||
|
if (sizeRoll < 50) {
|
||||||
|
nestedSize = 1;
|
||||||
|
} else if (sizeRoll < 80) {
|
||||||
|
nestedSize = 2;
|
||||||
|
} else {
|
||||||
|
nestedSize = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nestedSize == 1) {
|
||||||
|
final colorIndex = seed.abs() % colors.length;
|
||||||
|
return Container(color: colors[colorIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nested grid (seamless)
|
||||||
|
return Column(
|
||||||
|
children: List.generate(nestedSize, (row) {
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(nestedSize, (col) {
|
||||||
|
final nestedIndex = row * nestedSize + col;
|
||||||
|
final nestedSeed = seed + nestedIndex * 7;
|
||||||
|
final colorIndex = nestedSeed.abs() % colors.length;
|
||||||
|
return Expanded(
|
||||||
|
child: Container(color: colors[colorIndex]),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StretchScrollBehavior extends ScrollBehavior {
|
||||||
|
const _StretchScrollBehavior();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildOverscrollIndicator(
|
||||||
|
BuildContext context, Widget child, ScrollableDetails details) {
|
||||||
|
return StretchingOverscrollIndicator(
|
||||||
|
axisDirection: details.direction,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,7 +186,9 @@ class _TrackCard extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 256,
|
memCacheWidth: 256,
|
||||||
memCacheHeight: 256,
|
memCacheHeight: 256,
|
||||||
fadeInDuration: const Duration(milliseconds: 150),
|
// PERF: Duration.zero for smooth scrolling
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
placeholder: (context, url) => const SizedBox(),
|
placeholder: (context, url) => const SizedBox(),
|
||||||
errorWidget: (context, url, error) => Icon(
|
errorWidget: (context, url, error) => Icon(
|
||||||
Icons.music_note_rounded,
|
Icons.music_note_rounded,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: ensemble
|
name: ensemble
|
||||||
description: A minimalistic Music Assistant client built with Flutter
|
description: A minimalistic Music Assistant client built with Flutter
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.0-beta+15
|
version: 2.6.0-beta+28
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -9,6 +9,9 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
material_design_icons_flutter: ^7.0.7296
|
material_design_icons_flutter: ^7.0.7296
|
||||||
@@ -25,12 +28,16 @@ dependencies:
|
|||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
|
drift: ^2.22.0
|
||||||
|
drift_flutter: 0.2.0
|
||||||
|
path_provider: ^2.1.5
|
||||||
|
sqlite3_flutter_libs: ^0.5.28
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
uuid: ^4.2.1
|
uuid: ^4.2.1
|
||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
device_info_plus: ^10.1.0
|
device_info_plus: ^10.1.0
|
||||||
share_plus: ^9.0.0
|
share_plus: ^10.1.4
|
||||||
|
|
||||||
# Audio - Local Playback
|
# Audio - Local Playback
|
||||||
just_audio: ^0.9.36
|
just_audio: ^0.9.36
|
||||||
@@ -46,8 +53,11 @@ dev_dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.1
|
flutter_lints: ^3.0.1
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
build_runner: ^2.4.13
|
||||||
|
drift_dev: ^2.22.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
|
generate: true
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
|
|||||||