Compare commits
127
Commits
@@ -4,10 +4,13 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'claude/**'
|
||||
- 'feature/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -35,6 +38,9 @@ jobs:
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate code (Drift database)
|
||||
run: dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Generate launcher icons
|
||||
run: flutter pub run flutter_launcher_icons
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
---
|
||||
|
||||
<p>A minimalistic mobile client for <a href="https://music-assistant.io/">Music Assistant</a></p>
|
||||
<p><strong>An unofficial mobile client for Music Assistant</strong></p>
|
||||
<p>Stream your music library directly to your phone, or control playback on any connected speaker.</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Ensemble is an unofficial, community-built mobile client for Music Assistant. It is not affiliated with, endorsed by, or supported by the Music Assistant project or its developers.**
|
||||
|
||||
This application was built with AI-assisted development using **Claude Code** and **Gemini CLI**.
|
||||
|
||||
---
|
||||
@@ -42,9 +44,19 @@ This application was built with AI-assisted development using **Claude Code** an
|
||||
- **Playlist Support** - Browse and play your playlists
|
||||
- **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
|
||||
- **Instant App Restore** - App loads instantly with cached library data while syncing in background
|
||||
- **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
|
||||
|
||||
### Theming
|
||||
@@ -92,21 +104,10 @@ Ensemble supports multiple authentication methods:
|
||||
|
||||
## 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
|
||||
- Android device (Android 5.0+)
|
||||
|
||||
> **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.
|
||||
|
||||
## About Music Assistant
|
||||
|
||||
Ensemble is a mobile client for [Music Assistant](https://music-assistant.io/), an open-source music library manager that integrates with various music sources and streaming providers.
|
||||
|
||||
With Ensemble, you can:
|
||||
- **Stream locally** - Use your phone as a Music Assistant player
|
||||
- **Control remotely** - Manage playback on smart speakers, Chromecast, Sonos, and more
|
||||
|
||||
Learn more: [music-assistant.io](https://music-assistant.io/)
|
||||
- Audiobookshelf provider configured in Music Assistant (for audiobook features)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -13,4 +13,16 @@ class HeroTags {
|
||||
|
||||
/// Artist image hero tag prefix
|
||||
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_';
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ class Timings {
|
||||
/// Player state polling interval (for selected player updates)
|
||||
static const Duration playerPollingInterval = Duration(seconds: 5);
|
||||
|
||||
/// Local player state report interval (for seek bar smoothness)
|
||||
static const Duration localPlayerReportInterval = Duration(seconds: 1);
|
||||
/// Local player state report interval
|
||||
/// Note: Sendspin protocol only requires state on changes, not periodic reports.
|
||||
/// This is mainly for local UI position tracking. Set to 5s to reduce server traffic.
|
||||
static const Duration localPlayerReportInterval = Duration(seconds: 5);
|
||||
|
||||
/// Player list cache duration before refresh
|
||||
static const Duration playersCacheDuration = Duration(minutes: 5);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
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};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Profiles, RecentlyPlayed, LibraryCache, SyncMetadata])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
// Future migrations will go here
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import 'providers/navigation_provider.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/login_screen.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/auth/auth_manager.dart';
|
||||
import 'services/debug_logger.dart';
|
||||
@@ -27,6 +30,17 @@ final _logger = DebugLogger();
|
||||
Future<void> main() async {
|
||||
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
|
||||
final authManager = AuthManager();
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ enum MediaType {
|
||||
track,
|
||||
playlist,
|
||||
radio,
|
||||
audiobook,
|
||||
chapter,
|
||||
podcast,
|
||||
podcastEpisode,
|
||||
}
|
||||
|
||||
class ProviderMapping {
|
||||
@@ -140,11 +144,15 @@ class Artist extends MediaItem {
|
||||
favorite: item.favorite,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => super.toJson();
|
||||
}
|
||||
|
||||
class Album extends MediaItem {
|
||||
final List<Artist>? artists;
|
||||
final String? albumType;
|
||||
final int? year;
|
||||
|
||||
Album({
|
||||
required super.itemId,
|
||||
@@ -152,6 +160,7 @@ class Album extends MediaItem {
|
||||
required super.name,
|
||||
this.artists,
|
||||
this.albumType,
|
||||
this.year,
|
||||
super.sortName,
|
||||
super.uri,
|
||||
super.providerMappings,
|
||||
@@ -161,6 +170,15 @@ class Album extends MediaItem {
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> 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(
|
||||
itemId: item.itemId,
|
||||
provider: item.provider,
|
||||
@@ -169,6 +187,7 @@ class Album extends MediaItem {
|
||||
?.map((e) => Artist.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
albumType: json['album_type'] as String?,
|
||||
year: year,
|
||||
sortName: item.sortName,
|
||||
uri: item.uri,
|
||||
providerMappings: item.providerMappings,
|
||||
@@ -179,6 +198,28 @@ class Album extends MediaItem {
|
||||
|
||||
String get artistsString =>
|
||||
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 {
|
||||
@@ -224,6 +265,16 @@ class Track extends MediaItem {
|
||||
|
||||
String get artistsString =>
|
||||
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 {
|
||||
@@ -262,3 +313,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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,35 @@ class Player {
|
||||
this.elapsedTimeLastUpdated,
|
||||
});
|
||||
|
||||
/// Create a copy of this Player with some fields replaced
|
||||
Player copyWith({
|
||||
String? playerId,
|
||||
String? name,
|
||||
String? provider,
|
||||
bool? available,
|
||||
bool? powered,
|
||||
String? state,
|
||||
String? currentItemId,
|
||||
int? volumeLevel,
|
||||
bool? volumeMuted,
|
||||
double? elapsedTime,
|
||||
double? elapsedTimeLastUpdated,
|
||||
}) {
|
||||
return Player(
|
||||
playerId: playerId ?? this.playerId,
|
||||
name: name ?? this.name,
|
||||
provider: provider ?? this.provider,
|
||||
available: available ?? this.available,
|
||||
powered: powered ?? this.powered,
|
||||
state: state ?? this.state,
|
||||
currentItemId: currentItemId ?? this.currentItemId,
|
||||
volumeLevel: volumeLevel ?? this.volumeLevel,
|
||||
volumeMuted: volumeMuted ?? this.volumeMuted,
|
||||
elapsedTime: elapsedTime ?? this.elapsedTime,
|
||||
elapsedTimeLastUpdated: elapsedTimeLastUpdated ?? this.elapsedTimeLastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
// Derived properties
|
||||
bool get isPlaying => state == 'playing';
|
||||
bool get isMuted => volumeMuted ?? false;
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/error_handler.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 {
|
||||
if (_api == null) return;
|
||||
|
||||
@@ -132,6 +134,15 @@ class ConnectionProvider with ChangeNotifier {
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
await SettingsService.setOwnerName(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 {
|
||||
_logger.log('⚠️ No valid name in profile');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
@@ -506,7 +505,15 @@ class PlayerProvider with ChangeNotifier {
|
||||
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++;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
|
||||
@@ -308,6 +309,9 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
// Queue all tracks via Music Assistant
|
||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||
_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
|
||||
} catch (e) {
|
||||
_logger.log('Error playing album: $e');
|
||||
@@ -545,6 +549,47 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
}
|
||||
}
|
||||
|
||||
/// Show fullscreen album art overlay
|
||||
void _showFullscreenArt(String? imageUrl) {
|
||||
if (imageUrl == null) return;
|
||||
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
barrierColor: Colors.black87,
|
||||
barrierDismissible: true,
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 3.0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: 1024,
|
||||
memCacheHeight: 1024,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// CRITICAL FIX: Use select() instead of watch() to reduce rebuilds
|
||||
@@ -607,35 +652,38 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
GestureDetector(
|
||||
onTap: () => _showFullscreenArt(imageUrl),
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (widget.album.uri ?? widget.album.itemId) + _heroTagSuffix,
|
||||
child: Container(
|
||||
width: 280, // Increased size
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(16), // Slightly more rounded
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
image: imageUrl != null
|
||||
? DecorationImage(
|
||||
image: CachedNetworkImageProvider(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: imageUrl == null
|
||||
? Icon(
|
||||
Icons.album_rounded,
|
||||
size: 120,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -653,7 +701,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.name,
|
||||
widget.album.nameWithYear,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -960,7 +1008,7 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> with SingleTick
|
||||
childCount: _tracks.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 140)), // Space for bottom nav + mini player
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 164)), // Space for bottom nav + mini player
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../constants/hero_tags.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../services/metadata_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
@@ -40,6 +41,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
String? _artistDescription;
|
||||
String? _artistImageUrl;
|
||||
|
||||
// View preferences
|
||||
String _sortOrder = 'alpha'; // 'alpha' or 'year'
|
||||
String _viewMode = 'grid2'; // 'grid2', 'grid3', 'list'
|
||||
|
||||
String get _heroTagSuffix => widget.heroTagSuffix != null ? '_${widget.heroTagSuffix}' : '';
|
||||
|
||||
@override
|
||||
@@ -48,6 +53,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
_isFavorite = widget.artist.favorite ?? false;
|
||||
// Use initial image URL immediately for smooth hero animation
|
||||
_artistImageUrl = widget.initialImageUrl;
|
||||
_loadViewPreferences();
|
||||
_loadArtistAlbums();
|
||||
_loadArtistDescription();
|
||||
_refreshFavoriteStatus();
|
||||
@@ -64,6 +70,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 {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
if (maProvider.api == null) return;
|
||||
@@ -364,9 +430,14 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
final allAlbums = await provider.getArtistAlbumsWithCache(widget.artist.name);
|
||||
|
||||
if (mounted) {
|
||||
// Separate library albums from provider-only albums
|
||||
final libraryAlbums = allAlbums.where((a) => a.inLibrary).toList();
|
||||
final providerOnlyAlbums = allAlbums.where((a) => !a.inLibrary).toList();
|
||||
|
||||
setState(() {
|
||||
_albums = allAlbums;
|
||||
_providerAlbums = []; // All albums are merged in the cache method
|
||||
_albums = libraryAlbums;
|
||||
_providerAlbums = providerOnlyAlbums;
|
||||
_sortAlbums(); // Apply saved sort order
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -607,72 +678,117 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
// Library Albums Section
|
||||
// Library Albums Section with inline controls
|
||||
if (_albums.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
child: Text(
|
||||
'In Library',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _albums[index];
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
childCount: _albums.length,
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 8.0, 12.0, 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'In Library',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
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' ? 'Sort by year' : 'Sort alphabetically',
|
||||
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'
|
||||
? '3-column grid'
|
||||
: _viewMode == 'grid3'
|
||||
? 'List view'
|
||||
: '2-column grid',
|
||||
onPressed: _cycleViewMode,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildAlbumSliver(_albums),
|
||||
],
|
||||
|
||||
// Provider Albums Section
|
||||
if (_providerAlbums.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24.0, 32.0, 24.0, 12.0),
|
||||
child: Text(
|
||||
'From Providers',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.75,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final album = _providerAlbums[index];
|
||||
return _buildAlbumCard(album);
|
||||
},
|
||||
childCount: _providerAlbums.length,
|
||||
padding: EdgeInsets.fromLTRB(24.0, _albums.isEmpty ? 8.0 : 24.0, 12.0, 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'From Providers',
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
// Only show controls here if no library albums
|
||||
if (_albums.isEmpty) ...[
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_sortOrder == 'alpha' ? Icons.sort_by_alpha : Icons.calendar_today,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _sortOrder == 'alpha' ? 'Sort by year' : 'Sort alphabetically',
|
||||
onPressed: _toggleSortOrder,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == 'list'
|
||||
? Icons.view_list
|
||||
: _viewMode == 'grid3'
|
||||
? Icons.grid_view
|
||||
: Icons.grid_on,
|
||||
color: colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: _viewMode == 'grid2'
|
||||
? '3-column grid'
|
||||
: _viewMode == 'grid3'
|
||||
? 'List view'
|
||||
: '2-column grid',
|
||||
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
|
||||
],
|
||||
],
|
||||
@@ -707,7 +823,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0, // Square album art
|
||||
child: Hero(
|
||||
tag: HeroTags.albumCover + (album.uri ?? album.itemId) + '_$heroSuffix',
|
||||
child: ClipRRect(
|
||||
@@ -745,7 +862,7 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
album.name,
|
||||
album.nameWithYear,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -773,4 +890,98 @@ 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
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => 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',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
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 'audiobook_detail_screen.dart';
|
||||
|
||||
class AudiobookAuthorScreen extends StatefulWidget {
|
||||
final String authorName;
|
||||
final List<Audiobook> audiobooks;
|
||||
final String? heroTagSuffix;
|
||||
|
||||
const AudiobookAuthorScreen({
|
||||
super.key,
|
||||
required this.authorName,
|
||||
required this.audiobooks,
|
||||
this.heroTagSuffix,
|
||||
});
|
||||
|
||||
@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);
|
||||
_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,
|
||||
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(
|
||||
'${_audiobooks.length} ${_audiobooks.length == 1 ? 'audiobook' : 'audiobooks'}',
|
||||
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(
|
||||
'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' ? 'Sort by year' : 'Sort alphabetically',
|
||||
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'
|
||||
? '3-column grid'
|
||||
: _viewMode == 'grid3'
|
||||
? 'List view'
|
||||
: '2-column grid',
|
||||
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),
|
||||
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,
|
||||
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 != 'Unknown Narrator')
|
||||
Text(
|
||||
'Narrated by ${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,
|
||||
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 != 'Unknown Narrator'
|
||||
? 'Narrated by ${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),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,675 @@
|
||||
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' 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';
|
||||
|
||||
class AudiobookSeriesScreen extends StatefulWidget {
|
||||
final AudiobookSeries series;
|
||||
final String? heroTag;
|
||||
|
||||
const AudiobookSeriesScreen({
|
||||
super.key,
|
||||
required this.series,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
@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'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadViewPreferences();
|
||||
// Defer loading until after first frame to ensure UI renders first
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_loadSeriesBooks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = 'Not connected to Music Assistant';
|
||||
_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) {
|
||||
setState(() {
|
||||
_audiobooks = books;
|
||||
_sortAudiobooks(); // Sort inside setState so UI updates
|
||||
_isLoading = false;
|
||||
});
|
||||
// Debug: Log sorted order
|
||||
_logger.log('📚 Sorted order ($_sortOrder):');
|
||||
for (final book in _audiobooks) {
|
||||
_logger.log(' - ${book.seriesSequence ?? "null"}: ${book.name}');
|
||||
}
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_logger.log('📚 SeriesScreen error: $e');
|
||||
_logger.log('📚 SeriesScreen stack: $stack');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Failed to load books: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final maProvider = context.watch<MusicAssistantProvider>();
|
||||
|
||||
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
|
||||
? 'Loading...'
|
||||
: '${_audiobooks.length} ${_audiobooks.length == 1 ? 'book' : 'books'}',
|
||||
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(
|
||||
'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' ? 'Sort alphabetically' : 'Sort by series order',
|
||||
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'
|
||||
? '3-column grid'
|
||||
: _viewMode == 'grid3'
|
||||
? 'List view'
|
||||
: '2-column grid',
|
||||
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: const Text('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(
|
||||
'No books found in this series',
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildAudiobookSliver(maProvider),
|
||||
|
||||
// Bottom padding for mini player
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: BottomSpacing.withMiniPlayer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeriesCover(ColorScheme colorScheme, MusicAssistantProvider maProvider) {
|
||||
// If still loading or no books, show placeholder
|
||||
if (_isLoading || _audiobooks.isEmpty) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get cover URLs from loaded books
|
||||
final covers = <String>[];
|
||||
for (final book in _audiobooks.take(9)) {
|
||||
final imageUrl = maProvider.getImageUrl(book);
|
||||
if (imageUrl != null) {
|
||||
covers.add(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (covers.isEmpty) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.collections_bookmark_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Always use 3x3 grid for series detail
|
||||
const int gridSize = 3;
|
||||
|
||||
// Pad covers to fill grid, using empty strings for missing slots
|
||||
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: Container(
|
||||
margin: EdgeInsets.only(
|
||||
right: col < gridSize - 1 ? 1 : 0,
|
||||
bottom: row < gridSize - 1 ? 1 : 0,
|
||||
),
|
||||
child: coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.book,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.3),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
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 != 'Unknown Author')
|
||||
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,
|
||||
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 != 'Unknown Author'
|
||||
? 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),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToAudiobook(Audiobook book, {String? heroTagSuffix}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: AudiobookDetailScreen(
|
||||
audiobook: book,
|
||||
heroTagSuffix: heroTagSuffix,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/database_service.dart';
|
||||
import '../services/profile_service.dart';
|
||||
import '../services/auth/auth_strategy.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../widgets/debug/debug_console.dart';
|
||||
@@ -30,6 +32,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
String? _error;
|
||||
bool _showDebug = false;
|
||||
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
|
||||
void initState() {
|
||||
@@ -94,10 +98,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
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://
|
||||
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() {
|
||||
// Normalize the base URL
|
||||
var url = _normalizeServerUrl(_serverUrlController.text.trim());
|
||||
@@ -147,6 +168,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
_error = null;
|
||||
_detectedAuthStrategy = null;
|
||||
_detectedAuthType = null;
|
||||
_resolvedServerUrl = null;
|
||||
_usedHttpFallback = false;
|
||||
_debugLogs.clear();
|
||||
});
|
||||
|
||||
@@ -160,6 +183,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Auto-detect authentication requirements
|
||||
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'}');
|
||||
|
||||
if (strategy == null) {
|
||||
@@ -173,6 +203,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
setState(() {
|
||||
_detectedAuthStrategy = strategy;
|
||||
_detectedAuthType = _getAuthTypeName(strategy.name);
|
||||
_resolvedServerUrl = resolvedUrl;
|
||||
_isDetectingAuth = false;
|
||||
});
|
||||
|
||||
@@ -250,7 +281,9 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// Validate port (if provided)
|
||||
@@ -269,7 +302,13 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
// Save owner name (if provided) and port to settings
|
||||
// For MA auth, the owner name will be set from user profile after login
|
||||
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);
|
||||
|
||||
@@ -627,6 +666,71 @@ 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)
|
||||
? 'Tailscale VPN Connection'
|
||||
: 'Unencrypted Connection',
|
||||
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)
|
||||
? 'Using HTTP over Tailscale (encrypted tunnel)'
|
||||
: _usedHttpFallback
|
||||
? 'HTTPS failed, using HTTP fallback'
|
||||
: 'HTTP connection - data is not encrypted',
|
||||
style: TextStyle(
|
||||
color: (_isTailscaleUrl(_serverUrlController.text)
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onErrorContainer).withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Authentication fields (shown only if auth required)
|
||||
if (needsAuth) ...[
|
||||
// Auth Server URL (only for Authelia)
|
||||
|
||||
@@ -4,11 +4,14 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../widgets/global_player_overlay.dart';
|
||||
import '../widgets/player_selector.dart';
|
||||
import '../widgets/album_row.dart';
|
||||
import '../widgets/artist_row.dart';
|
||||
import '../widgets/track_row.dart';
|
||||
import '../widgets/audiobook_row.dart';
|
||||
import '../widgets/series_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -31,6 +34,10 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
bool _showDiscoverSeries = false;
|
||||
// Random order for favorites (generated once per session)
|
||||
late List<int> _favoritesOrder;
|
||||
|
||||
@@ -67,6 +74,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -75,6 +85,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -84,6 +97,9 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
provider.invalidateHomeCache();
|
||||
|
||||
// Force full library sync from MA API
|
||||
await provider.forceLibrarySync();
|
||||
|
||||
// Reload settings in case they changed
|
||||
await _loadSettings();
|
||||
|
||||
@@ -127,6 +143,28 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
titleSpacing: 0,
|
||||
centerTitle: false,
|
||||
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(),
|
||||
],
|
||||
),
|
||||
@@ -135,19 +173,58 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
selector: (_, p) => p.isConnected,
|
||||
builder: (context, isConnected, child) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
return !isConnected
|
||||
? DisconnectedState.full(
|
||||
onSettings: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
final syncService = SyncService.instance;
|
||||
|
||||
// Show cached data even when not connected (if we have cache)
|
||||
// Only show disconnected state if we have no cached data at all
|
||||
if (!isConnected && !syncService.hasCache) {
|
||||
return DisconnectedState.full(
|
||||
onSettings: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Connecting banner when showing cached data
|
||||
if (!isConnected && syncService.hasCache)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: colorScheme.primaryContainer.withOpacity(0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Connecting...',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surface,
|
||||
child: _buildConnectedView(context, maProvider),
|
||||
);
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -220,6 +297,33 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
rowHeight: rowHeight,
|
||||
),
|
||||
|
||||
// Continue Listening Audiobooks (optional)
|
||||
if (_showContinueListeningAudiobooks)
|
||||
AudiobookRow(
|
||||
key: const ValueKey('continue-listening-audiobooks'),
|
||||
title: 'Continue Listening',
|
||||
loadAudiobooks: () => provider.getInProgressAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
),
|
||||
|
||||
// Discover Audiobooks (optional)
|
||||
if (_showDiscoverAudiobooks)
|
||||
AudiobookRow(
|
||||
key: const ValueKey('discover-audiobooks'),
|
||||
title: 'Discover Audiobooks',
|
||||
loadAudiobooks: () => provider.getDiscoverAudiobooks(),
|
||||
rowHeight: rowHeight,
|
||||
),
|
||||
|
||||
// Discover Series (optional)
|
||||
if (_showDiscoverSeries)
|
||||
SeriesRow(
|
||||
key: const ValueKey('discover-series'),
|
||||
title: 'Discover Series',
|
||||
loadSeries: () => provider.getDiscoverSeries(),
|
||||
rowHeight: rowHeight,
|
||||
),
|
||||
|
||||
// Favorites rows in random order
|
||||
...favoritesWidgets,
|
||||
],
|
||||
|
||||
+1836
-100
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../services/recently_played_service.dart';
|
||||
|
||||
final _playlistLogger = DebugLogger();
|
||||
|
||||
@@ -70,6 +71,9 @@ class _PlaylistDetailsScreenState extends State<PlaylistDetailsScreen> {
|
||||
await maProvider.playTracks(player.playerId, _tracks, startIndex: 0);
|
||||
_playlistLogger.info('Playlist queued successfully', context: 'Playlist');
|
||||
|
||||
// Record to local recently played (per-profile)
|
||||
RecentlyPlayedService.instance.recordPlaylistPlayed(widget.playlist);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -472,7 +472,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
album.nameWithYear,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
||||
@@ -26,6 +26,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
bool _showDiscoverSeries = false;
|
||||
// Audiobook libraries
|
||||
List<Map<String, String>> _discoveredLibraries = [];
|
||||
Map<String, bool> _libraryEnabled = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,6 +58,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
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 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);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -59,6 +82,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
_discoveredLibraries = discovered;
|
||||
_libraryEnabled = libraryEnabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -493,6 +521,82 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
'Audiobooks',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Audiobook home rows section
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'Continue Listening',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show audiobooks in progress',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _showContinueListeningAudiobooks,
|
||||
onChanged: (value) {
|
||||
setState(() => _showContinueListeningAudiobooks = value);
|
||||
SettingsService.setShowContinueListeningAudiobooks(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'Discover Audiobooks',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show random audiobooks to discover',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _showDiscoverAudiobooks,
|
||||
onChanged: (value) {
|
||||
setState(() => _showDiscoverAudiobooks = value);
|
||||
SettingsService.setShowDiscoverAudiobooks(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
Divider(color: colorScheme.outline.withOpacity(0.2), height: 1),
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
'Discover Series',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show random audiobook series to discover',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _showDiscoverSeries,
|
||||
onChanged: (value) {
|
||||
setState(() => _showDiscoverSeries = value);
|
||||
SettingsService.setShowDiscoverSeries(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Text(
|
||||
@@ -585,6 +689,76 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Audiobook Libraries section
|
||||
if (_discoveredLibraries.isNotEmpty) ...[
|
||||
Text(
|
||||
'Audiobook Libraries',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Choose which Audiobookshelf libraries to include',
|
||||
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'] ?? 'Unknown Library';
|
||||
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(
|
||||
const SnackBar(
|
||||
content: Text('Pull to refresh the library to apply changes'),
|
||||
duration: 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(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
|
||||
@@ -31,11 +31,42 @@ class AuthManager {
|
||||
|
||||
/// Auto-detect authentication requirements for a server
|
||||
/// 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 {
|
||||
_logger.log('🔍 Auto-detecting auth strategy for $serverUrl');
|
||||
_lastSuccessfulUrl = null;
|
||||
|
||||
// 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://')) {
|
||||
// Use http for local IPs, https for domains
|
||||
if (baseUrl.startsWith('192.') ||
|
||||
@@ -44,13 +75,22 @@ class AuthManager {
|
||||
baseUrl == 'localhost' ||
|
||||
baseUrl.startsWith('127.')) {
|
||||
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 {
|
||||
baseUrl = 'https://$baseUrl';
|
||||
}
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
/// 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
|
||||
_logger.log('Checking for Music Assistant native auth...');
|
||||
_logger.log('Checking for Music Assistant native auth at $baseUrl...');
|
||||
final maAuthResult = await _checkMusicAssistantAuth(baseUrl);
|
||||
if (maAuthResult != null) {
|
||||
if (maAuthResult == 'required') {
|
||||
@@ -124,7 +164,7 @@ class AuthManager {
|
||||
}
|
||||
} catch (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');
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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();
|
||||
}
|
||||
@@ -71,6 +71,18 @@ class ErrorHandler {
|
||||
);
|
||||
}
|
||||
|
||||
// Unavailable content errors - these won't resolve with retry
|
||||
if (errorStr.contains('no playable') ||
|
||||
errorStr.contains('lack available providers') ||
|
||||
errorStr.contains('no available providers')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.playback,
|
||||
userMessage: 'These tracks are unavailable. The music provider may not have them available for streaming.',
|
||||
technicalMessage: error.toString(),
|
||||
canRetry: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Playback errors
|
||||
if (errorStr.contains('queue') ||
|
||||
errorStr.contains('player') ||
|
||||
|
||||
@@ -254,6 +254,9 @@ class MetadataService {
|
||||
// Cache for album images
|
||||
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)
|
||||
/// Returns the image URL if found, null otherwise
|
||||
static Future<String?> getAlbumImageUrl(String albumName, String? artistName) async {
|
||||
@@ -323,10 +326,152 @@ class MetadataService {
|
||||
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
|
||||
static void clearCache() {
|
||||
_cache.clear();
|
||||
_artistImageCache.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
|
||||
/// Gets recently played tracks, then fetches full track details to extract album info
|
||||
/// Optimized: batches track lookups to avoid N+1 query problem
|
||||
@@ -1045,7 +1583,20 @@ class MusicAssistantAPI {
|
||||
/// If clearQueue is true, replaces the queue (default behavior)
|
||||
/// If startIndex is provided, only tracks from that index onwards will be queued
|
||||
Future<void> playTracks(String playerId, List<Track> tracks, {int? startIndex, bool clearQueue = true}) async {
|
||||
return await RetryHelper.retryCritical(
|
||||
return await RetryHelper.retry(
|
||||
maxAttempts: 3,
|
||||
initialDelaySeconds: 1,
|
||||
maxDelaySeconds: 4,
|
||||
// Don't retry errors about missing/unavailable tracks - those won't resolve with retries
|
||||
shouldRetry: (error) {
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
if (errorStr.contains('no playable') ||
|
||||
errorStr.contains('no tracks') ||
|
||||
errorStr.contains('lack available')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
operation: () async {
|
||||
// If startIndex is provided, slice the tracks list to start from that index
|
||||
// This is a workaround since Music Assistant ignores the start_item parameter
|
||||
@@ -1053,8 +1604,36 @@ class MusicAssistantAPI {
|
||||
? tracks.sublist(startIndex)
|
||||
: tracks;
|
||||
|
||||
if (tracksToPlay.isEmpty) {
|
||||
throw Exception('No tracks to play');
|
||||
}
|
||||
|
||||
// Filter to only tracks with available provider mappings
|
||||
final playableTracks = tracksToPlay.where((track) {
|
||||
if (track.providerMappings == null || track.providerMappings!.isEmpty) {
|
||||
_logger.log('⚠️ Track "${track.name}" has no provider mappings');
|
||||
return false;
|
||||
}
|
||||
final hasAvailable = track.providerMappings!.any((m) => m.available);
|
||||
if (!hasAvailable) {
|
||||
_logger.log('⚠️ Track "${track.name}" has no available providers');
|
||||
}
|
||||
return hasAvailable;
|
||||
}).toList();
|
||||
|
||||
if (playableTracks.isEmpty) {
|
||||
_logger.log('❌ No playable tracks found (${tracksToPlay.length} tracks had no available providers)');
|
||||
throw Exception('No playable tracks - all ${tracksToPlay.length} tracks lack available providers');
|
||||
}
|
||||
|
||||
if (playableTracks.length < tracksToPlay.length) {
|
||||
_logger.log('⚠️ ${tracksToPlay.length - playableTracks.length} tracks skipped (no available providers)');
|
||||
}
|
||||
|
||||
// Build array of URI strings (not objects!)
|
||||
final mediaUris = tracksToPlay.map((track) => _buildTrackUri(track)).toList();
|
||||
final mediaUris = playableTracks.map((track) => _buildTrackUri(track)).toList();
|
||||
|
||||
_logger.log('🎵 Playing ${mediaUris.length} tracks: ${mediaUris.take(3).join(", ")}${mediaUris.length > 3 ? "..." : ""}');
|
||||
|
||||
final option = clearQueue ? 'replace' : 'play';
|
||||
|
||||
@@ -1458,6 +2037,14 @@ class MusicAssistantAPI {
|
||||
return _eventStreams['player_updated']!.stream;
|
||||
}
|
||||
|
||||
/// Stream of player_added events (for refreshing player list when new players join)
|
||||
Stream<Map<String, dynamic>> get playerAddedEvents {
|
||||
if (!_eventStreams.containsKey('player_added')) {
|
||||
_eventStreams['player_added'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['player_added']!.stream;
|
||||
}
|
||||
|
||||
/// Register this device as a player with retry logic
|
||||
/// CRITICAL: This creates a player config in MA's settings.json
|
||||
/// The server expects: player_id and player_name
|
||||
@@ -1703,6 +2290,155 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SENDSPIN PLAYER MANAGEMENT (MA 2.7.0b20+ replacement for builtin_player)
|
||||
// ============================================================================
|
||||
|
||||
/// Stream of Sendspin player events
|
||||
Stream<Map<String, dynamic>> get sendspinPlayerEvents {
|
||||
if (!_eventStreams.containsKey('sendspin_player')) {
|
||||
_eventStreams['sendspin_player'] = StreamController<Map<String, dynamic>>.broadcast();
|
||||
}
|
||||
return _eventStreams['sendspin_player']!.stream;
|
||||
}
|
||||
|
||||
/// Get Sendspin connection info for a player
|
||||
/// Returns connection details including:
|
||||
/// - local_ws_url: Direct WebSocket URL (e.g., ws://192.168.4.120:8927/sendspin)
|
||||
/// - ice_servers: List of ICE/TURN servers for WebRTC fallback
|
||||
/// - player_id: The player's ID
|
||||
Future<Map<String, dynamic>?> getSendspinConnectionInfo(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Getting connection info for player $playerId');
|
||||
|
||||
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
|
||||
/// Similar to updateBuiltinPlayerState but for Sendspin protocol
|
||||
Future<void> updateSendspinPlayerState(
|
||||
String playerId, {
|
||||
required bool powered,
|
||||
required bool playing,
|
||||
required bool paused,
|
||||
required int position,
|
||||
required int volume,
|
||||
required bool muted,
|
||||
}) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Updating player state: powered=$powered, playing=$playing, paused=$paused');
|
||||
await _sendCommand(
|
||||
'sendspin/update_state',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
'state': {
|
||||
'powered': powered,
|
||||
'playing': playing,
|
||||
'paused': paused,
|
||||
'position': position,
|
||||
'volume': volume,
|
||||
'muted': muted,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error updating player state: $e');
|
||||
// Non-fatal, don't rethrow
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect Sendspin player from server
|
||||
Future<void> disconnectSendspinPlayer(String playerId) async {
|
||||
try {
|
||||
_logger.log('Sendspin: Disconnecting player $playerId');
|
||||
await _sendCommand(
|
||||
'sendspin/disconnect',
|
||||
args: {
|
||||
'player_id': playerId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error disconnecting player: $e');
|
||||
// Non-fatal during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// END SENDSPIN PLAYER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
Future<void> _sendQueueCommand(String queueId, String command) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart' show unawaited;
|
||||
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart' as pcm;
|
||||
import 'debug_logger.dart';
|
||||
|
||||
/// Callback type for elapsed time updates
|
||||
typedef ElapsedTimeCallback = void Function(Duration elapsed);
|
||||
|
||||
/// Audio format configuration matching Sendspin protocol
|
||||
class PcmAudioFormat {
|
||||
final int sampleRate;
|
||||
final int channels;
|
||||
final int bitDepth;
|
||||
|
||||
const PcmAudioFormat({
|
||||
this.sampleRate = 48000,
|
||||
this.channels = 2,
|
||||
this.bitDepth = 16,
|
||||
});
|
||||
|
||||
/// Default Sendspin format: 48kHz, stereo, 16-bit PCM
|
||||
static const sendspin = PcmAudioFormat();
|
||||
}
|
||||
|
||||
/// Player state for PCM audio
|
||||
enum PcmPlayerState {
|
||||
idle,
|
||||
initializing,
|
||||
ready,
|
||||
playing,
|
||||
pausing, // Transitional state: pause requested, waiting for completion
|
||||
paused,
|
||||
resuming, // Transitional state: resume requested, re-initializing
|
||||
stopping, // Transitional state: stop requested, cleaning up
|
||||
error,
|
||||
}
|
||||
|
||||
/// Error types for PCM player operations
|
||||
enum PcmPlayerError {
|
||||
initializationFailed,
|
||||
feedFailed,
|
||||
pauseFailed,
|
||||
resumeFailed,
|
||||
streamError,
|
||||
releaseTimeout,
|
||||
}
|
||||
|
||||
/// Callback type for error events
|
||||
typedef PcmErrorCallback = void Function(PcmPlayerError error, String message);
|
||||
|
||||
/// Service to play raw PCM audio data from Sendspin WebSocket stream
|
||||
/// Uses flutter_pcm_sound plugin for low-level PCM playback
|
||||
///
|
||||
/// State Machine:
|
||||
/// - idle → initializing → ready → playing ↔ paused
|
||||
/// - Any state can transition to error
|
||||
/// - Transitional states (pausing, resuming, stopping) block operations
|
||||
class PcmAudioPlayer {
|
||||
final _logger = DebugLogger();
|
||||
|
||||
PcmPlayerState _state = PcmPlayerState.idle;
|
||||
PcmAudioFormat _format = PcmAudioFormat.sendspin;
|
||||
|
||||
StreamSubscription<Uint8List>? _audioSubscription;
|
||||
|
||||
// Audio buffer for smooth playback
|
||||
final List<Uint8List> _audioBuffer = [];
|
||||
bool _isFeeding = false;
|
||||
bool _isStarted = false;
|
||||
Completer<void>? _feedCompleter; // Tracks when current feed operation completes
|
||||
|
||||
// Error callback for operation failures
|
||||
PcmErrorCallback? onError;
|
||||
|
||||
// Maximum chunks to feed at once to keep native buffer small (~200ms)
|
||||
// Each chunk is ~25ms of audio, so 8 chunks = ~200ms
|
||||
// Reduced from 12 (300ms) to 8 for faster pause response
|
||||
static const int _maxChunksPerFeed = 8;
|
||||
|
||||
// Feed threshold - lower = faster pause but more risk of underruns
|
||||
// Reduced from 8000 to 5000 frames (~104ms vs ~166ms) for faster response
|
||||
static const int _feedThreshold = 5000;
|
||||
|
||||
// Stats
|
||||
int _framesPlayed = 0;
|
||||
int _bytesPlayed = 0;
|
||||
|
||||
// Elapsed time tracking for notification sync
|
||||
Timer? _elapsedTimeTimer;
|
||||
final _elapsedTimeController = StreamController<Duration>.broadcast();
|
||||
ElapsedTimeCallback? onElapsedTimeUpdate;
|
||||
|
||||
// Track offset for pause/resume (preserves position across pause cycles)
|
||||
int _bytesPlayedAtLastPause = 0;
|
||||
DateTime? _playbackStartTime;
|
||||
|
||||
PcmPlayerState get state => _state;
|
||||
bool get isPlaying => _state == PcmPlayerState.playing;
|
||||
bool get isPaused => _state == PcmPlayerState.paused;
|
||||
bool get isReady => _state == PcmPlayerState.ready || _state == PcmPlayerState.playing || _state == PcmPlayerState.paused;
|
||||
int get framesPlayed => _framesPlayed;
|
||||
int get bytesPlayed => _bytesPlayed;
|
||||
|
||||
/// Check if player is in a transitional state (operation in progress)
|
||||
bool get isTransitioning => _state == PcmPlayerState.pausing ||
|
||||
_state == PcmPlayerState.resuming ||
|
||||
_state == PcmPlayerState.stopping;
|
||||
|
||||
/// Check if feeding should be blocked
|
||||
bool get _shouldBlockFeeding => isTransitioning || _state == PcmPlayerState.paused;
|
||||
|
||||
/// Stream of elapsed time updates (emits every 500ms when playing)
|
||||
Stream<Duration> get elapsedTimeStream => _elapsedTimeController.stream;
|
||||
|
||||
/// Calculate elapsed playback time from bytes played
|
||||
/// For 48kHz stereo 16-bit: 4 bytes per frame, 48000 frames per second
|
||||
Duration get elapsedTime {
|
||||
// Bytes per frame = channels * (bitDepth / 8) = 2 * 2 = 4
|
||||
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
|
||||
final framesFromBytes = _bytesPlayed / bytesPerFrame;
|
||||
final elapsedSeconds = framesFromBytes / _format.sampleRate;
|
||||
return Duration(milliseconds: (elapsedSeconds * 1000).round());
|
||||
}
|
||||
|
||||
/// Get elapsed time in seconds (for convenience)
|
||||
double get elapsedTimeSeconds {
|
||||
final bytesPerFrame = _format.channels * (_format.bitDepth ~/ 8);
|
||||
final framesFromBytes = _bytesPlayed / bytesPerFrame;
|
||||
return framesFromBytes / _format.sampleRate;
|
||||
}
|
||||
|
||||
/// Initialize the PCM player with the given format
|
||||
Future<bool> initialize({PcmAudioFormat? format}) async {
|
||||
if (_state == PcmPlayerState.initializing) return false;
|
||||
|
||||
_format = format ?? PcmAudioFormat.sendspin;
|
||||
_state = PcmPlayerState.initializing;
|
||||
|
||||
try {
|
||||
_logger.log('PcmAudioPlayer: Initializing (${_format.sampleRate}Hz, ${_format.channels}ch, ${_format.bitDepth}bit)');
|
||||
|
||||
// Setup flutter_pcm_sound with Sendspin audio format
|
||||
await pcm.FlutterPcmSound.setup(
|
||||
sampleRate: _format.sampleRate,
|
||||
channelCount: _format.channels,
|
||||
);
|
||||
|
||||
// Set feed threshold - request more data when buffer has fewer frames
|
||||
// Lower threshold = lower latency but more risk of underruns
|
||||
// Using _feedThreshold (5000 frames = ~104ms) for faster pause response
|
||||
await pcm.FlutterPcmSound.setFeedThreshold(_feedThreshold);
|
||||
|
||||
// Set up feed callback for when buffer needs more data
|
||||
pcm.FlutterPcmSound.setFeedCallback(_onFeedRequested);
|
||||
|
||||
// Set log level for debugging
|
||||
await pcm.FlutterPcmSound.setLogLevel(pcm.LogLevel.standard);
|
||||
|
||||
_state = PcmPlayerState.ready;
|
||||
_logger.log('PcmAudioPlayer: Initialized successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Initialization failed: $e');
|
||||
_state = PcmPlayerState.error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback when flutter_pcm_sound needs more audio data
|
||||
void _onFeedRequested(int remainingFrames) {
|
||||
// This is called from native when buffer is getting low
|
||||
// Block feeding during pause, transitional states, or when not playing
|
||||
if (_state != PcmPlayerState.playing) return;
|
||||
if (_shouldBlockFeeding) return;
|
||||
if (_audioBuffer.isEmpty || _isFeeding) return;
|
||||
|
||||
_feedNextChunk();
|
||||
}
|
||||
|
||||
/// Connect to a Sendspin audio data stream and start playback
|
||||
Future<bool> connectToStream(Stream<Uint8List> audioStream) async {
|
||||
if (_state == PcmPlayerState.error || _state == PcmPlayerState.idle) {
|
||||
_logger.log('PcmAudioPlayer: Cannot connect - player not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any existing subscription
|
||||
await _audioSubscription?.cancel();
|
||||
|
||||
_logger.log('PcmAudioPlayer: Connecting to audio stream');
|
||||
|
||||
// Subscribe to the audio stream
|
||||
_audioSubscription = audioStream.listen(
|
||||
_onAudioData,
|
||||
onError: _onStreamError,
|
||||
onDone: _onStreamDone,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Handle incoming audio data from the stream
|
||||
void _onAudioData(Uint8List audioData) {
|
||||
// Ignore data if in error state or if in transitional/paused state
|
||||
if (_state == PcmPlayerState.error) return;
|
||||
if (_shouldBlockFeeding) {
|
||||
// Silently ignore audio data when paused or transitioning
|
||||
// This prevents buffer accumulation during pause and any race conditions
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
_audioBuffer.add(audioData);
|
||||
|
||||
// Start playback if not already started
|
||||
if (!_isStarted && _state == PcmPlayerState.ready) {
|
||||
_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
|
||||
if (!_isFeeding && _isStarted) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start audio playback
|
||||
Future<void> _startPlayback() async {
|
||||
if (_isStarted) return;
|
||||
|
||||
try {
|
||||
await pcm.FlutterPcmSound.start();
|
||||
_isStarted = true;
|
||||
_state = PcmPlayerState.playing;
|
||||
_playbackStartTime = DateTime.now();
|
||||
_startElapsedTimeTimer();
|
||||
_logger.log('PcmAudioPlayer: Started playback');
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Error starting playback: $e');
|
||||
_state = PcmPlayerState.error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the elapsed time update timer
|
||||
void _startElapsedTimeTimer() {
|
||||
_elapsedTimeTimer?.cancel();
|
||||
_elapsedTimeTimer = Timer.periodic(
|
||||
const Duration(milliseconds: 500),
|
||||
(_) => _emitElapsedTime(),
|
||||
);
|
||||
// Emit immediately
|
||||
_emitElapsedTime();
|
||||
}
|
||||
|
||||
/// Stop the elapsed time update timer
|
||||
void _stopElapsedTimeTimer() {
|
||||
_elapsedTimeTimer?.cancel();
|
||||
_elapsedTimeTimer = null;
|
||||
}
|
||||
|
||||
/// Emit the current elapsed time to listeners
|
||||
void _emitElapsedTime() {
|
||||
if (_state != PcmPlayerState.playing) return;
|
||||
|
||||
final elapsed = elapsedTime;
|
||||
if (!_elapsedTimeController.isClosed) {
|
||||
_elapsedTimeController.add(elapsed);
|
||||
}
|
||||
onElapsedTimeUpdate?.call(elapsed);
|
||||
}
|
||||
|
||||
/// Feed the next chunk of audio data to the player
|
||||
/// CRITICAL: Only feeds limited chunks to keep native buffer small (~200ms)
|
||||
/// This enables faster pause response by minimizing buffered audio
|
||||
Future<void> _feedNextChunk() async {
|
||||
if (_isFeeding || _audioBuffer.isEmpty || _shouldBlockFeeding) return;
|
||||
|
||||
_isFeeding = true;
|
||||
_feedCompleter = Completer<void>();
|
||||
|
||||
try {
|
||||
int chunksProcessed = 0;
|
||||
|
||||
// CRITICAL: Only feed limited chunks to keep native buffer small
|
||||
// This is the key to fast pause - we drain ~200ms instead of 5-8 seconds
|
||||
while (_audioBuffer.isNotEmpty &&
|
||||
chunksProcessed < _maxChunksPerFeed &&
|
||||
_state == PcmPlayerState.playing &&
|
||||
!_shouldBlockFeeding) {
|
||||
final chunk = _audioBuffer.removeAt(0);
|
||||
|
||||
// Convert Uint8List (raw bytes) to Int16 samples
|
||||
// Sendspin sends 16-bit little-endian PCM
|
||||
final samples = _bytesToInt16List(chunk);
|
||||
|
||||
// Check state again before the async feed call
|
||||
if (samples.isNotEmpty && !_shouldBlockFeeding) {
|
||||
await pcm.FlutterPcmSound.feed(pcm.PcmArrayInt16.fromList(samples));
|
||||
|
||||
// Don't update stats if we got paused during the feed
|
||||
if (!_shouldBlockFeeding) {
|
||||
_framesPlayed++;
|
||||
_bytesPlayed += chunk.length;
|
||||
chunksProcessed++;
|
||||
|
||||
// Log periodically
|
||||
if (_framesPlayed % 100 == 0) {
|
||||
_logger.log('PcmAudioPlayer: Played $_framesPlayed frames (${(_bytesPlayed / 1024).toStringAsFixed(1)} KB)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Yield to event loop mid-feed to allow UI to respond
|
||||
if (chunksProcessed % 4 == 0) {
|
||||
await Future.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't log errors during pause/transition - expected when player is released
|
||||
if (!_shouldBlockFeeding) {
|
||||
_logger.log('PcmAudioPlayer: Error feeding audio: $e');
|
||||
_emitError(PcmPlayerError.feedFailed, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
_isFeeding = false;
|
||||
_feedCompleter?.complete();
|
||||
_feedCompleter = null;
|
||||
}
|
||||
|
||||
/// Emit an error to the callback
|
||||
void _emitError(PcmPlayerError error, String message) {
|
||||
_logger.log('PcmAudioPlayer: Error - $error: $message');
|
||||
onError?.call(error, message);
|
||||
}
|
||||
|
||||
/// Convert raw bytes (Uint8List) to Int16 samples
|
||||
/// Assumes little-endian 16-bit PCM
|
||||
List<int> _bytesToInt16List(Uint8List bytes) {
|
||||
if (bytes.length < 2) return [];
|
||||
|
||||
final byteData = ByteData.sublistView(bytes);
|
||||
final samples = <int>[];
|
||||
|
||||
for (int i = 0; i < bytes.length - 1; i += 2) {
|
||||
samples.add(byteData.getInt16(i, Endian.little));
|
||||
}
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
/// Handle stream errors - notify listeners and pause playback
|
||||
void _onStreamError(dynamic error) {
|
||||
_logger.log('PcmAudioPlayer: Stream error: $error');
|
||||
_emitError(PcmPlayerError.streamError, error.toString());
|
||||
|
||||
// Pause on stream error to prevent playback of corrupted/incomplete audio
|
||||
if (_state == PcmPlayerState.playing) {
|
||||
_logger.log('PcmAudioPlayer: Pausing due to stream error');
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle stream completion
|
||||
void _onStreamDone() {
|
||||
_logger.log('PcmAudioPlayer: Audio stream ended');
|
||||
// Don't stop immediately - let buffered audio finish
|
||||
}
|
||||
|
||||
/// Start playback (if paused or ready)
|
||||
/// Returns true if playback started successfully
|
||||
Future<bool> play() async {
|
||||
if (_state == PcmPlayerState.error) return false;
|
||||
if (isTransitioning) {
|
||||
_logger.log('PcmAudioPlayer: Cannot play - operation in progress (state: $_state)');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_state == PcmPlayerState.paused) {
|
||||
// Resume from pause - need to re-initialize since we released on pause
|
||||
_logger.log('PcmAudioPlayer: Resuming from pause at ${elapsedTime.inSeconds}s');
|
||||
_state = PcmPlayerState.resuming;
|
||||
|
||||
// Re-initialize the PCM player (it was released on pause)
|
||||
try {
|
||||
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;
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Error re-initializing on resume: $e');
|
||||
_emitError(PcmPlayerError.resumeFailed, e.toString());
|
||||
_state = PcmPlayerState.error;
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = PcmPlayerState.playing;
|
||||
_startElapsedTimeTimer();
|
||||
|
||||
// Resume feeding - new audio will come from stream
|
||||
if (!_isFeeding && _audioBuffer.isNotEmpty) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
|
||||
_logger.log('PcmAudioPlayer: Resumed playback');
|
||||
return true;
|
||||
} else if (_state == PcmPlayerState.ready) {
|
||||
await _startPlayback();
|
||||
_logger.log('PcmAudioPlayer: Started playback');
|
||||
|
||||
// Start feeding
|
||||
if (!_isFeeding && _audioBuffer.isNotEmpty) {
|
||||
_feedNextChunk();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Pause playback - stops feeding and releases player for instant stop
|
||||
/// Position is preserved via _bytesPlayed tracking
|
||||
/// Uses release() to clear native buffer for instant audio stop
|
||||
/// Returns true if pause was successful
|
||||
Future<bool> pause() async {
|
||||
if (_state != PcmPlayerState.playing) {
|
||||
_logger.log('PcmAudioPlayer: Cannot pause - not playing (state: $_state)');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('PcmAudioPlayer: Pause requested');
|
||||
|
||||
// Set pausing state FIRST to stop feed loop from starting new feeds
|
||||
_state = PcmPlayerState.pausing;
|
||||
|
||||
// Clear our buffer - no more data will be fed
|
||||
_audioBuffer.clear();
|
||||
|
||||
// Clear feed callback to prevent native from triggering more feeds
|
||||
pcm.FlutterPcmSound.setFeedCallback(null);
|
||||
|
||||
// Mark as not feeding
|
||||
_isFeeding = false;
|
||||
|
||||
// Save position and stop timer
|
||||
_bytesPlayedAtLastPause = _bytesPlayed;
|
||||
_stopElapsedTimeTimer();
|
||||
|
||||
// Schedule release() to clear native buffer - use Future.delayed to yield control
|
||||
// This allows UI to update before the potentially blocking release() call
|
||||
unawaited(Future.delayed(Duration.zero, () async {
|
||||
try {
|
||||
await pcm.FlutterPcmSound.release().timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {
|
||||
_logger.log('PcmAudioPlayer: Release timed out');
|
||||
_emitError(PcmPlayerError.releaseTimeout, 'release() timed out');
|
||||
},
|
||||
);
|
||||
_isStarted = false;
|
||||
_logger.log('PcmAudioPlayer: Player released for instant stop');
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Release error (expected if already released): $e');
|
||||
}
|
||||
}));
|
||||
|
||||
// Transition to paused state
|
||||
_state = PcmPlayerState.paused;
|
||||
_logger.log('PcmAudioPlayer: Paused playback at ${elapsedTime.inSeconds}s');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Stop playback (clears buffer and resets position)
|
||||
/// Returns true if stop was successful
|
||||
Future<bool> stop() async {
|
||||
if (_state == PcmPlayerState.idle) return true;
|
||||
|
||||
_logger.log('PcmAudioPlayer: Stop requested');
|
||||
|
||||
// Set stopping state to block any in-flight feed operations
|
||||
_state = PcmPlayerState.stopping;
|
||||
_isStarted = false;
|
||||
_stopElapsedTimeTimer();
|
||||
|
||||
// Clear the feed callback to prevent native code from triggering new feeds
|
||||
pcm.FlutterPcmSound.setFeedCallback(null);
|
||||
|
||||
// Wait for any in-progress feed operation to complete (with timeout)
|
||||
final completer = _feedCompleter;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
await completer.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {
|
||||
_logger.log('PcmAudioPlayer: Feed wait timed out on stop');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_audioBuffer.clear();
|
||||
_isFeeding = false;
|
||||
|
||||
try {
|
||||
await pcm.FlutterPcmSound.release();
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Error releasing: $e');
|
||||
}
|
||||
|
||||
_state = PcmPlayerState.ready;
|
||||
_framesPlayed = 0;
|
||||
_bytesPlayed = 0;
|
||||
_bytesPlayedAtLastPause = 0;
|
||||
_playbackStartTime = null;
|
||||
_logger.log('PcmAudioPlayer: Stopped playback');
|
||||
|
||||
// Re-initialize for next playback
|
||||
await initialize(format: _format);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Reset position to zero (for new track) without stopping playback
|
||||
void resetPosition() {
|
||||
_framesPlayed = 0;
|
||||
_bytesPlayed = 0;
|
||||
_bytesPlayedAtLastPause = 0;
|
||||
_playbackStartTime = DateTime.now();
|
||||
_logger.log('PcmAudioPlayer: Position reset to 0');
|
||||
_emitElapsedTime();
|
||||
}
|
||||
|
||||
/// Disconnect from audio stream
|
||||
Future<void> disconnect() async {
|
||||
await _audioSubscription?.cancel();
|
||||
_audioSubscription = null;
|
||||
await stop();
|
||||
_logger.log('PcmAudioPlayer: Disconnected from stream');
|
||||
}
|
||||
|
||||
/// Release all resources
|
||||
Future<void> dispose() async {
|
||||
_logger.log('PcmAudioPlayer: Disposing...');
|
||||
|
||||
// Set stopping state to block any feed operations
|
||||
_state = PcmPlayerState.stopping;
|
||||
_isStarted = false;
|
||||
_stopElapsedTimeTimer();
|
||||
|
||||
// Clear the feed callback first
|
||||
pcm.FlutterPcmSound.setFeedCallback(null);
|
||||
|
||||
// Wait for any in-progress feed operation
|
||||
final completer = _feedCompleter;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
await completer.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {
|
||||
_logger.log('PcmAudioPlayer: Feed wait timed out on dispose');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_isFeeding = false;
|
||||
await _audioSubscription?.cancel();
|
||||
_audioSubscription = null;
|
||||
_audioBuffer.clear();
|
||||
|
||||
try {
|
||||
await pcm.FlutterPcmSound.release().timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {
|
||||
_logger.log('PcmAudioPlayer: Release timed out on dispose');
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('PcmAudioPlayer: Error releasing: $e');
|
||||
}
|
||||
|
||||
if (!_elapsedTimeController.isClosed) {
|
||||
await _elapsedTimeController.close();
|
||||
}
|
||||
|
||||
_state = PcmPlayerState.idle;
|
||||
_logger.log('PcmAudioPlayer: Disposed');
|
||||
}
|
||||
}
|
||||
@@ -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,243 @@
|
||||
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 {
|
||||
await _db.addRecentlyPlayed(
|
||||
mediaId: album.itemId,
|
||||
mediaType: 'album',
|
||||
name: album.name,
|
||||
artistName: album.artistsString,
|
||||
metadata: {
|
||||
'provider': album.provider,
|
||||
'uri': album.uri,
|
||||
},
|
||||
);
|
||||
_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 (_) {}
|
||||
}
|
||||
|
||||
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,
|
||||
));
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'device_id_service.dart';
|
||||
|
||||
/// Connection state for Sendspin player
|
||||
enum SendspinConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Callback types for Sendspin events
|
||||
typedef SendspinPlayCallback = void Function(String streamUrl, Map<String, dynamic> trackInfo);
|
||||
typedef SendspinPauseCallback = void Function();
|
||||
typedef SendspinStopCallback = void Function();
|
||||
typedef SendspinSeekCallback = void Function(int positionSeconds);
|
||||
typedef SendspinVolumeCallback = void Function(int volumeLevel);
|
||||
typedef SendspinAudioDataCallback = void Function(Uint8List audioData);
|
||||
typedef SendspinStreamStartCallback = void Function(Map<String, dynamic>? trackInfo);
|
||||
typedef SendspinStreamEndCallback = void Function();
|
||||
|
||||
/// Service to manage Sendspin WebSocket connection for local playback
|
||||
/// Sendspin is the replacement for builtin_player in MA 2.7.0b20+
|
||||
///
|
||||
/// Connection strategy (smart fallback for external access):
|
||||
/// 1. If server is HTTPS, try external wss://{server}/sendspin first
|
||||
/// 2. Fall back to local_ws_url from API (ws://local-ip:8927/sendspin)
|
||||
/// 3. WebRTC fallback as last resort (requires TURN servers)
|
||||
class SendspinService {
|
||||
final String serverUrl;
|
||||
final _logger = DebugLogger();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _heartbeatTimer;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Connection state
|
||||
SendspinConnectionState _state = SendspinConnectionState.disconnected;
|
||||
SendspinConnectionState get state => _state;
|
||||
|
||||
final _stateController = StreamController<SendspinConnectionState>.broadcast();
|
||||
Stream<SendspinConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
// Player info
|
||||
String? _playerId;
|
||||
String? _playerName;
|
||||
String? _connectedUrl;
|
||||
|
||||
String? get playerId => _playerId;
|
||||
String? get playerName => _playerName;
|
||||
|
||||
// Event callbacks
|
||||
SendspinPlayCallback? onPlay;
|
||||
SendspinPauseCallback? onPause;
|
||||
SendspinStopCallback? onStop;
|
||||
SendspinSeekCallback? onSeek;
|
||||
SendspinVolumeCallback? onVolume;
|
||||
SendspinAudioDataCallback? onAudioData;
|
||||
SendspinStreamStartCallback? onStreamStart;
|
||||
SendspinStreamEndCallback? onStreamEnd;
|
||||
|
||||
// Audio data stream for raw PCM frames
|
||||
final _audioDataController = StreamController<Uint8List>.broadcast();
|
||||
Stream<Uint8List> get audioDataStream => _audioDataController.stream;
|
||||
|
||||
// Track audio streaming state
|
||||
bool _isStreamingAudio = false;
|
||||
int _audioFramesReceived = 0;
|
||||
|
||||
// Player state for reporting to server
|
||||
bool _isPowered = true;
|
||||
bool _isPlaying = false;
|
||||
bool _isPaused = false;
|
||||
int _position = 0;
|
||||
int _volume = 100;
|
||||
bool _isMuted = false;
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
SendspinService(this.serverUrl);
|
||||
|
||||
/// Initialize and connect to Sendspin server
|
||||
/// Uses persistent player ID and username-based naming
|
||||
Future<bool> connect() async {
|
||||
if (_isDisposed) return false;
|
||||
if (_state == SendspinConnectionState.connected) return true;
|
||||
|
||||
_updateState(SendspinConnectionState.connecting);
|
||||
|
||||
try {
|
||||
// Get persistent player ID and name
|
||||
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_playerName = await SettingsService.getLocalPlayerName();
|
||||
|
||||
_logger.log('Sendspin: Connecting as "$_playerName" (ID: $_playerId)');
|
||||
|
||||
// Try connection strategies in order
|
||||
bool connected = false;
|
||||
|
||||
// Strategy 1: If server is HTTPS, try external wss:// first
|
||||
if (_isHttpsServer()) {
|
||||
final externalUrl = _buildExternalSendspinUrl();
|
||||
_logger.log('Sendspin: Trying external URL: $externalUrl');
|
||||
connected = await _tryConnect(externalUrl, timeout: const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
// Strategy 2: If external failed or server is HTTP, try local connection
|
||||
// Note: We would need local_ws_url from getSendspinConnectionInfo API
|
||||
// For now, skip this step and fall back to WebRTC (handled by provider)
|
||||
|
||||
if (!connected) {
|
||||
_logger.log('Sendspin: External connection failed, WebRTC fallback needed');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Connection error: $e');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect with a specific WebSocket URL (called by provider with local_ws_url)
|
||||
Future<bool> connectWithUrl(String wsUrl) async {
|
||||
if (_isDisposed) return false;
|
||||
if (_state == SendspinConnectionState.connected) return true;
|
||||
|
||||
_updateState(SendspinConnectionState.connecting);
|
||||
|
||||
try {
|
||||
_playerId = await DeviceIdService.getOrCreateDevicePlayerId();
|
||||
_playerName = await SettingsService.getLocalPlayerName();
|
||||
|
||||
_logger.log('Sendspin: Connecting with URL: $wsUrl');
|
||||
|
||||
final connected = await _tryConnect(wsUrl, timeout: const Duration(seconds: 5));
|
||||
|
||||
if (!connected) {
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Connection error: $e');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to connect to a specific WebSocket URL
|
||||
Future<bool> _tryConnect(String url, {Duration timeout = const Duration(seconds: 5)}) async {
|
||||
try {
|
||||
// Connect to the base URL without query params - we send player info in hello message
|
||||
_logger.log('Sendspin: Connecting to $url');
|
||||
|
||||
// Create WebSocket connection
|
||||
final webSocket = await WebSocket.connect(url).timeout(timeout);
|
||||
|
||||
_channel = IOWebSocketChannel(webSocket);
|
||||
_connectedUrl = url;
|
||||
|
||||
// Set up message listener BEFORE sending hello
|
||||
_channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDone,
|
||||
);
|
||||
|
||||
// Send client/hello message immediately after connecting
|
||||
// This is required by the Sendspin protocol - server waits for this
|
||||
// Message format follows aiosendspin protocol spec
|
||||
_logger.log('Sendspin: Sending client/hello with player_id=$_playerId');
|
||||
_sendMessage({
|
||||
'type': 'client/hello',
|
||||
'payload': {
|
||||
'client_id': _playerId,
|
||||
'name': _playerName,
|
||||
'version': 1, // Protocol version (integer)
|
||||
'supported_roles': ['player@v1'],
|
||||
'player_support': {
|
||||
'supported_formats': [
|
||||
{
|
||||
'codec': 'pcm',
|
||||
'channels': 2,
|
||||
'sample_rate': 48000,
|
||||
'bit_depth': 16,
|
||||
},
|
||||
],
|
||||
'buffer_capacity': 1048576, // 1MB buffer
|
||||
'supported_commands': ['volume', 'mute'],
|
||||
},
|
||||
},
|
||||
}, allowDuringHandshake: true);
|
||||
|
||||
// Wait for server acknowledgment (server sends welcome/registered after hello)
|
||||
final ackReceived = await _waitForAck().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => false,
|
||||
);
|
||||
|
||||
if (!ackReceived) {
|
||||
_logger.log('Sendspin: No acknowledgment from server after hello');
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.log('Sendspin: Connected and registered successfully');
|
||||
_updateState(SendspinConnectionState.connected);
|
||||
_startHeartbeat();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Connection attempt failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for server acknowledgment after connection
|
||||
Completer<bool>? _ackCompleter;
|
||||
|
||||
Future<bool> _waitForAck() async {
|
||||
_ackCompleter = Completer<bool>();
|
||||
return _ackCompleter!.future;
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
/// WebSocket can send text frames (JSON control messages) or binary frames (PCM audio data)
|
||||
void _handleMessage(dynamic message) {
|
||||
// Check if this is binary audio data (Uint8List) or text (String)
|
||||
if (message is List<int>) {
|
||||
_handleBinaryAudioData(Uint8List.fromList(message));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text messages (JSON)
|
||||
if (message is! String) {
|
||||
_logger.log('Sendspin: Unexpected message type: ${message.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final data = jsonDecode(message) as Map<String, dynamic>;
|
||||
final type = data['type'] as String?;
|
||||
|
||||
_logger.log('Sendspin: Received message type: $type');
|
||||
|
||||
switch (type) {
|
||||
case 'server/hello':
|
||||
// Server acknowledged our client/hello - we're registered!
|
||||
_logger.log('Sendspin: Received server/hello - connection successful');
|
||||
final payload = data['payload'] as Map<String, dynamic>?;
|
||||
if (payload != null) {
|
||||
_logger.log('Sendspin: Server name: ${payload['name']}, version: ${payload['version']}');
|
||||
_logger.log('Sendspin: Active roles: ${payload['active_roles']}');
|
||||
}
|
||||
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
|
||||
_ackCompleter!.complete(true);
|
||||
}
|
||||
// Send initial client/state immediately after handshake (required by spec)
|
||||
// Note: These must use allowDuringHandshake since state is still 'connecting'
|
||||
_sendInitialState();
|
||||
// Also send initial time sync
|
||||
_sendClientTime(allowDuringHandshake: true);
|
||||
break;
|
||||
|
||||
case 'server/time':
|
||||
// Server responded to our time sync - can be used to calculate clock offset
|
||||
// For now, just log it (proper sync would use Kalman filter)
|
||||
_logger.log('Sendspin: Received server/time response');
|
||||
break;
|
||||
|
||||
case 'group/update':
|
||||
// Group state update - store for reference but no response needed
|
||||
_logger.log('Sendspin: Received group/update');
|
||||
final groupPayload = data['payload'] as Map<String, dynamic>?;
|
||||
if (groupPayload != null) {
|
||||
_logger.log('Sendspin: Group state: ${groupPayload['playback_state']}');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'stream/start':
|
||||
// Server is starting to send audio data
|
||||
_logger.log('Sendspin: Audio stream starting');
|
||||
_isStreamingAudio = true;
|
||||
_audioFramesReceived = 0;
|
||||
_isPlaying = true;
|
||||
_isPaused = false;
|
||||
// Notify provider to start PCM player and foreground service
|
||||
final streamPayload = data['payload'] as Map<String, dynamic>?;
|
||||
onStreamStart?.call(streamPayload);
|
||||
break;
|
||||
|
||||
case 'stream/end':
|
||||
// Server finished sending audio data
|
||||
_logger.log('Sendspin: Audio stream ended (received $_audioFramesReceived frames)');
|
||||
_isStreamingAudio = false;
|
||||
_isPlaying = false;
|
||||
// Notify provider to stop PCM player
|
||||
onStreamEnd?.call();
|
||||
break;
|
||||
|
||||
case 'ack':
|
||||
case 'connected':
|
||||
case 'registered':
|
||||
// Legacy/fallback acknowledgment types
|
||||
if (_ackCompleter != null && !_ackCompleter!.isCompleted) {
|
||||
_ackCompleter!.complete(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'play':
|
||||
// Server wants us to play audio (URL-based, not raw PCM)
|
||||
final streamUrl = data['url'] as String?;
|
||||
final trackInfo = data['track'] as Map<String, dynamic>? ?? {};
|
||||
if (streamUrl != null && onPlay != null) {
|
||||
_isPlaying = true;
|
||||
_isPaused = false;
|
||||
onPlay!(streamUrl, trackInfo);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
_isPaused = true;
|
||||
_isPlaying = false;
|
||||
onPause?.call();
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
_isPlaying = false;
|
||||
_isPaused = false;
|
||||
_isStreamingAudio = false;
|
||||
onStop?.call();
|
||||
break;
|
||||
|
||||
case 'seek':
|
||||
final position = data['position'] as int?;
|
||||
if (position != null) {
|
||||
_position = position;
|
||||
onSeek?.call(position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'volume':
|
||||
final level = data['level'] as int?;
|
||||
if (level != null) {
|
||||
_volume = level;
|
||||
onVolume?.call(level);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ping':
|
||||
// Respond to server ping
|
||||
_sendMessage({'type': 'pong'});
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
final errorMsg = data['message'] as String?;
|
||||
_logger.log('Sendspin: Server error: $errorMsg');
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.log('Sendspin: Unknown message type: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error handling JSON message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle binary audio data (PCM frames from server)
|
||||
/// Sendspin binary frame format:
|
||||
/// - Byte 0: message type (uint8) - Type 4 = audio data
|
||||
/// - Bytes 1-8: timestamp (int64 microseconds, little-endian) for synchronization
|
||||
/// - Bytes 9+: actual PCM audio data (16-bit stereo 48kHz)
|
||||
void _handleBinaryAudioData(Uint8List frame) {
|
||||
// Minimum frame size: 1 (type) + 8 (timestamp) + 2 (at least one sample)
|
||||
if (frame.length < 11) {
|
||||
_logger.log('Sendspin: Binary frame too short: ${frame.length} bytes');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse header
|
||||
final messageType = frame[0];
|
||||
|
||||
// Type 4 = audio data, other types may be used for other purposes
|
||||
if (messageType != 4) {
|
||||
_logger.log('Sendspin: Unexpected binary message type: $messageType');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract timestamp (bytes 1-8, little-endian int64)
|
||||
final timestampBytes = ByteData.sublistView(frame, 1, 9);
|
||||
final timestamp = timestampBytes.getInt64(0, Endian.little);
|
||||
|
||||
// Extract actual PCM audio data (everything after the 9-byte header)
|
||||
final audioData = Uint8List.sublistView(frame, 9);
|
||||
|
||||
_audioFramesReceived++;
|
||||
|
||||
// Log periodically to avoid spam
|
||||
if (_audioFramesReceived == 1) {
|
||||
_logger.log('Sendspin: Receiving audio data (first frame: ${frame.length} bytes, audio: ${audioData.length} bytes, ts: $timestamp)');
|
||||
} else if (_audioFramesReceived % 100 == 0) {
|
||||
_logger.log('Sendspin: Received $_audioFramesReceived audio frames');
|
||||
}
|
||||
|
||||
// Emit only the PCM audio data (without header) to stream for consumers
|
||||
if (!_audioDataController.isClosed) {
|
||||
_audioDataController.add(audioData);
|
||||
}
|
||||
|
||||
// Call callback if registered
|
||||
onAudioData?.call(audioData);
|
||||
}
|
||||
|
||||
/// Handle WebSocket errors
|
||||
void _handleError(dynamic error) {
|
||||
_logger.log('Sendspin: WebSocket error: $error');
|
||||
_updateState(SendspinConnectionState.error);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Handle WebSocket close
|
||||
void _handleDone() {
|
||||
_logger.log('Sendspin: WebSocket closed');
|
||||
_updateState(SendspinConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Send a JSON message to the server
|
||||
/// allowDuringHandshake: set to true to send messages before connection is established (e.g., hello)
|
||||
void _sendMessage(Map<String, dynamic> message, {bool allowDuringHandshake = false}) {
|
||||
if (_channel == null) return;
|
||||
|
||||
// During handshake, we need to send hello even though state is still 'connecting'
|
||||
if (!allowDuringHandshake && _state != SendspinConnectionState.connected) return;
|
||||
|
||||
try {
|
||||
final json = jsonEncode(message);
|
||||
_logger.log('Sendspin: Sending message: ${message['type']}');
|
||||
_channel!.sink.add(json);
|
||||
} catch (e) {
|
||||
_logger.log('Sendspin: Error sending message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Report current player state to server
|
||||
/// Uses Sendspin protocol 'client/state' message format
|
||||
void reportState({
|
||||
bool? powered,
|
||||
bool? playing,
|
||||
bool? paused,
|
||||
int? position,
|
||||
int? volume,
|
||||
bool? muted,
|
||||
}) {
|
||||
if (powered != null) _isPowered = powered;
|
||||
if (playing != null) _isPlaying = playing;
|
||||
if (paused != null) _isPaused = paused;
|
||||
if (position != null) _position = position;
|
||||
if (volume != null) _volume = volume;
|
||||
if (muted != null) _isMuted = muted;
|
||||
|
||||
// Determine player state for Sendspin protocol
|
||||
// 'synchronized' = ready/playing, 'idle' = not playing
|
||||
final playerState = _isPlaying || _isPaused ? 'synchronized' : 'idle';
|
||||
|
||||
_sendMessage({
|
||||
'type': 'client/state',
|
||||
'payload': {
|
||||
'player': {
|
||||
'state': playerState,
|
||||
'volume': _volume,
|
||||
'muted': _isMuted,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if server URL is HTTPS
|
||||
bool _isHttpsServer() {
|
||||
return serverUrl.startsWith('https://') ||
|
||||
serverUrl.startsWith('wss://') ||
|
||||
(!serverUrl.contains('://') && !serverUrl.contains(':'));
|
||||
}
|
||||
|
||||
/// Build external Sendspin WebSocket URL from server URL
|
||||
String _buildExternalSendspinUrl() {
|
||||
var url = serverUrl;
|
||||
|
||||
// Convert HTTP(S) to WS(S)
|
||||
if (url.startsWith('https://')) {
|
||||
url = 'wss://${url.substring(8)}';
|
||||
} else if (url.startsWith('http://')) {
|
||||
url = 'ws://${url.substring(7)}';
|
||||
} else if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
|
||||
url = 'wss://$url';
|
||||
}
|
||||
|
||||
// Remove trailing slash and add /sendspin path
|
||||
url = url.replaceAll(RegExp(r'/+$'), '');
|
||||
|
||||
// Remove any existing path and add /sendspin
|
||||
final uri = Uri.parse(url);
|
||||
return Uri(
|
||||
scheme: uri.scheme,
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : null,
|
||||
path: '/sendspin',
|
||||
).toString();
|
||||
}
|
||||
|
||||
/// Start heartbeat timer using Sendspin's client/time for clock synchronization
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
_sendClientTime();
|
||||
});
|
||||
}
|
||||
|
||||
/// Send client/time message for clock synchronization (Sendspin protocol)
|
||||
void _sendClientTime({bool allowDuringHandshake = false}) {
|
||||
final timestampMicroseconds = DateTime.now().microsecondsSinceEpoch;
|
||||
_sendMessage({
|
||||
'type': 'client/time',
|
||||
'payload': {
|
||||
'timestamp': timestampMicroseconds,
|
||||
},
|
||||
}, allowDuringHandshake: allowDuringHandshake);
|
||||
}
|
||||
|
||||
/// Send initial client/state immediately after handshake (required by Sendspin spec)
|
||||
/// Must use allowDuringHandshake since state is still 'connecting' at this point
|
||||
void _sendInitialState() {
|
||||
_logger.log('Sendspin: Sending initial client/state');
|
||||
_sendMessage({
|
||||
'type': 'client/state',
|
||||
'payload': {
|
||||
'player': {
|
||||
'state': 'synchronized',
|
||||
'volume': _volume,
|
||||
'muted': _isMuted,
|
||||
},
|
||||
},
|
||||
}, allowDuringHandshake: true);
|
||||
}
|
||||
|
||||
/// Stop heartbeat timer
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
/// Schedule reconnection attempt
|
||||
void _scheduleReconnect() {
|
||||
if (_isDisposed) return;
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
||||
if (!_isDisposed && _state != SendspinConnectionState.connected) {
|
||||
_logger.log('Sendspin: Attempting reconnection...');
|
||||
if (_connectedUrl != null) {
|
||||
connectWithUrl(_connectedUrl!);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update connection state
|
||||
void _updateState(SendspinConnectionState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
_stateController.add(newState);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from server using Sendspin's client/goodbye message
|
||||
Future<void> disconnect() async {
|
||||
_stopHeartbeat();
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
if (_channel != null) {
|
||||
// Send graceful goodbye per Sendspin protocol
|
||||
_sendMessage({
|
||||
'type': 'client/goodbye',
|
||||
'payload': {
|
||||
'reason': 'user_request',
|
||||
},
|
||||
});
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
_updateState(SendspinConnectionState.disconnected);
|
||||
}
|
||||
|
||||
/// Dispose the service
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_stopHeartbeat();
|
||||
_reconnectTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_stateController.close();
|
||||
_audioDataController.close();
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,39 @@ class SettingsService {
|
||||
static const String _keyShowRecentAlbums = 'show_recent_albums';
|
||||
static const String _keyShowDiscoverArtists = 'show_discover_artists';
|
||||
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 _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||
static const String _keyShowFavoriteTracks = 'show_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}
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -351,6 +380,37 @@ class SettingsService {
|
||||
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)
|
||||
static Future<bool> getShowFavoriteAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -381,4 +441,251 @@ class SettingsService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -147,13 +147,13 @@ class _AlbumCardState extends State<AlbumCard> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album title
|
||||
// Album title with year
|
||||
Hero(
|
||||
tag: HeroTags.albumTitle + (widget.album.uri ?? widget.album.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
widget.album.name,
|
||||
widget.album.nameWithYear,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
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,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -286,8 +286,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final duration = Duration(seconds: seconds);
|
||||
final minutes = duration.inMinutes;
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
final secs = duration.inSeconds % 60;
|
||||
|
||||
// For audiobooks and long content (>= 1 hour), show hours
|
||||
if (hours > 0) {
|
||||
return '${hours}:${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${minutes.toString().padLeft(1, '0')}:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -326,27 +332,69 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
try {
|
||||
if (_isCurrentTrackFavorite) {
|
||||
// Remove from favorites
|
||||
final libraryItemId = currentTrack.itemId;
|
||||
if (libraryItemId is int) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
} else if (libraryItemId is String) {
|
||||
final parsed = int.tryParse(libraryItemId);
|
||||
if (parsed != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', parsed);
|
||||
// Remove from favorites - need library_item_id (numeric)
|
||||
int? libraryItemId;
|
||||
|
||||
if (currentTrack.provider == 'library') {
|
||||
// If provider is library, itemId is the library ID
|
||||
libraryItemId = int.tryParse(currentTrack.itemId.toString());
|
||||
} else if (currentTrack.providerMappings != null) {
|
||||
// Find the library mapping to get the library ID
|
||||
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
||||
if (mappings != null) {
|
||||
for (final mapping in mappings) {
|
||||
if (mapping.providerInstance == 'library') {
|
||||
libraryItemId = int.tryParse(mapping.itemId.toString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraryItemId != null) {
|
||||
await maProvider.api!.removeFromFavorites('track', libraryItemId);
|
||||
} else {
|
||||
throw Exception('Could not determine library ID for this track');
|
||||
}
|
||||
} else {
|
||||
// Add to favorites
|
||||
final provider = currentTrack.provider as String? ?? 'library';
|
||||
final itemId = currentTrack.itemId?.toString() ?? '';
|
||||
await maProvider.api!.addToFavorites('track', itemId, provider);
|
||||
// Add to favorites - use the source provider (not library)
|
||||
String actualProvider = currentTrack.provider?.toString() ?? 'library';
|
||||
String actualItemId = currentTrack.itemId?.toString() ?? '';
|
||||
|
||||
if (currentTrack.providerMappings != null) {
|
||||
final mappings = currentTrack.providerMappings as List<dynamic>?;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
// Find a non-library provider mapping
|
||||
for (final mapping in mappings) {
|
||||
if (mapping.available == true && mapping.providerInstance != 'library') {
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback to first available if no non-library found
|
||||
if (actualProvider == 'library') {
|
||||
for (final mapping in mappings) {
|
||||
if (mapping.available == true) {
|
||||
actualProvider = mapping.providerInstance;
|
||||
actualItemId = mapping.itemId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await maProvider.api!.addToFavorites('track', actualItemId, actualProvider);
|
||||
}
|
||||
|
||||
// Toggle local state
|
||||
setState(() {
|
||||
_isCurrentTrackFavorite = !_isCurrentTrackFavorite;
|
||||
});
|
||||
|
||||
// Invalidate home cache so favorites are updated
|
||||
maProvider.invalidateHomeCache();
|
||||
} catch (e) {
|
||||
// Show error feedback
|
||||
if (mounted) {
|
||||
@@ -360,6 +408,50 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show fullscreen album art overlay
|
||||
void _showFullscreenArt(BuildContext context, String? imageUrl) {
|
||||
if (imageUrl == null) return;
|
||||
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
barrierColor: Colors.black87,
|
||||
barrierDismissible: true,
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 300) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Center(
|
||||
child: Hero(
|
||||
tag: 'fullscreen_album_art',
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 3.0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: 1024,
|
||||
memCacheHeight: 1024,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get available players sorted alphabetically (consistent with device selector)
|
||||
List<dynamic> _getAvailablePlayersSorted(MusicAssistantProvider maProvider) {
|
||||
// Use the provider's already-sorted list, filter for available only
|
||||
@@ -502,11 +594,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
/// Animate committing to the next/previous player
|
||||
void _animateCommit(int direction, VoidCallback onSwitch) {
|
||||
if (_isSliding) return;
|
||||
_isSliding = true;
|
||||
|
||||
// Mark transition BEFORE animation - this keeps peek content visible
|
||||
// and hides main content to prevent any flash
|
||||
_inTransition = true;
|
||||
// Mark transition state in setState to ensure rebuilds happen properly
|
||||
setState(() {
|
||||
_isSliding = true;
|
||||
// Mark transition BEFORE animation - this keeps peek content visible
|
||||
// and hides main content to prevent any flash
|
||||
_inTransition = true;
|
||||
});
|
||||
|
||||
final startOffset = _slideOffset;
|
||||
final targetOffset = direction < 0 ? -1.0 : 1.0;
|
||||
@@ -830,6 +925,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final collapsedBg = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.primaryContainer
|
||||
: colorScheme.primaryContainer;
|
||||
// Create a darker shade for the "unplayed" portion of progress bar
|
||||
final collapsedBgUnplayed = Color.lerp(collapsedBg, Colors.black, 0.3)!;
|
||||
final expandedBg = adaptiveScheme?.surface ?? const Color(0xFF121212);
|
||||
// Only update if we have adaptive colors, otherwise keep previous value
|
||||
if (adaptiveScheme != null) {
|
||||
@@ -837,7 +934,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
} else if (_currentExpandedBgColor == null) {
|
||||
_currentExpandedBgColor = expandedBg; // First time fallback
|
||||
}
|
||||
final backgroundColor = Color.lerp(collapsedBg, expandedBg, t)!;
|
||||
// When collapsed, use the darker unplayed color as base (progress bar will overlay the played portion)
|
||||
// When expanded, transition to the normal background
|
||||
final backgroundColor = Color.lerp(t < 0.5 ? collapsedBgUnplayed : collapsedBg, expandedBg, t)!;
|
||||
|
||||
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.onPrimaryContainer
|
||||
@@ -1008,9 +1107,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: GestureDetector(
|
||||
// Use translucent to allow child widgets (like buttons) to receive taps
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
if (!isExpanded) expand();
|
||||
},
|
||||
// Only handle tap when collapsed - when expanded, let children handle their own taps
|
||||
onTap: isExpanded ? null : expand,
|
||||
onVerticalDragUpdate: (details) {
|
||||
if (details.primaryDelta! < -10 && !isExpanded) {
|
||||
expand();
|
||||
@@ -1070,6 +1168,46 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Mini player progress bar background (collapsed only)
|
||||
// Shows played portion with brighter color, unplayed with darker
|
||||
// Starts after album art so progress is visible
|
||||
// Slides with content during swipe, hidden during transition if peek available
|
||||
if (t < 0.5 && currentTrack?.duration != null && !(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: _collapsedArtSize + miniPlayerSlideOffset,
|
||||
top: 0,
|
||||
width: width - _collapsedArtSize,
|
||||
bottom: 0,
|
||||
child: ValueListenableBuilder<int>(
|
||||
valueListenable: _progressNotifier,
|
||||
builder: (context, elapsedSeconds, _) {
|
||||
final totalSeconds = currentTrack!.duration!.inSeconds;
|
||||
if (totalSeconds <= 0) return const SizedBox.shrink();
|
||||
final progress = (elapsedSeconds / totalSeconds).clamp(0.0, 1.0);
|
||||
// Fade out as we expand
|
||||
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||
final progressAreaWidth = width - _collapsedArtSize;
|
||||
return Opacity(
|
||||
opacity: progressOpacity,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(borderRadius),
|
||||
bottomRight: Radius.circular(borderRadius),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: progressAreaWidth * progress,
|
||||
height: height,
|
||||
color: collapsedBg,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Peek player content (shows when dragging OR during transition)
|
||||
// Show when: actively dragging (slideOffset != 0) OR in transition state
|
||||
// Must have peek data to render
|
||||
@@ -1093,45 +1231,53 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Album art - with slide animation when collapsed
|
||||
// Hidden during transition to prevent flash (peek content shows instead)
|
||||
// FALLBACK: Show main content if transition is active but peek content unavailable
|
||||
// This prevents showing only the progress bar with no content
|
||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: artLeft + miniPlayerSlideOffset,
|
||||
top: artTop,
|
||||
child: Container(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||
boxShadow: t > 0.3
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
// Use RepaintBoundary to isolate art repaints during animation
|
||||
child: RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
// Use onTapUp instead of onTap - resolves immediately and wins
|
||||
// the gesture arena against the outer vertical drag detector
|
||||
onTapUp: t > 0.5 ? (_) => _showFullscreenArt(context, imageUrl) : null,
|
||||
child: Container(
|
||||
width: artSize,
|
||||
height: artSize,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Fixed cache size to avoid mid-animation cache thrashing
|
||||
memCacheWidth: 512,
|
||||
memCacheHeight: 512,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||
)
|
||||
: _buildPlaceholderArt(colorScheme, t),
|
||||
// GPU PERF: Fixed blur/offset, only animate shadow opacity
|
||||
boxShadow: t > 0.3
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
// Use RepaintBoundary to isolate art repaints during animation
|
||||
child: RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(artBorderRadius),
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// Fixed cache size to avoid mid-animation cache thrashing
|
||||
memCacheWidth: 512,
|
||||
memCacheHeight: 512,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
placeholder: (_, __) => _buildPlaceholderArt(colorScheme, t),
|
||||
errorWidget: (_, __, ___) => _buildPlaceholderArt(colorScheme, t),
|
||||
)
|
||||
: _buildPlaceholderArt(colorScheme, t),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1139,8 +1285,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Track title - with slide animation when collapsed
|
||||
// Hidden during transition to prevent flash
|
||||
// FALLBACK: Show if transition active but no peek content available
|
||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
top: titleTop,
|
||||
@@ -1162,17 +1309,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
),
|
||||
|
||||
// Artist name - with slide animation when collapsed
|
||||
// Artist/Author name - with slide animation when collapsed
|
||||
// For audiobooks: show author from audiobook context
|
||||
// Hidden during transition to prevent flash
|
||||
// FALLBACK: Show if transition active but no peek content available
|
||||
// GPU PERF: Use conditional instead of Opacity to avoid saveLayer
|
||||
if (!(_inTransition && t < 0.1))
|
||||
if (!(_inTransition && t < 0.1 && _peekPlayer != null))
|
||||
Positioned(
|
||||
left: titleLeft + miniPlayerSlideOffset,
|
||||
top: artistTop,
|
||||
child: SizedBox(
|
||||
width: titleWidth,
|
||||
child: Text(
|
||||
currentTrack.artistsString,
|
||||
maProvider.isPlayingAudiobook
|
||||
? (maProvider.currentAudiobook?.authorsString ?? 'Unknown Author')
|
||||
: currentTrack.artistsString,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(t > 0.5 ? 0.7 : 0.6),
|
||||
fontSize: artistFontSize,
|
||||
@@ -1185,23 +1336,62 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
),
|
||||
|
||||
// Album name (expanded only)
|
||||
// Album name OR Chapter name (expanded only)
|
||||
// GPU PERF: Use color alpha instead of Opacity widget
|
||||
if (currentTrack.album != null && t > 0.3)
|
||||
// For audiobooks: show current chapter; for music: show album
|
||||
if (t > 0.3 && (currentTrack.album != null || maProvider.isPlayingAudiobook))
|
||||
Positioned(
|
||||
left: contentPadding,
|
||||
right: contentPadding,
|
||||
top: _lerpDouble(artistTop + 24, expandedAlbumTop, t),
|
||||
child: Text(
|
||||
currentTrack.album!.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
child: maProvider.isPlayingAudiobook
|
||||
? _buildChapterInfo(maProvider, textColor, t)
|
||||
: Text(
|
||||
currentTrack.album!.name,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Audio source/format info (expanded only, when Sendspin active)
|
||||
// Shows "Playing from Sendspin" and audio format
|
||||
if (t > 0.5 && maProvider.isSendspinConnected)
|
||||
Positioned(
|
||||
left: contentPadding,
|
||||
right: contentPadding,
|
||||
top: expandedAlbumTop + (currentTrack.album != null ? 24 : 0),
|
||||
child: FadeTransition(
|
||||
opacity: _expandAnimation.drive(
|
||||
Tween(begin: 0.0, end: 1.0).chain(
|
||||
CurveTween(curve: const Interval(0.5, 1.0, curve: Curves.easeIn)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.graphic_eq_rounded,
|
||||
size: 14,
|
||||
color: primaryColor.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
maProvider.currentAudioFormat ?? 'PCM Audio',
|
||||
style: TextStyle(
|
||||
color: primaryColor.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1302,7 +1492,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
left: t > 0.5 ? 0 : null,
|
||||
right: t > 0.5 ? 0 : collapsedControlsRight - miniPlayerSlideOffset,
|
||||
top: controlsTop,
|
||||
child: Row(
|
||||
child: maProvider.isPlayingAudiobook
|
||||
? _buildAudiobookControls(
|
||||
maProvider: maProvider,
|
||||
selectedPlayer: selectedPlayer,
|
||||
textColor: textColor,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: backgroundColor,
|
||||
skipButtonSize: skipButtonSize,
|
||||
playButtonSize: playButtonSize,
|
||||
playButtonContainerSize: playButtonContainerSize,
|
||||
t: t,
|
||||
expandedElementsOpacity: expandedElementsOpacity,
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||
children: [
|
||||
@@ -1458,7 +1661,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
),
|
||||
),
|
||||
|
||||
// Queue panel (slides in from right)
|
||||
// Queue/Chapters panel (slides in from right)
|
||||
// For audiobooks: show chapters panel
|
||||
// For music: show queue panel
|
||||
if (t > 0.5 && queueT > 0)
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
@@ -1467,17 +1672,27 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(_queuePanelAnimation),
|
||||
child: QueuePanel(
|
||||
maProvider: maProvider,
|
||||
queue: _queue,
|
||||
isLoading: _isLoadingQueue,
|
||||
textColor: textColor,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: expandedBg,
|
||||
topPadding: topPadding,
|
||||
onClose: _toggleQueuePanel,
|
||||
onRefresh: _loadQueue,
|
||||
),
|
||||
child: maProvider.isPlayingAudiobook
|
||||
? ChaptersPanel(
|
||||
maProvider: maProvider,
|
||||
audiobook: maProvider.currentAudiobook,
|
||||
textColor: textColor,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: expandedBg,
|
||||
topPadding: topPadding,
|
||||
onClose: _toggleQueuePanel,
|
||||
)
|
||||
: QueuePanel(
|
||||
maProvider: maProvider,
|
||||
queue: _queue,
|
||||
isLoading: _isLoadingQueue,
|
||||
textColor: textColor,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: expandedBg,
|
||||
topPadding: topPadding,
|
||||
onClose: _toggleQueuePanel,
|
||||
onRefresh: _loadQueue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1642,6 +1857,56 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build peek player progress bar that slides in during swipe
|
||||
Widget _buildPeekProgressBar({
|
||||
required double slideOffset,
|
||||
required bool inTransition,
|
||||
required double collapsedWidth,
|
||||
required double progressAreaWidth,
|
||||
required double height,
|
||||
required double borderRadius,
|
||||
required Color color,
|
||||
}) {
|
||||
// Calculate peek progress bar position (mirrors _buildPeekContent positioning)
|
||||
double peekProgressLeft;
|
||||
if (inTransition && slideOffset.abs() < 0.01) {
|
||||
peekProgressLeft = _collapsedArtSize;
|
||||
} else {
|
||||
final isFromRight = slideOffset < 0;
|
||||
final peekProgress = slideOffset.abs();
|
||||
peekProgressLeft = _collapsedArtSize + (isFromRight
|
||||
? collapsedWidth * (1 - peekProgress)
|
||||
: -collapsedWidth * (1 - peekProgress));
|
||||
}
|
||||
|
||||
// Get peek player's elapsed time - safely handle null/invalid values
|
||||
final peekElapsed = _peekPlayer?.currentElapsed ?? 0.0;
|
||||
final peekTotal = _peekTrack?.duration?.inSeconds ?? 0;
|
||||
if (peekTotal <= 0) return const SizedBox.shrink();
|
||||
final peekProgressValue = (peekElapsed / peekTotal).clamp(0.0, 1.0);
|
||||
|
||||
return Positioned(
|
||||
left: peekProgressLeft,
|
||||
top: 0,
|
||||
width: progressAreaWidth,
|
||||
bottom: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(borderRadius),
|
||||
bottomRight: Radius.circular(borderRadius),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: progressAreaWidth * peekProgressValue,
|
||||
height: height,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
||||
return Container(
|
||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
||||
@@ -1653,6 +1918,121 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build chapter info display for audiobooks
|
||||
Widget _buildChapterInfo(MusicAssistantProvider maProvider, Color textColor, double t) {
|
||||
final chapter = maProvider.getCurrentChapter();
|
||||
final chapterIndex = maProvider.getCurrentChapterIndex();
|
||||
final totalChapters = maProvider.currentAudiobook?.chapters?.length ?? 0;
|
||||
|
||||
if (chapter == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final chapterText = totalChapters > 0
|
||||
? 'Chapter ${chapterIndex + 1}/$totalChapters: ${chapter.title}'
|
||||
: chapter.title;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bookmark_outline_rounded,
|
||||
size: 14,
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
chapterText,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.45 * ((t - 0.3) / 0.7).clamp(0.0, 1.0)),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build audiobook-specific playback controls
|
||||
/// Layout: [Prev Chapter] [-30s] [Play/Pause] [+30s] [Next Chapter]
|
||||
Widget _buildAudiobookControls({
|
||||
required MusicAssistantProvider maProvider,
|
||||
required dynamic selectedPlayer,
|
||||
required Color textColor,
|
||||
required Color primaryColor,
|
||||
required Color backgroundColor,
|
||||
required double skipButtonSize,
|
||||
required double playButtonSize,
|
||||
required double playButtonContainerSize,
|
||||
required double t,
|
||||
required double expandedElementsOpacity,
|
||||
}) {
|
||||
final hasChapters = maProvider.currentAudiobook?.chapters?.isNotEmpty ?? false;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: t > 0.5 ? MainAxisAlignment.center : MainAxisAlignment.end,
|
||||
children: [
|
||||
// Previous Chapter (expanded only, if chapters available)
|
||||
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
|
||||
_buildSecondaryButton(
|
||||
icon: Icons.skip_previous_rounded,
|
||||
color: textColor.withOpacity(expandedElementsOpacity),
|
||||
onPressed: () => maProvider.seekToPreviousChapter(selectedPlayer.playerId),
|
||||
),
|
||||
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
||||
|
||||
// Rewind 30 seconds
|
||||
_buildControlButton(
|
||||
icon: Icons.replay_30_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, -30),
|
||||
useAnimation: t > 0.5,
|
||||
),
|
||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
// Play/Pause
|
||||
_buildPlayButton(
|
||||
isPlaying: selectedPlayer.isPlaying,
|
||||
textColor: textColor,
|
||||
primaryColor: primaryColor,
|
||||
backgroundColor: backgroundColor,
|
||||
size: playButtonSize,
|
||||
containerSize: playButtonContainerSize,
|
||||
progress: t,
|
||||
onPressed: () => maProvider.playPauseSelectedPlayer(),
|
||||
onLongPress: () => maProvider.stopPlayer(selectedPlayer.playerId),
|
||||
),
|
||||
SizedBox(width: _lerpDouble(0, 20, t)),
|
||||
|
||||
// Forward 30 seconds
|
||||
_buildControlButton(
|
||||
icon: Icons.forward_30_rounded,
|
||||
color: textColor,
|
||||
size: skipButtonSize,
|
||||
onPressed: () => maProvider.seekRelative(selectedPlayer.playerId, 30),
|
||||
useAnimation: t > 0.5,
|
||||
),
|
||||
|
||||
// Next Chapter (expanded only, if chapters available)
|
||||
if (t > 0.5 && hasChapters) SizedBox(width: _lerpDouble(0, 12, t)),
|
||||
if (t > 0.5 && expandedElementsOpacity > 0.1 && hasChapters)
|
||||
_buildSecondaryButton(
|
||||
icon: Icons.skip_next_rounded,
|
||||
color: textColor.withOpacity(expandedElementsOpacity),
|
||||
onPressed: () => maProvider.seekToNextChapter(selectedPlayer.playerId),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlButton({
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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';
|
||||
|
||||
/// 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(
|
||||
'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()
|
||||
: _buildChaptersList(chapters, currentChapterIndex),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return EmptyState.custom(
|
||||
icon: Icons.bookmark_outline_rounded,
|
||||
title: 'No chapters',
|
||||
subtitle: 'This audiobook has no chapter information',
|
||||
);
|
||||
}
|
||||
|
||||
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,6 +1,7 @@
|
||||
/// Player widget components extracted from expandable_player.dart
|
||||
library player_widgets;
|
||||
|
||||
export 'chapters_panel.dart';
|
||||
export 'device_selector_bar.dart';
|
||||
export 'player_controls.dart';
|
||||
export 'queue_panel.dart';
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../providers/music_assistant_provider.dart';
|
||||
import '../services/debug_logger.dart';
|
||||
import '../screens/audiobook_series_screen.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 = {};
|
||||
|
||||
@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;
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_loadingCovers.remove(seriesId);
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
colorScheme: colorScheme,
|
||||
textTheme: textTheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
_logger.endBuild('SeriesRow:${widget.title}');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class _SeriesCard extends StatelessWidget {
|
||||
final AudiobookSeries series;
|
||||
final List<String>? covers;
|
||||
final ColorScheme colorScheme;
|
||||
final TextTheme textTheme;
|
||||
|
||||
const _SeriesCard({
|
||||
required this.series,
|
||||
required this.covers,
|
||||
required this.colorScheme,
|
||||
required this.textTheme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AudiobookSeriesScreen(series: series),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: _buildCoverGrid(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
series.name,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (series.bookCount != null)
|
||||
Text(
|
||||
'${series.bookCount} ${series.bookCount == 1 ? 'book' : 'books'}',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCoverGrid() {
|
||||
if (covers == null || covers!.isEmpty) {
|
||||
// Static placeholder - no animation
|
||||
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;
|
||||
}
|
||||
|
||||
final displayCovers = covers!.take(gridSize * gridSize).toList();
|
||||
|
||||
return GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: gridSize,
|
||||
childAspectRatio: 1,
|
||||
crossAxisSpacing: 1,
|
||||
mainAxisSpacing: 1,
|
||||
),
|
||||
itemCount: displayCovers.length,
|
||||
itemBuilder: (context, index) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: displayCovers[index],
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StretchScrollBehavior extends ScrollBehavior {
|
||||
const _StretchScrollBehavior();
|
||||
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return StretchingOverscrollIndicator(
|
||||
axisDirection: details.direction,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 1.9.2+13
|
||||
version: 2.1.1-beta+17
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -25,12 +25,16 @@ dependencies:
|
||||
|
||||
# Storage
|
||||
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
|
||||
uuid: ^4.2.1
|
||||
package_info_plus: ^8.0.0
|
||||
device_info_plus: ^10.1.0
|
||||
share_plus: ^9.0.0
|
||||
share_plus: ^10.1.4
|
||||
|
||||
# Audio - Local Playback
|
||||
just_audio: ^0.9.36
|
||||
@@ -38,11 +42,16 @@ dependencies:
|
||||
audio_session: ^0.1.18
|
||||
flutter_volume_controller: ^1.3.2
|
||||
|
||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||
flutter_pcm_sound: ^3.3.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.1
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
build_runner: ^2.4.13
|
||||
drift_dev: ^2.22.1
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
Reference in New Issue
Block a user