Compare commits
44
Commits
@@ -35,6 +35,16 @@ jobs:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -65,7 +75,12 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
run: |
|
||||
# Use init.gradle to force Maven mirrors (workaround for Maven Central 403)
|
||||
mkdir -p ~/.gradle
|
||||
cp android/init.gradle ~/.gradle/init.gradle
|
||||
|
||||
flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Player System Animation & Polish Audit
|
||||
|
||||
## Context
|
||||
|
||||
This is a Music Assistant client app (Ensemble) built with Flutter. The player system has undergone extensive development and now needs a comprehensive polish audit. The goal is buttery smooth 60fps animations and a premium user experience.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The player system consists of three interconnected components:
|
||||
|
||||
1. **Mini Player** (collapsed state) - Floating card at bottom, shows current track
|
||||
2. **Expanded Player** - Full-screen player with album art, controls, progress
|
||||
3. **Queue Panel** - Slides in from right of expanded player, shows playback queue
|
||||
|
||||
Additionally:
|
||||
- **Player Selector** - Horizontal swipe between multiple players (collapsed mode)
|
||||
- **Device Reveal** - Swipe down from mini player to show available players
|
||||
|
||||
## Key Files
|
||||
|
||||
```
|
||||
lib/widgets/
|
||||
├── expandable_player.dart # Main player widget (1800+ lines)
|
||||
├── global_player_overlay.dart # Overlay wrapper, static accessors
|
||||
├── player/
|
||||
│ ├── queue_panel.dart # Queue list with great_list_view
|
||||
│ ├── chapters_panel.dart # Audiobook chapters
|
||||
│ └── mini_player_content.dart
|
||||
```
|
||||
|
||||
## CRITICAL CONSTRAINTS
|
||||
|
||||
1. **DO NOT MODIFY the queue list implementation** - It uses `great_list_view` package with `AutomaticAnimatedListView`. This was chosen specifically to avoid Flutter's grey screen overlay bug (#103804). The current implementation works correctly.
|
||||
|
||||
2. **Preserve all existing functionality** - Focus on polish, not rewrites
|
||||
|
||||
3. **Test changes incrementally** - This system is complex with many interdependencies
|
||||
|
||||
## Audit Areas
|
||||
|
||||
### 1. Animation Performance Audit
|
||||
|
||||
Use sub-agents to analyze each animation system:
|
||||
|
||||
**Agent 1: Expand/Collapse Animation**
|
||||
- Analyze `_controller` and `_expandAnimation`
|
||||
- Check for jank during morphing transition
|
||||
- Verify lerp functions are efficient
|
||||
- Look for unnecessary rebuilds during animation
|
||||
- Check `RepaintBoundary` placement
|
||||
|
||||
**Agent 2: Queue Panel Animation**
|
||||
- Analyze `_queuePanelController` and `_queuePanelAnimation`
|
||||
- Verify slide-in/out is smooth
|
||||
- Check interaction with expanded player state
|
||||
- Verify the Listener-based swipe detection doesn't cause issues
|
||||
|
||||
**Agent 3: Player Selector Animation**
|
||||
- Analyze horizontal swipe between players
|
||||
- Check `_slideOffset` and peek player animations
|
||||
- Verify finger-following feels natural
|
||||
- Look for edge cases with rapid swipes
|
||||
|
||||
**Agent 4: Micro-interactions**
|
||||
- Play/pause button animations
|
||||
- Progress bar interactions
|
||||
- Volume swipe overlay
|
||||
- Favorite button feedback
|
||||
- Skip button responsiveness
|
||||
|
||||
### 2. State Management Audit
|
||||
|
||||
**Check for:**
|
||||
- Unnecessary `setState` calls during animations
|
||||
- ValueNotifier usage efficiency
|
||||
- Animation listener cleanup
|
||||
- Memory leaks from subscriptions
|
||||
- State synchronization between components
|
||||
|
||||
### 3. Visual Polish Audit
|
||||
|
||||
**Check for:**
|
||||
- Color transitions during expand/collapse
|
||||
- Text fade/scale animations
|
||||
- Image loading placeholders
|
||||
- Shadow/elevation consistency
|
||||
- Border radius morphing smoothness
|
||||
- Safe area handling
|
||||
|
||||
### 4. Gesture Conflict Resolution
|
||||
|
||||
**Verify:**
|
||||
- Vertical drag (expand/collapse) vs horizontal drag (player switch)
|
||||
- Queue panel swipe-right-to-close vs Dismissible swipe-left-to-delete
|
||||
- Edge dead zones for Android back gesture
|
||||
- Tap vs drag disambiguation
|
||||
|
||||
### 5. Edge Cases
|
||||
|
||||
**Test scenarios:**
|
||||
- Rapid expand/collapse
|
||||
- Swipe during animation
|
||||
- Back button during animations
|
||||
- Track change during queue scroll
|
||||
- Network image loading failures
|
||||
- Very long track/artist names
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
```
|
||||
ultrathink: true
|
||||
```
|
||||
|
||||
### Phase 1: Discovery (Read-Only)
|
||||
Launch parallel sub-agents to analyze each file:
|
||||
- Read and understand the animation architecture
|
||||
- Map all animation controllers and their relationships
|
||||
- Identify potential performance bottlenecks
|
||||
- Document current RepaintBoundary usage
|
||||
|
||||
### Phase 2: Analysis Report
|
||||
Compile findings into categories:
|
||||
- **Critical**: Causes visible jank or bugs
|
||||
- **Important**: Noticeable but not breaking
|
||||
- **Nice-to-have**: Minor polish improvements
|
||||
|
||||
### Phase 3: Implementation Plan
|
||||
For each finding:
|
||||
- Specific file and line numbers
|
||||
- Proposed change
|
||||
- Risk assessment
|
||||
- Testing approach
|
||||
|
||||
### Phase 4: Incremental Fixes
|
||||
- One fix at a time
|
||||
- Build and test after each change
|
||||
- Verify no regressions
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide a structured audit report:
|
||||
|
||||
```markdown
|
||||
## Animation Audit Report
|
||||
|
||||
### Executive Summary
|
||||
[2-3 sentences on overall state]
|
||||
|
||||
### Critical Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Important Issues
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Polish Opportunities
|
||||
1. [Issue]: [File:Line] - [Description] - [Proposed Fix]
|
||||
|
||||
### Architecture Observations
|
||||
[Notes on overall structure, patterns noticed]
|
||||
|
||||
### Recommended Priority Order
|
||||
1. [First fix]
|
||||
2. [Second fix]
|
||||
...
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
After this audit and fixes:
|
||||
- All animations run at consistent 60fps
|
||||
- No jank during any transition
|
||||
- Gestures feel responsive and natural
|
||||
- State transitions are seamless
|
||||
- No visual glitches or flickers
|
||||
- Memory usage is stable during interactions
|
||||
|
||||
## Notes
|
||||
|
||||
- The app uses adaptive theming (colors extracted from album art)
|
||||
- There's an `AnimationDebugger` utility for profiling
|
||||
- Position tracking uses a shared `PositionTracker` singleton
|
||||
- The player supports both music and audiobooks (different UI modes)
|
||||
@@ -2,6 +2,9 @@ buildscript {
|
||||
ext.kotlin_version = '1.9.10'
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -14,6 +17,9 @@ buildscript {
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
// Use Maven Central mirrors as fallback for 403 errors
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Force all repositories to use mirrors first
|
||||
allprojects {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add mirrors before Maven Central
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
|
||||
settingsEvaluated { settings ->
|
||||
settings.pluginManagement {
|
||||
repositories {
|
||||
all { ArtifactRepository repo ->
|
||||
if (repo instanceof MavenArtifactRepository) {
|
||||
def url = repo.url.toString()
|
||||
if (url.contains('repo.maven.apache.org') || url.contains('repo1.maven.org')) {
|
||||
remove repo
|
||||
}
|
||||
}
|
||||
}
|
||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,16 @@ class HeroTags {
|
||||
|
||||
/// Podcast cover hero tag prefix
|
||||
static const String podcastCover = 'podcast_cover_';
|
||||
|
||||
/// Playlist cover hero tag prefix
|
||||
static const String playlistCover = 'playlist_cover_';
|
||||
|
||||
/// Playlist title hero tag prefix
|
||||
static const String playlistTitle = 'playlist_title_';
|
||||
|
||||
/// Radio station cover hero tag prefix
|
||||
static const String radioCover = 'radio_cover_';
|
||||
|
||||
/// Radio station title hero tag prefix
|
||||
static const String radioTitle = 'radio_title_';
|
||||
}
|
||||
|
||||
@@ -251,6 +251,16 @@
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoritePlaylists": "Favorite Playlists",
|
||||
"@favoritePlaylists": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"favoriteRadioStations": "Favorite Radio Stations",
|
||||
"@favoriteRadioStations": {
|
||||
"description": "Home screen section title"
|
||||
},
|
||||
|
||||
"searchMusic": "Search music...",
|
||||
"@searchMusic": {
|
||||
"description": "Search placeholder text"
|
||||
@@ -1062,6 +1072,8 @@
|
||||
"showFavoriteAlbums": "Show a row of your favorite albums",
|
||||
"showFavoriteArtists": "Show a row of your favorite artists",
|
||||
"showFavoriteTracks": "Show a row of your favorite tracks",
|
||||
"showFavoritePlaylists": "Show a row of your favorite playlists",
|
||||
"showFavoriteRadioStations": "Show a row of your favorite radio stations",
|
||||
"extractColorsFromArtwork": "Extract colors from album and artist artwork",
|
||||
"chooseHomeScreenRows": "Choose which rows to display on the home screen",
|
||||
"addedToFavorites": "Added to favorites",
|
||||
|
||||
+20
-10
@@ -71,13 +71,16 @@ Future<void> main() async {
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
// Set system UI overlay style
|
||||
// Set initial system UI overlay style based on platform brightness
|
||||
// SystemUIWrapper will update this dynamically when theme changes
|
||||
final platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
final isDark = platformBrightness == Brightness.dark;
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFF1a1a1a),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark ? const Color(0xFF1a1a1a) : const Color(0xFFF5F5F5),
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -229,10 +232,17 @@ class _MusicAssistantAppState extends State<MusicAssistantApp> with WidgetsBindi
|
||||
ColorScheme? darkColorScheme;
|
||||
|
||||
if (themeProvider.useMaterialTheme && snapshot.hasData && snapshot.data != null) {
|
||||
// Use system color schemes
|
||||
// Use system color schemes, but override background to match app design
|
||||
// Material You doesn't set background consistently, causing black screen issues
|
||||
final (light, dark) = snapshot.data!;
|
||||
lightColorScheme = light;
|
||||
darkColorScheme = dark;
|
||||
lightColorScheme = light.copyWith(
|
||||
surface: light.surface,
|
||||
background: const Color(0xFFFAFAFA), // App's preferred light background
|
||||
);
|
||||
darkColorScheme = dark.copyWith(
|
||||
surface: const Color(0xFF2a2a2a), // App's preferred dark surface
|
||||
background: const Color(0xFF1a1a1a), // App's preferred dark background
|
||||
);
|
||||
} else {
|
||||
// Use custom color from theme provider
|
||||
lightColorScheme = generateLightColorScheme(themeProvider.customColor);
|
||||
@@ -332,8 +342,8 @@ class _SystemUIWrapperState extends State<SystemUIWrapper> {
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor: isDark
|
||||
? widget.darkColorScheme.background
|
||||
: widget.lightColorScheme.background,
|
||||
? widget.darkColorScheme.surface
|
||||
: widget.lightColorScheme.surface,
|
||||
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2416,17 +2416,22 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('📻 Found artist from artists array: $artistName');
|
||||
}
|
||||
|
||||
// Check for stream_title which might contain "Artist - Title" format
|
||||
// Check for stream_title which contains the actual now-playing metadata
|
||||
// For radio, stream_title has "Artist - Title" format from the stream's ICY metadata
|
||||
final streamTitle = currentMedia['stream_title'] as String?;
|
||||
if (streamTitle != null && streamTitle.contains(' - ') && artistName == null) {
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
// If trackTitle is still the default or matches the stream_title, update it too
|
||||
if (trackTitle == 'Unknown Track' || trackTitle == streamTitle) {
|
||||
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||
if (streamTitle.contains(' - ')) {
|
||||
// Parse "Artist - Title" format
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artistName = parts[0].trim();
|
||||
trackTitle = parts.sublist(1).join(' - ').trim();
|
||||
_logger.log('📻 Parsed from stream_title: artist=$artistName, title=$trackTitle');
|
||||
}
|
||||
_logger.log('📻 Parsed artist from stream_title: $artistName, title: $trackTitle');
|
||||
} else {
|
||||
// No separator, use stream_title as the title
|
||||
trackTitle = streamTitle;
|
||||
_logger.log('📻 Using stream_title as title: $trackTitle');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2453,14 +2458,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
|
||||
// Only cache if we don't already have better data from queue
|
||||
final existingTrack = _cacheService.getCachedTrackForPlayer(playerId);
|
||||
// Check if existing track has proper artist (not Unknown Artist and not empty)
|
||||
final existingArtist = existingTrack?.artistsString;
|
||||
final existingHasProperArtist = existingTrack != null &&
|
||||
existingTrack.artistsString != 'Unknown Artist' &&
|
||||
!existingTrack.name.contains(' - ');
|
||||
existingArtist != null &&
|
||||
existingArtist != 'Unknown Artist' &&
|
||||
existingArtist.trim().isNotEmpty;
|
||||
final existingHasImage = existingTrack?.metadata?['images'] != null;
|
||||
final newHasImage = metadata != null;
|
||||
final newHasAlbum = albumName != null;
|
||||
final existingHasAlbum = existingTrack?.album != null;
|
||||
final newHasProperArtist = artistName != 'Unknown Artist';
|
||||
// Check if new data has proper artist AND title (not malformed like "- Something")
|
||||
final newTitleIsMalformed = trackTitle.startsWith('- ') || trackTitle.trim().isEmpty;
|
||||
final newHasProperArtist = artistName != 'Unknown Artist' && !newTitleIsMalformed;
|
||||
|
||||
// For podcasts, album info is crucial (it's the podcast name)
|
||||
// If new track has album but existing doesn't, prefer new or merge
|
||||
@@ -2511,13 +2521,20 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// For selected player, _updatePlayerState() is already called above which fetches queue data
|
||||
// Only update _currentTrack here if we don't have it yet OR if we have better album data for podcasts
|
||||
// Update _currentTrack if:
|
||||
// - We don't have it yet
|
||||
// - Podcast with new album data
|
||||
// - Radio with new stream metadata (artist changed from Unknown)
|
||||
final currentHasUnknownArtist = _currentTrack?.artistsString == 'Unknown Artist' ||
|
||||
_currentTrack?.artistsString == null;
|
||||
final shouldUpdateCurrentTrack = _selectedPlayer != null &&
|
||||
playerId == _selectedPlayer!.playerId &&
|
||||
(_currentTrack == null || (isPodcastUri && newHasAlbum && _currentTrack?.album == null));
|
||||
(_currentTrack == null ||
|
||||
(isPodcastUri && newHasAlbum && _currentTrack?.album == null) ||
|
||||
(isRadioUri && newHasProperArtist && currentHasUnknownArtist));
|
||||
if (shouldUpdateCurrentTrack) {
|
||||
_currentTrack = _cacheService.getCachedTrackForPlayer(playerId) ?? trackFromEvent;
|
||||
_logger.log('📋 Updated _currentTrack for podcast with album: ${_currentTrack?.album?.name}');
|
||||
_logger.log('📋 Updated _currentTrack: ${_currentTrack?.name} by ${_currentTrack?.artistsString}');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
@@ -2554,15 +2571,19 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stream_title with "Artist - Title" format
|
||||
// Check for stream_title which contains the actual now-playing metadata
|
||||
final streamTitle = currentMedia['stream_title'] as String?;
|
||||
if (streamTitle != null && streamTitle.contains(' - ') && artist == null) {
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artist = parts[0].trim();
|
||||
if (title == 'Unknown Track' || title == streamTitle) {
|
||||
if (streamTitle != null && streamTitle.isNotEmpty) {
|
||||
if (streamTitle.contains(' - ')) {
|
||||
// Parse "Artist - Title" format
|
||||
final parts = streamTitle.split(' - ');
|
||||
if (parts.length >= 2) {
|
||||
artist = parts[0].trim();
|
||||
title = parts.sublist(1).join(' - ').trim();
|
||||
}
|
||||
} else {
|
||||
// No separator, use stream_title as the title
|
||||
title = streamTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2778,6 +2799,29 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
return _cachedFavoriteTracks;
|
||||
}
|
||||
|
||||
/// Get favorite playlists from the library
|
||||
Future<List<Playlist>> getFavoritePlaylists() async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
return await getPlaylists(favoriteOnly: true);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch favorite playlists: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get favorite radio stations from the library
|
||||
Future<List<MediaItem>> getFavoriteRadioStations() async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final stations = await _api!.getRadioStations(favoriteOnly: true);
|
||||
return filterByProvider(stations);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch favorite radio stations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIOBOOK HOME SCREEN ROWS
|
||||
// ============================================================================
|
||||
|
||||
@@ -68,6 +68,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If queue panel is open, close it first (before collapsing player)
|
||||
if (GlobalPlayerOverlay.isQueuePanelOpen) {
|
||||
GlobalPlayerOverlay.closeQueuePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
// If global player is expanded, collapse it first
|
||||
if (GlobalPlayerOverlay.isPlayerExpanded) {
|
||||
GlobalPlayerOverlay.collapsePlayer();
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../widgets/artist_row.dart';
|
||||
import '../widgets/track_row.dart';
|
||||
import '../widgets/audiobook_row.dart';
|
||||
import '../widgets/series_row.dart';
|
||||
import '../widgets/playlist_row.dart';
|
||||
import '../widgets/radio_station_row.dart';
|
||||
import '../widgets/common/disconnected_state.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -35,6 +37,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
// Audiobook rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -73,6 +77,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
final showContAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
final showDiscAudiobooks = await SettingsService.getShowDiscoverAudiobooks();
|
||||
final showDiscSeries = await SettingsService.getShowDiscoverSeries();
|
||||
@@ -85,6 +91,8 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showContinueListeningAudiobooks = showContAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -373,6 +381,24 @@ class _NewHomeScreenState extends State<NewHomeScreen> with AutomaticKeepAliveCl
|
||||
loadTracks: () => provider.getFavoriteTracks(),
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-playlists':
|
||||
if (!_showFavoritePlaylists) return null;
|
||||
return PlaylistRow(
|
||||
key: const ValueKey('favorite-playlists'),
|
||||
title: S.of(context)!.favoritePlaylists,
|
||||
loadPlaylists: () => provider.getFavoritePlaylists(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
case 'favorite-radio-stations':
|
||||
if (!_showFavoriteRadioStations) return null;
|
||||
return RadioStationRow(
|
||||
key: const ValueKey('favorite-radio-stations'),
|
||||
title: S.of(context)!.favoriteRadioStations,
|
||||
loadRadioStations: () => provider.getFavoriteRadioStations(),
|
||||
heroTagSuffix: 'home',
|
||||
rowHeight: rowHeight,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+17
-146
@@ -15,6 +15,7 @@ import '../widgets/artist_avatar.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
import '../utils/search/search_scoring.dart';
|
||||
import 'album_details_screen.dart';
|
||||
import 'artist_details_screen.dart';
|
||||
import 'playlist_details_screen.dart';
|
||||
@@ -128,6 +129,9 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
// PERF: Cache available filters to avoid recalculating during build
|
||||
List<String>? _cachedAvailableFilters;
|
||||
|
||||
// Advanced search scorer with fuzzy matching, stopword removal, etc.
|
||||
final SearchScorer _searchScorer = SearchScorer();
|
||||
|
||||
// Scroll-to-hide search bar (vertical scroll only)
|
||||
bool _isSearchBarVisible = true;
|
||||
double _lastVerticalScrollOffset = 0;
|
||||
@@ -750,151 +754,15 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for a media item based on query match
|
||||
/// Calculate relevance score for a media item based on query match.
|
||||
///
|
||||
/// Uses advanced scoring with:
|
||||
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||
/// - Fuzzy matching (typo tolerance via Jaro-Winkler)
|
||||
/// - N-gram matching (partial matches)
|
||||
/// - Reverse matching (query contains result name)
|
||||
double _calculateRelevanceScore(MediaItem item, String query) {
|
||||
final queryLower = query.toLowerCase().trim();
|
||||
if (queryLower.isEmpty) return 0;
|
||||
|
||||
double score = 0;
|
||||
final nameLower = item.name.toLowerCase();
|
||||
|
||||
// Primary name matching
|
||||
if (nameLower == queryLower) {
|
||||
score = 100; // Exact match
|
||||
} else if (nameLower.startsWith(queryLower)) {
|
||||
score = 80; // Starts with query
|
||||
} else if (_matchesWordBoundary(nameLower, queryLower)) {
|
||||
score = 60; // Contains query at word boundary
|
||||
} else if (nameLower.contains(queryLower)) {
|
||||
score = 40; // Contains query anywhere
|
||||
} else {
|
||||
score = 20; // Fuzzy/partial match (MA returned it, so some relevance)
|
||||
}
|
||||
|
||||
// Bonus for library items (Album has inLibrary property)
|
||||
if (item is Album && item.inLibrary) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Bonus for favorites
|
||||
if (item.favorite == true) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
// Secondary field matching (artist/author/creator name)
|
||||
if (item is Album) {
|
||||
final artistLower = item.artistsString.toLowerCase();
|
||||
if (artistLower == queryLower) {
|
||||
score += 15; // Artist exact match
|
||||
} else if (artistLower.contains(queryLower)) {
|
||||
score += 8; // Artist contains query
|
||||
}
|
||||
} else if (item is Track) {
|
||||
final artistLower = item.artistsString.toLowerCase();
|
||||
if (artistLower == queryLower) {
|
||||
score += 15;
|
||||
} else if (artistLower.contains(queryLower)) {
|
||||
score += 8;
|
||||
}
|
||||
// Also check album name for tracks
|
||||
if (item.album?.name != null) {
|
||||
final albumLower = item.album!.name.toLowerCase();
|
||||
if (albumLower.contains(queryLower)) {
|
||||
score += 5;
|
||||
}
|
||||
}
|
||||
} else if (item is Audiobook) {
|
||||
// Check audiobook author names
|
||||
final authorLower = item.authorsString.toLowerCase();
|
||||
if (authorLower == queryLower) {
|
||||
score += 15; // Author exact match
|
||||
} else if (authorLower.contains(queryLower)) {
|
||||
score += 8; // Author contains query
|
||||
}
|
||||
// Also check narrator names
|
||||
final narratorLower = item.narratorsString.toLowerCase();
|
||||
if (narratorLower.contains(queryLower)) {
|
||||
score += 5;
|
||||
}
|
||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||
// Check podcast metadata for author/creator
|
||||
final metadata = item.metadata;
|
||||
bool foundCreatorMatch = false;
|
||||
if (metadata != null) {
|
||||
final author = (metadata['author'] as String? ?? '').toLowerCase();
|
||||
final publisher = (metadata['publisher'] as String? ?? '').toLowerCase();
|
||||
final owner = (metadata['owner'] as String? ?? '').toLowerCase();
|
||||
final creator = (metadata['creator'] as String? ?? '').toLowerCase();
|
||||
|
||||
// Check all possible creator fields
|
||||
final creatorFields = [author, publisher, owner, creator].where((s) => s.isNotEmpty);
|
||||
bool foundExact = false;
|
||||
bool foundContains = false;
|
||||
for (final field in creatorFields) {
|
||||
if (field == queryLower) {
|
||||
foundExact = true;
|
||||
break;
|
||||
} else if (field.contains(queryLower)) {
|
||||
foundContains = true;
|
||||
}
|
||||
}
|
||||
if (foundExact) {
|
||||
score += 15; // Creator exact match
|
||||
foundCreatorMatch = true;
|
||||
} else if (foundContains) {
|
||||
score += 8; // Creator contains query
|
||||
foundCreatorMatch = true;
|
||||
}
|
||||
|
||||
// Also check description for keyword matches
|
||||
final description = (metadata['description'] as String? ?? '').toLowerCase();
|
||||
if (description.contains(queryLower)) {
|
||||
score += 5; // Increased from 3 to match other media types
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If no creator metadata matched, but query matches strongly in name,
|
||||
// give a boost. Podcast names often include the host's name (e.g., "The Louis Theroux Podcast")
|
||||
if (!foundCreatorMatch && nameLower.contains(queryLower)) {
|
||||
// Multi-word query that matches in name suggests creator/host name
|
||||
if (queryLower.contains(' ')) {
|
||||
// Calculate how prominent the query is in the name
|
||||
// e.g., "Louis Theroux Interviews" with query "Louis Theroux" = 14/23 = ~60%
|
||||
final prominence = queryLower.length / nameLower.length;
|
||||
if (prominence >= 0.5) {
|
||||
score += 15; // Query is >50% of name - very strong signal
|
||||
} else if (prominence >= 0.3) {
|
||||
score += 12; // Query is 30-50% of name - strong signal
|
||||
} else {
|
||||
score += 8; // Query in name but less prominent
|
||||
}
|
||||
} else {
|
||||
score += 5; // Single word match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Check if query matches at a word boundary in text
|
||||
/// Handles both single-word and multi-word queries
|
||||
bool _matchesWordBoundary(String text, String query) {
|
||||
// For multi-word queries, check if query appears at start of a word in text
|
||||
// e.g., "The Louis Theroux Podcast" contains "louis theroux" at word boundary
|
||||
if (query.contains(' ')) {
|
||||
// Check if text starts with query or contains query after whitespace
|
||||
if (text.startsWith(query)) return true;
|
||||
if (text.contains(' $query')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// For single-word queries, check individual words
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
for (final word in words) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return false;
|
||||
return _searchScorer.scoreItem(item, query);
|
||||
}
|
||||
|
||||
/// Extract artists from tracks/albums that match the query but aren't in direct results
|
||||
@@ -907,8 +775,10 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
) {
|
||||
if (query.isEmpty) return [];
|
||||
|
||||
final queryLower = query.toLowerCase();
|
||||
final queryWords = queryLower.split(RegExp(r'\s+'));
|
||||
// Use normalizer to get tokens with stopwords removed
|
||||
// This way "the Beatles" becomes ["beatles"] for better matching
|
||||
final normalizedQuery = _searchScorer.normalizer.normalizeQuery(query);
|
||||
final queryWords = normalizedQuery.tokensNoStop;
|
||||
|
||||
// Create a set of existing artist identifiers to avoid duplicates
|
||||
final existingArtistKeys = <String>{};
|
||||
@@ -942,6 +812,7 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
}
|
||||
|
||||
// Filter candidates: only include if artist name contains any query word
|
||||
// (stopwords already removed from queryWords)
|
||||
final crossRefArtists = <Artist>[];
|
||||
for (final artist in candidateArtists.values) {
|
||||
final artistLower = artist.name.toLowerCase();
|
||||
|
||||
@@ -29,6 +29,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showFavoriteAlbums = false;
|
||||
bool _showFavoriteArtists = false;
|
||||
bool _showFavoriteTracks = false;
|
||||
bool _showFavoritePlaylists = false;
|
||||
bool _showFavoriteRadioStations = false;
|
||||
// Audiobook home rows (default off)
|
||||
bool _showContinueListeningAudiobooks = false;
|
||||
bool _showDiscoverAudiobooks = false;
|
||||
@@ -41,6 +43,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Player settings
|
||||
bool _preferLocalPlayer = false;
|
||||
bool _smartSortPlayers = false;
|
||||
bool _volumePrecisionMode = true;
|
||||
// Hint settings
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
@@ -70,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final showFavAlbums = await SettingsService.getShowFavoriteAlbums();
|
||||
final showFavArtists = await SettingsService.getShowFavoriteArtists();
|
||||
final showFavTracks = await SettingsService.getShowFavoriteTracks();
|
||||
final showFavPlaylists = await SettingsService.getShowFavoritePlaylists();
|
||||
final showFavRadio = await SettingsService.getShowFavoriteRadioStations();
|
||||
|
||||
// Load audiobook home row settings
|
||||
final showContinueAudiobooks = await SettingsService.getShowContinueListeningAudiobooks();
|
||||
@@ -92,6 +97,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load player settings
|
||||
final preferLocal = await SettingsService.getPreferLocalPlayer();
|
||||
final smartSort = await SettingsService.getSmartSortPlayers();
|
||||
final volumePrecision = await SettingsService.getVolumePrecisionMode();
|
||||
|
||||
// Load hint settings
|
||||
final showHints = await SettingsService.getShowHints();
|
||||
@@ -107,6 +113,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteAlbums = showFavAlbums;
|
||||
_showFavoriteArtists = showFavArtists;
|
||||
_showFavoriteTracks = showFavTracks;
|
||||
_showFavoritePlaylists = showFavPlaylists;
|
||||
_showFavoriteRadioStations = showFavRadio;
|
||||
_showContinueListeningAudiobooks = showContinueAudiobooks;
|
||||
_showDiscoverAudiobooks = showDiscAudiobooks;
|
||||
_showDiscoverSeries = showDiscSeries;
|
||||
@@ -115,6 +123,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_libraryEnabled = libraryEnabled;
|
||||
_preferLocalPlayer = preferLocal;
|
||||
_smartSortPlayers = smartSort;
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
});
|
||||
@@ -150,6 +159,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return {'title': s.favoriteArtists, 'subtitle': s.showFavoriteArtists};
|
||||
case 'favorite-tracks':
|
||||
return {'title': s.favoriteTracks, 'subtitle': s.showFavoriteTracks};
|
||||
case 'favorite-playlists':
|
||||
return {'title': s.favoritePlaylists, 'subtitle': s.showFavoritePlaylists};
|
||||
case 'favorite-radio-stations':
|
||||
return {'title': s.favoriteRadioStations, 'subtitle': s.showFavoriteRadioStations};
|
||||
default:
|
||||
return {'title': rowId, 'subtitle': ''};
|
||||
}
|
||||
@@ -176,6 +189,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return _showFavoriteArtists;
|
||||
case 'favorite-tracks':
|
||||
return _showFavoriteTracks;
|
||||
case 'favorite-playlists':
|
||||
return _showFavoritePlaylists;
|
||||
case 'favorite-radio-stations':
|
||||
return _showFavoriteRadioStations;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -221,6 +238,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_showFavoriteTracks = value;
|
||||
SettingsService.setShowFavoriteTracks(value);
|
||||
break;
|
||||
case 'favorite-playlists':
|
||||
_showFavoritePlaylists = value;
|
||||
SettingsService.setShowFavoritePlaylists(value);
|
||||
break;
|
||||
case 'favorite-radio-stations':
|
||||
_showFavoriteRadioStations = value;
|
||||
SettingsService.setShowFavoriteRadioStations(value);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -287,13 +312,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Logo - same size as login screen (50% of screen width)
|
||||
// Use color filter to make logo dark in light theme
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 48.0),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: Theme.of(context).brightness == Brightness.light
|
||||
? ColorFiltered(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Color(0xFF1a1a1a), // Dark color for light theme
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
'assets/images/ensemble_icon_transparent.png',
|
||||
width: MediaQuery.of(context).size.width * 0.5,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
|
||||
// Connection status box - centered with border radius like theme boxes
|
||||
@@ -708,6 +746,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
'Volume precision mode',
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Hold still while adjusting volume for fine control',
|
||||
style: TextStyle(color: colorScheme.onSurface.withOpacity(0.6), fontSize: 12),
|
||||
),
|
||||
value: _volumePrecisionMode,
|
||||
onChanged: (value) {
|
||||
setState(() => _volumePrecisionMode = value);
|
||||
SettingsService.setVolumePrecisionMode(value);
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ class AuthManager {
|
||||
host.startsWith('172.31.') ||
|
||||
host == 'localhost' ||
|
||||
host.startsWith('127.') ||
|
||||
host.endsWith('.local');
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.ts.net');
|
||||
}
|
||||
|
||||
/// Try to detect auth strategy for a given URL
|
||||
|
||||
@@ -2453,6 +2453,39 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an item from the queue
|
||||
Future<void> queueCommandDeleteItem(String queueId, String itemId) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/delete_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'item_id_or_index': itemId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error deleting queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an item to a new position in the queue
|
||||
Future<void> queueCommandMoveItem(String queueId, String itemId, int newIndex) async {
|
||||
try {
|
||||
await _sendCommand(
|
||||
'player_queues/move_item',
|
||||
args: {
|
||||
'queue_id': queueId,
|
||||
'item_id_or_index': itemId,
|
||||
'new_index': newIndex,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.log('Error moving queue item: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BUILT-IN PLAYER MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
@@ -35,6 +35,8 @@ class SettingsService {
|
||||
static const String _keyShowFavoriteAlbums = 'show_favorite_albums';
|
||||
static const String _keyShowFavoriteArtists = 'show_favorite_artists';
|
||||
static const String _keyShowFavoriteTracks = 'show_favorite_tracks';
|
||||
static const String _keyShowFavoritePlaylists = 'show_favorite_playlists';
|
||||
static const String _keyShowFavoriteRadioStations = 'show_favorite_radio_stations';
|
||||
static const String _keyShowOnlyArtistsWithAlbums = 'show_only_artists_with_albums'; // Library artists filter
|
||||
static const String _keyHomeRowOrder = 'home_row_order'; // JSON list of row IDs
|
||||
|
||||
@@ -49,6 +51,8 @@ class SettingsService {
|
||||
'favorite-albums',
|
||||
'favorite-artists',
|
||||
'favorite-tracks',
|
||||
'favorite-playlists',
|
||||
'favorite-radio-stations',
|
||||
];
|
||||
|
||||
// View Mode Settings
|
||||
@@ -84,6 +88,9 @@ class SettingsService {
|
||||
static const String _keyHasUsedPlayerReveal = 'has_used_player_reveal'; // Track if user has pulled to reveal players
|
||||
static const String _keyHasCompletedOnboarding = 'has_completed_onboarding'; // Track if user has seen welcome screen
|
||||
|
||||
// Volume Precision Mode Settings
|
||||
static const String _keyVolumePrecisionMode = 'volume_precision_mode'; // Enable hold-to-precision volume control
|
||||
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
@@ -490,6 +497,26 @@ class SettingsService {
|
||||
await prefs.setBool(_keyShowFavoriteTracks, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoritePlaylists() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoritePlaylists) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoritePlaylists(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoritePlaylists, show);
|
||||
}
|
||||
|
||||
static Future<bool> getShowFavoriteRadioStations() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyShowFavoriteRadioStations) ?? false;
|
||||
}
|
||||
|
||||
static Future<void> setShowFavoriteRadioStations(bool show) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyShowFavoriteRadioStations, show);
|
||||
}
|
||||
|
||||
// Library Artists Filter - show only artists that have albums in library
|
||||
static Future<bool> getShowOnlyArtistsWithAlbums() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -824,6 +851,22 @@ class SettingsService {
|
||||
await prefs.setBool(_keyHasCompletedOnboarding, completed);
|
||||
}
|
||||
|
||||
// Volume Precision Mode Settings
|
||||
|
||||
/// Get whether volume precision mode is enabled (default: true)
|
||||
/// When enabled, holding still while adjusting volume enters precision mode
|
||||
/// for fine-grained control (10x more precise)
|
||||
static Future<bool> getVolumePrecisionMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_keyVolumePrecisionMode) ?? true;
|
||||
}
|
||||
|
||||
/// Set whether volume precision mode is enabled
|
||||
static Future<void> setVolumePrecisionMode(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyVolumePrecisionMode, enabled);
|
||||
}
|
||||
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
// Stored as JSON: {"podcastId": "itunesUrl", ...}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ class ThemeProvider extends ChangeNotifier {
|
||||
|
||||
/// Get the current adaptive surface color (for bottom nav background, etc.)
|
||||
/// Returns a subtle tinted surface based on the adaptive colors
|
||||
/// NOTE: Prefer getAdaptiveSurfaceColorFor() which respects light/dark mode
|
||||
Color? get adaptiveSurfaceColor {
|
||||
if (_adaptiveColors == null) return null;
|
||||
// Use the miniPlayer color but darkened for a subtle tinted background
|
||||
@@ -68,6 +69,20 @@ class ThemeProvider extends ChangeNotifier {
|
||||
return hsl.withLightness((hsl.lightness * 0.4).clamp(0.08, 0.15)).toColor();
|
||||
}
|
||||
|
||||
/// Get adaptive surface color for the given brightness (light/dark mode aware)
|
||||
/// Returns the same color as the expanded player background (scheme.surface)
|
||||
Color? getAdaptiveSurfaceColorFor(Brightness brightness) {
|
||||
// Pick scheme based on current mode
|
||||
final scheme = brightness == Brightness.dark
|
||||
? _adaptiveDarkScheme
|
||||
: _adaptiveLightScheme;
|
||||
|
||||
if (scheme == null) return null;
|
||||
|
||||
// Use surface - same as expanded player background (line 1234 in expandable_player.dart)
|
||||
return scheme.surface;
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final themeModeString = await SettingsService.getThemeMode();
|
||||
_themeMode = _parseThemeMode(themeModeString);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Fuzzy string matching using Jaro-Winkler similarity.
|
||||
///
|
||||
/// Jaro-Winkler is optimized for short strings like artist/song names
|
||||
/// and gives extra weight to matching prefixes (common typos occur mid-word).
|
||||
class FuzzyMatcher {
|
||||
/// Calculate Jaro-Winkler similarity between two strings.
|
||||
///
|
||||
/// Returns a value between 0.0 (no similarity) and 1.0 (exact match).
|
||||
///
|
||||
/// Jaro-Winkler gives bonus weight to strings that match from the beginning,
|
||||
/// which is useful for handling typos like "Beetles" vs "Beatles".
|
||||
double jaroWinklerSimilarity(String s1, String s2) {
|
||||
if (s1 == s2) return 1.0;
|
||||
if (s1.isEmpty || s2.isEmpty) return 0.0;
|
||||
|
||||
final jaro = _jaroSimilarity(s1, s2);
|
||||
|
||||
// Winkler modification: boost score for common prefix
|
||||
int prefixLength = 0;
|
||||
const maxPrefix = 4; // Standard Winkler prefix length
|
||||
final minLen = min(s1.length, s2.length);
|
||||
final prefixLimit = min(maxPrefix, minLen);
|
||||
|
||||
for (int i = 0; i < prefixLimit; i++) {
|
||||
if (s1[i] == s2[i]) {
|
||||
prefixLength++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const scalingFactor = 0.1; // Standard Winkler scaling factor
|
||||
return jaro + (prefixLength * scalingFactor * (1 - jaro));
|
||||
}
|
||||
|
||||
/// Calculate base Jaro similarity.
|
||||
double _jaroSimilarity(String s1, String s2) {
|
||||
final s1Len = s1.length;
|
||||
final s2Len = s2.length;
|
||||
|
||||
// Match window: characters within this distance can match
|
||||
final matchWindow = (max(s1Len, s2Len) / 2 - 1).floor();
|
||||
if (matchWindow < 0) return 0.0;
|
||||
|
||||
final s1Matches = List.filled(s1Len, false);
|
||||
final s2Matches = List.filled(s2Len, false);
|
||||
|
||||
int matches = 0;
|
||||
int transpositions = 0;
|
||||
|
||||
// Find matching characters
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
final start = max(0, i - matchWindow);
|
||||
final end = min(s2Len, i + matchWindow + 1);
|
||||
|
||||
for (int j = start; j < end; j++) {
|
||||
if (s2Matches[j] || s1[i] != s2[j]) continue;
|
||||
s1Matches[i] = true;
|
||||
s2Matches[j] = true;
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches == 0) return 0.0;
|
||||
|
||||
// Count transpositions
|
||||
int k = 0;
|
||||
for (int i = 0; i < s1Len; i++) {
|
||||
if (!s1Matches[i]) continue;
|
||||
while (!s2Matches[k]) {
|
||||
k++;
|
||||
}
|
||||
if (s1[i] != s2[k]) transpositions++;
|
||||
k++;
|
||||
}
|
||||
|
||||
// Jaro similarity formula
|
||||
return (matches / s1Len +
|
||||
matches / s2Len +
|
||||
(matches - transpositions / 2) / matches) /
|
||||
3;
|
||||
}
|
||||
|
||||
/// Find the best match score between query tokens and text tokens.
|
||||
///
|
||||
/// Useful for matching individual words when full string matching fails.
|
||||
/// Returns the highest similarity score found between any token pair.
|
||||
double bestTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double bestScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
// Early exit if we find a perfect match
|
||||
if (score == 1.0) return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore;
|
||||
}
|
||||
|
||||
/// Check if two strings are a fuzzy match within a threshold.
|
||||
bool isFuzzyMatch(String s1, String s2, {double threshold = 0.85}) {
|
||||
return jaroWinklerSimilarity(s1, s2) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate average token match score.
|
||||
///
|
||||
/// For each query token, finds the best matching text token,
|
||||
/// then averages all best matches. Useful for multi-word queries.
|
||||
double averageTokenMatch(List<String> queryTokens, List<String> textTokens) {
|
||||
if (queryTokens.isEmpty || textTokens.isEmpty) return 0.0;
|
||||
|
||||
double totalScore = 0.0;
|
||||
|
||||
for (final queryToken in queryTokens) {
|
||||
double bestForToken = 0.0;
|
||||
for (final textToken in textTokens) {
|
||||
final score = jaroWinklerSimilarity(queryToken, textToken);
|
||||
if (score > bestForToken) {
|
||||
bestForToken = score;
|
||||
}
|
||||
}
|
||||
totalScore += bestForToken;
|
||||
}
|
||||
|
||||
return totalScore / queryTokens.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/// N-gram based string matching for partial matches.
|
||||
///
|
||||
/// Uses character bigrams (2-grams) to find partial string matches
|
||||
/// when exact and fuzzy matching fail.
|
||||
class NgramMatcher {
|
||||
/// Generate character bigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["he", "el", "ll", "lo"]
|
||||
///
|
||||
/// For strings shorter than 2 characters, returns the string itself.
|
||||
List<String> generateBigrams(String text) {
|
||||
if (text.length < 2) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final bigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 1; i++) {
|
||||
bigrams.add(text.substring(i, i + 2));
|
||||
}
|
||||
return bigrams;
|
||||
}
|
||||
|
||||
/// Generate character trigrams from text.
|
||||
///
|
||||
/// Example: "hello" -> ["hel", "ell", "llo"]
|
||||
List<String> generateTrigrams(String text) {
|
||||
if (text.length < 3) return text.isNotEmpty ? [text] : [];
|
||||
|
||||
final trigrams = <String>[];
|
||||
for (int i = 0; i < text.length - 2; i++) {
|
||||
trigrams.add(text.substring(i, i + 3));
|
||||
}
|
||||
return trigrams;
|
||||
}
|
||||
|
||||
/// Calculate bigram similarity using Dice coefficient.
|
||||
///
|
||||
/// Dice coefficient: 2 * |intersection| / (|set1| + |set2|)
|
||||
/// Returns a value between 0.0 and 1.0.
|
||||
double bigramSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1);
|
||||
final bigrams2 = generateBigrams(lower2);
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final set1 = bigrams1.toSet();
|
||||
final set2 = bigrams2.toSet();
|
||||
|
||||
final intersection = set1.intersection(set2).length;
|
||||
|
||||
// Dice coefficient
|
||||
return (2 * intersection) / (set1.length + set2.length);
|
||||
}
|
||||
|
||||
/// Calculate Jaccard similarity between bigram sets.
|
||||
///
|
||||
/// Jaccard: |intersection| / |union|
|
||||
/// More conservative than Dice coefficient.
|
||||
double jaccardSimilarity(String s1, String s2) {
|
||||
final lower1 = s1.toLowerCase();
|
||||
final lower2 = s2.toLowerCase();
|
||||
|
||||
final bigrams1 = generateBigrams(lower1).toSet();
|
||||
final bigrams2 = generateBigrams(lower2).toSet();
|
||||
|
||||
if (bigrams1.isEmpty || bigrams2.isEmpty) return 0.0;
|
||||
|
||||
final intersection = bigrams1.intersection(bigrams2).length;
|
||||
final union = bigrams1.union(bigrams2).length;
|
||||
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
/// Check if query partially matches text via bigrams.
|
||||
///
|
||||
/// Uses Dice coefficient with configurable threshold.
|
||||
bool hasPartialMatch(String query, String text, {double threshold = 0.5}) {
|
||||
return bigramSimilarity(query, text) >= threshold;
|
||||
}
|
||||
|
||||
/// Calculate containment coefficient.
|
||||
///
|
||||
/// Measures how many of the query's bigrams are in the text.
|
||||
/// Useful when query is expected to be shorter than text.
|
||||
/// Returns |intersection| / |query bigrams|
|
||||
double containmentScore(String query, String text) {
|
||||
final queryBigrams = generateBigrams(query.toLowerCase()).toSet();
|
||||
final textBigrams = generateBigrams(text.toLowerCase()).toSet();
|
||||
|
||||
if (queryBigrams.isEmpty) return 0.0;
|
||||
|
||||
final intersection = queryBigrams.intersection(textBigrams).length;
|
||||
return intersection / queryBigrams.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/// Configuration for search scoring weights and thresholds.
|
||||
///
|
||||
/// All scores are designed to work with the existing sorting system
|
||||
/// where higher scores indicate better matches.
|
||||
class ScoringConfig {
|
||||
// Base scores for match types (primary name matching)
|
||||
final double exactMatch;
|
||||
final double exactMatchNoStopwords;
|
||||
final double startsWithMatch;
|
||||
final double startsWithNoStopwords;
|
||||
final double wordBoundaryMatch;
|
||||
final double wordBoundaryNoStopwords;
|
||||
final double reverseContainsMatch;
|
||||
final double reverseContainsNoStopwords;
|
||||
final double containsMatch;
|
||||
final double containsNoStopwords;
|
||||
final double fuzzyMatchHigh;
|
||||
final double fuzzyMatchMedium;
|
||||
final double ngramMatch;
|
||||
final double baseline;
|
||||
|
||||
// Bonuses (additive)
|
||||
final double libraryBonus;
|
||||
final double favoriteBonus;
|
||||
final double artistFieldExactBonus;
|
||||
final double artistFieldPartialBonus;
|
||||
final double albumFieldBonus;
|
||||
final double authorFieldExactBonus;
|
||||
final double authorFieldPartialBonus;
|
||||
final double narratorFieldBonus;
|
||||
final double creatorFieldExactBonus;
|
||||
final double creatorFieldPartialBonus;
|
||||
final double descriptionBonus;
|
||||
|
||||
// Thresholds
|
||||
final double fuzzyHighThreshold;
|
||||
final double fuzzyMediumThreshold;
|
||||
final double ngramThreshold;
|
||||
|
||||
// Minimum lengths for matching
|
||||
final int minReverseMatchLength;
|
||||
final int minTokenLength;
|
||||
|
||||
const ScoringConfig({
|
||||
// Primary match scores
|
||||
this.exactMatch = 100,
|
||||
this.exactMatchNoStopwords = 95,
|
||||
this.startsWithMatch = 85,
|
||||
this.startsWithNoStopwords = 80,
|
||||
this.wordBoundaryMatch = 70,
|
||||
this.wordBoundaryNoStopwords = 65,
|
||||
this.reverseContainsMatch = 60,
|
||||
this.reverseContainsNoStopwords = 55,
|
||||
this.containsMatch = 50,
|
||||
this.containsNoStopwords = 45,
|
||||
this.fuzzyMatchHigh = 40,
|
||||
this.fuzzyMatchMedium = 35,
|
||||
this.ngramMatch = 25,
|
||||
this.baseline = 20,
|
||||
|
||||
// Bonuses
|
||||
this.libraryBonus = 10,
|
||||
this.favoriteBonus = 5,
|
||||
this.artistFieldExactBonus = 15,
|
||||
this.artistFieldPartialBonus = 8,
|
||||
this.albumFieldBonus = 5,
|
||||
this.authorFieldExactBonus = 15,
|
||||
this.authorFieldPartialBonus = 8,
|
||||
this.narratorFieldBonus = 5,
|
||||
this.creatorFieldExactBonus = 15,
|
||||
this.creatorFieldPartialBonus = 8,
|
||||
this.descriptionBonus = 5,
|
||||
|
||||
// Thresholds
|
||||
this.fuzzyHighThreshold = 0.90,
|
||||
this.fuzzyMediumThreshold = 0.85,
|
||||
this.ngramThreshold = 0.5,
|
||||
|
||||
// Minimum lengths
|
||||
this.minReverseMatchLength = 3,
|
||||
this.minTokenLength = 3,
|
||||
});
|
||||
|
||||
/// Default configuration
|
||||
static const ScoringConfig defaults = ScoringConfig();
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/// Search scoring system for ranking search results.
|
||||
///
|
||||
/// Provides advanced text matching including:
|
||||
/// - Stopword removal ("the Ramones" finds "Ramones")
|
||||
/// - Fuzzy matching (typo tolerance)
|
||||
/// - N-gram matching (partial matches)
|
||||
/// - Reverse matching (query contains result)
|
||||
library;
|
||||
|
||||
export 'scoring_config.dart';
|
||||
export 'text_normalizer.dart';
|
||||
export 'fuzzy_matcher.dart';
|
||||
export 'ngram_matcher.dart';
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import '../../models/media_item.dart';
|
||||
import 'scoring_config.dart';
|
||||
import 'text_normalizer.dart';
|
||||
import 'fuzzy_matcher.dart';
|
||||
import 'ngram_matcher.dart';
|
||||
|
||||
/// Main search scorer that combines all matching strategies.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final scorer = SearchScorer();
|
||||
/// final score = scorer.scoreItem(mediaItem, query);
|
||||
/// ```
|
||||
class SearchScorer {
|
||||
final TextNormalizer _normalizer;
|
||||
final FuzzyMatcher _fuzzyMatcher;
|
||||
final NgramMatcher _ngramMatcher;
|
||||
final ScoringConfig _config;
|
||||
|
||||
// Cache for normalized query (reused across items in same search)
|
||||
String? _cachedQueryString;
|
||||
NormalizedQuery? _cachedQuery;
|
||||
|
||||
SearchScorer({ScoringConfig? config})
|
||||
: _normalizer = TextNormalizer(),
|
||||
_fuzzyMatcher = FuzzyMatcher(),
|
||||
_ngramMatcher = NgramMatcher(),
|
||||
_config = config ?? ScoringConfig.defaults;
|
||||
|
||||
/// Access to the normalizer for external use
|
||||
TextNormalizer get normalizer => _normalizer;
|
||||
|
||||
/// Clear the query cache (call when starting a new search)
|
||||
void clearCache() {
|
||||
_cachedQueryString = null;
|
||||
_cachedQuery = null;
|
||||
}
|
||||
|
||||
/// Score a media item against a search query.
|
||||
///
|
||||
/// Returns a score where higher values indicate better matches.
|
||||
/// Score components:
|
||||
/// - Primary name matching (0-100)
|
||||
/// - Secondary field bonuses (0-20)
|
||||
/// - Library/favorite bonuses (0-15)
|
||||
double scoreItem(MediaItem item, String query) {
|
||||
// Use cached normalized query if available
|
||||
if (_cachedQueryString != query) {
|
||||
_cachedQueryString = query;
|
||||
_cachedQuery = _normalizer.normalizeQuery(query);
|
||||
}
|
||||
|
||||
final nq = _cachedQuery!;
|
||||
if (nq.isEmpty) return 0;
|
||||
|
||||
final nameLower = item.name.toLowerCase();
|
||||
final nameNoStopwords = _normalizer.normalizeTextNoStopwords(item.name);
|
||||
|
||||
// Calculate primary score based on name matching
|
||||
double score = _calculatePrimaryScore(nameLower, nameNoStopwords, nq);
|
||||
|
||||
// Add secondary field bonuses based on media type
|
||||
score += _calculateSecondaryScore(item, nq);
|
||||
|
||||
// Add library/favorite bonuses
|
||||
score += _calculateBonuses(item);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// Calculate primary score based on name matching.
|
||||
double _calculatePrimaryScore(
|
||||
String nameLower,
|
||||
String nameNoStopwords,
|
||||
NormalizedQuery nq,
|
||||
) {
|
||||
// Tier 1: Exact match (highest priority)
|
||||
if (nameLower == nq.normalized) {
|
||||
return _config.exactMatch;
|
||||
}
|
||||
if (nameNoStopwords == nq.withoutStopwords) {
|
||||
return _config.exactMatchNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 2: Starts with
|
||||
if (nameLower.startsWith(nq.normalized)) {
|
||||
return _config.startsWithMatch;
|
||||
}
|
||||
if (nameNoStopwords.startsWith(nq.withoutStopwords)) {
|
||||
return _config.startsWithNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 3: Word boundary match
|
||||
if (_matchesWordBoundary(nameLower, nq.normalized)) {
|
||||
return _config.wordBoundaryMatch;
|
||||
}
|
||||
if (_matchesWordBoundary(nameNoStopwords, nq.withoutStopwords)) {
|
||||
return _config.wordBoundaryNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 4: Reverse contains (result name IN query)
|
||||
// This solves "the ramones" finding "Ramones"
|
||||
if (_reverseContains(nq.normalized, nameLower)) {
|
||||
return _config.reverseContainsMatch;
|
||||
}
|
||||
if (_reverseContains(nq.withoutStopwords, nameNoStopwords)) {
|
||||
return _config.reverseContainsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 5: Contains anywhere
|
||||
if (nameLower.contains(nq.normalized)) {
|
||||
return _config.containsMatch;
|
||||
}
|
||||
if (nameNoStopwords.contains(nq.withoutStopwords)) {
|
||||
return _config.containsNoStopwords;
|
||||
}
|
||||
|
||||
// Tier 6: Fuzzy matching (typo tolerance)
|
||||
final fuzzyScore = _fuzzyMatcher.jaroWinklerSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (fuzzyScore >= _config.fuzzyHighThreshold) {
|
||||
// Scale score based on similarity (0.90-1.0 -> 40-45)
|
||||
return _config.fuzzyMatchHigh +
|
||||
((fuzzyScore - _config.fuzzyHighThreshold) * 50);
|
||||
}
|
||||
if (fuzzyScore >= _config.fuzzyMediumThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 7: Token-level fuzzy (individual words)
|
||||
final tokenFuzzy = _fuzzyMatcher.bestTokenMatch(
|
||||
nq.tokensNoStop,
|
||||
_normalizer.tokenize(nameNoStopwords),
|
||||
);
|
||||
if (tokenFuzzy >= _config.fuzzyHighThreshold) {
|
||||
return _config.fuzzyMatchMedium;
|
||||
}
|
||||
|
||||
// Tier 8: N-gram partial matching
|
||||
final ngramScore = _ngramMatcher.bigramSimilarity(
|
||||
nq.withoutStopwords,
|
||||
nameNoStopwords,
|
||||
);
|
||||
if (ngramScore >= _config.ngramThreshold) {
|
||||
// Scale: 0.5-1.0 -> 25-35
|
||||
return _config.ngramMatch + (ngramScore * 10);
|
||||
}
|
||||
|
||||
// Baseline: Music Assistant API returned it, so some relevance
|
||||
return _config.baseline;
|
||||
}
|
||||
|
||||
/// Check if query matches at word boundary in text.
|
||||
bool _matchesWordBoundary(String text, String query) {
|
||||
if (query.contains(' ')) {
|
||||
// Multi-word query: check if at start or after space
|
||||
if (text.startsWith(query)) return true;
|
||||
if (text.contains(' $query')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Single-word query: check if any word starts with query
|
||||
final words = text.split(RegExp(r'\s+'));
|
||||
for (final word in words) {
|
||||
if (word.startsWith(query)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if text is contained within query (reverse matching).
|
||||
///
|
||||
/// Solves: "the ramones" query finding "Ramones" artist
|
||||
bool _reverseContains(String query, String text) {
|
||||
if (text.length < _config.minReverseMatchLength) return false;
|
||||
|
||||
// Direct containment
|
||||
if (query.contains(text)) return true;
|
||||
|
||||
// Token-level: any query token equals text
|
||||
final queryTokens = query.split(RegExp(r'\s+'));
|
||||
for (final token in queryTokens) {
|
||||
if (token.length >= _config.minReverseMatchLength && text == token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Calculate secondary field bonuses based on media type.
|
||||
double _calculateSecondaryScore(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
|
||||
if (item is Album) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
} else if (item is Track) {
|
||||
bonus += _scoreArtistField(item.artistsString, nq);
|
||||
// Also check album name
|
||||
if (item.album?.name != null) {
|
||||
final albumLower = item.album!.name.toLowerCase();
|
||||
if (albumLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.albumFieldBonus;
|
||||
}
|
||||
}
|
||||
} else if (item is Audiobook) {
|
||||
// Check authors
|
||||
final authorLower = item.authorsString.toLowerCase();
|
||||
if (authorLower == nq.withoutStopwords) {
|
||||
bonus += _config.authorFieldExactBonus;
|
||||
} else if (authorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.authorFieldPartialBonus;
|
||||
}
|
||||
// Check narrators
|
||||
final narratorLower = item.narratorsString.toLowerCase();
|
||||
if (narratorLower.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.narratorFieldBonus;
|
||||
}
|
||||
} else if (item.mediaType == MediaType.podcast || item.mediaType == MediaType.podcastEpisode) {
|
||||
bonus += _scorePodcastFields(item, nq);
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Score artist field for albums and tracks.
|
||||
double _scoreArtistField(String artistsString, NormalizedQuery nq) {
|
||||
final artistLower = artistsString.toLowerCase();
|
||||
if (artistLower == nq.withoutStopwords) {
|
||||
return _config.artistFieldExactBonus;
|
||||
}
|
||||
if (artistLower.contains(nq.withoutStopwords)) {
|
||||
return _config.artistFieldPartialBonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Score podcast-specific fields (creator, description).
|
||||
double _scorePodcastFields(MediaItem item, NormalizedQuery nq) {
|
||||
double bonus = 0;
|
||||
final metadata = item.metadata;
|
||||
|
||||
if (metadata != null) {
|
||||
// Check creator fields
|
||||
final creatorFields = [
|
||||
metadata['author'] as String?,
|
||||
metadata['publisher'] as String?,
|
||||
metadata['owner'] as String?,
|
||||
metadata['creator'] as String?,
|
||||
].where((s) => s != null && s.isNotEmpty).map((s) => s!.toLowerCase());
|
||||
|
||||
bool foundExact = false;
|
||||
bool foundContains = false;
|
||||
for (final field in creatorFields) {
|
||||
if (field == nq.withoutStopwords) {
|
||||
foundExact = true;
|
||||
break;
|
||||
} else if (field.contains(nq.withoutStopwords)) {
|
||||
foundContains = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundExact) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (foundContains) {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
|
||||
// Check description
|
||||
final description =
|
||||
(metadata['description'] as String? ?? '').toLowerCase();
|
||||
if (description.contains(nq.withoutStopwords)) {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: podcast name prominence check
|
||||
if (bonus == 0) {
|
||||
final nameLower = item.name.toLowerCase();
|
||||
if (nameLower.contains(nq.withoutStopwords)) {
|
||||
if (nq.withoutStopwords.contains(' ')) {
|
||||
// Multi-word query prominence
|
||||
final prominence = nq.withoutStopwords.length / nameLower.length;
|
||||
if (prominence >= 0.5) {
|
||||
bonus += _config.creatorFieldExactBonus;
|
||||
} else if (prominence >= 0.3) {
|
||||
bonus += _config.creatorFieldPartialBonus + 4;
|
||||
} else {
|
||||
bonus += _config.creatorFieldPartialBonus;
|
||||
}
|
||||
} else {
|
||||
bonus += _config.descriptionBonus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
/// Calculate library and favorite bonuses.
|
||||
double _calculateBonuses(MediaItem item) {
|
||||
double bonus = 0;
|
||||
|
||||
// Library bonus (Album has inLibrary property)
|
||||
if (item is Album && item.inLibrary) {
|
||||
bonus += _config.libraryBonus;
|
||||
}
|
||||
|
||||
// Favorite bonus
|
||||
if (item.favorite == true) {
|
||||
bonus += _config.favoriteBonus;
|
||||
}
|
||||
|
||||
return bonus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/// Text normalization utilities for search scoring.
|
||||
///
|
||||
/// Handles query normalization, stopword removal, and tokenization.
|
||||
|
||||
/// Represents a normalized search query with various forms for matching.
|
||||
class NormalizedQuery {
|
||||
/// Original query as entered by user
|
||||
final String original;
|
||||
|
||||
/// Lowercased and trimmed query
|
||||
final String normalized;
|
||||
|
||||
/// Query with stopwords removed
|
||||
final String withoutStopwords;
|
||||
|
||||
/// Individual tokens from normalized query
|
||||
final List<String> tokens;
|
||||
|
||||
/// Tokens with stopwords removed
|
||||
final List<String> tokensNoStop;
|
||||
|
||||
const NormalizedQuery({
|
||||
required this.original,
|
||||
required this.normalized,
|
||||
required this.withoutStopwords,
|
||||
required this.tokens,
|
||||
required this.tokensNoStop,
|
||||
});
|
||||
|
||||
/// Whether the query is empty after normalization
|
||||
bool get isEmpty => normalized.isEmpty;
|
||||
|
||||
/// Whether stopword removal changed the query
|
||||
bool get hasStopwordsRemoved => normalized != withoutStopwords;
|
||||
}
|
||||
|
||||
/// Normalizes text and queries for search matching.
|
||||
class TextNormalizer {
|
||||
/// Common English stopwords relevant to music/media searches.
|
||||
///
|
||||
/// Intentionally minimal to avoid over-filtering:
|
||||
/// - Articles that commonly prefix artist/album names
|
||||
/// - Common prepositions that add noise
|
||||
static const Set<String> _stopwords = {
|
||||
// Articles
|
||||
'the',
|
||||
'a',
|
||||
'an',
|
||||
// Common prepositions in music/media context
|
||||
'of',
|
||||
'and',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'with',
|
||||
'by',
|
||||
// Common noise words
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'be',
|
||||
};
|
||||
|
||||
/// Normalize a search query into multiple forms for matching.
|
||||
///
|
||||
/// Returns a [NormalizedQuery] containing:
|
||||
/// - Original query
|
||||
/// - Lowercased/trimmed version
|
||||
/// - Version with stopwords removed
|
||||
/// - Tokenized versions
|
||||
NormalizedQuery normalizeQuery(String query) {
|
||||
final normalized = query.toLowerCase().trim();
|
||||
final tokens = tokenize(normalized);
|
||||
final tokensNoStop = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
|
||||
// If all tokens were stopwords, preserve original tokens
|
||||
// e.g., "The The" (band name) shouldn't become empty
|
||||
final effectiveTokensNoStop =
|
||||
tokensNoStop.isEmpty ? tokens : tokensNoStop;
|
||||
final withoutStopwords = effectiveTokensNoStop.join(' ');
|
||||
|
||||
return NormalizedQuery(
|
||||
original: query,
|
||||
normalized: normalized,
|
||||
withoutStopwords: withoutStopwords,
|
||||
tokens: tokens,
|
||||
tokensNoStop: effectiveTokensNoStop,
|
||||
);
|
||||
}
|
||||
|
||||
/// Normalize text for comparison (lowercase, trim).
|
||||
String normalizeText(String text) {
|
||||
return text.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Normalize text with stopwords removed.
|
||||
String normalizeTextNoStopwords(String text) {
|
||||
final tokens = tokenize(text.toLowerCase().trim());
|
||||
final filtered = tokens.where((t) => !_stopwords.contains(t)).toList();
|
||||
return filtered.isEmpty ? text.toLowerCase().trim() : filtered.join(' ');
|
||||
}
|
||||
|
||||
/// Split text into tokens on whitespace.
|
||||
List<String> tokenize(String text) {
|
||||
return text.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Check if a word is a stopword.
|
||||
bool isStopword(String word) {
|
||||
return _stopwords.contains(word.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../services/animation_debugger.dart';
|
||||
import '../theme/palette_helper.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'animated_icon_button.dart';
|
||||
import 'global_player_overlay.dart';
|
||||
import 'volume_control.dart';
|
||||
@@ -76,6 +77,9 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final ValueNotifier<int> _progressNotifier = ValueNotifier<int>(0);
|
||||
final ValueNotifier<double?> _seekPositionNotifier = ValueNotifier<double?>(null);
|
||||
|
||||
// Pre-computed static colors to avoid object creation during animation frames
|
||||
static const Color _shadowColor = Color(0x4D000000); // Colors.black.withOpacity(0.3)
|
||||
|
||||
// Dimensions
|
||||
static double get _collapsedHeight => MiniPlayerLayout.height;
|
||||
static const double _collapsedMargin = 12.0; // Increased from 8 to 12 (4px more gap above nav bar)
|
||||
@@ -92,7 +96,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Slide animation for device switching - now supports finger-following
|
||||
late AnimationController _slideController;
|
||||
double _slideOffset = 0.0; // -1 to 1, negative = sliding left, positive = sliding right
|
||||
// ValueNotifier avoids setState during high-frequency drag updates
|
||||
final ValueNotifier<double> _slideOffsetNotifier = ValueNotifier(0.0);
|
||||
double get _slideOffset => _slideOffsetNotifier.value;
|
||||
set _slideOffset(double value) => _slideOffsetNotifier.value = value;
|
||||
bool _isSliding = false;
|
||||
bool _isDragging = false; // True while finger is actively dragging
|
||||
|
||||
@@ -108,16 +115,31 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
bool _isDraggingVolume = false;
|
||||
double _dragVolumeLevel = 0.0;
|
||||
int _lastVolumeUpdateTime = 0;
|
||||
int _lastVolumeDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||
|
||||
// Volume precision mode state
|
||||
bool _inVolumePrecisionMode = false;
|
||||
Timer? _volumePrecisionTimer;
|
||||
Offset? _lastVolumeDragPosition;
|
||||
double _lastVolumeLocalX = 0.0; // Last local X position during drag
|
||||
bool _volumePrecisionModeEnabled = true; // From settings
|
||||
double _volumePrecisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||
double _volumePrecisionStartX = 0.0; // Finger X position when precision mode started
|
||||
static const int _precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||
static const double _precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||
static const double _precisionSensitivity = 0.1; // Zoomed range (10% = left edge to right edge)
|
||||
|
||||
// Track favorite state for current track
|
||||
bool _isCurrentTrackFavorite = false;
|
||||
String? _lastTrackUri; // Track which track we last checked favorite status for
|
||||
|
||||
// Cached title height to avoid TextPainter.layout() every animation frame
|
||||
// Only invalidate when track name changes (screen width doesn't change during animation)
|
||||
double? _cachedExpandedTitleHeight;
|
||||
String? _lastMeasuredTrackName;
|
||||
double? _lastMeasuredTitleWidth;
|
||||
|
||||
// Gesture-driven expansion state
|
||||
bool _isVerticalDragging = false;
|
||||
@@ -176,6 +198,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_loadQueue();
|
||||
} else if (status == AnimationStatus.dismissed) {
|
||||
// Close queue panel when player collapses
|
||||
_queuePanelController.duration = _queueCloseDuration;
|
||||
_queuePanelController.reverse();
|
||||
}
|
||||
});
|
||||
@@ -191,6 +214,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_stopQueueRefreshTimer();
|
||||
}
|
||||
});
|
||||
|
||||
// Load precision mode setting
|
||||
_loadVolumePrecisionModeSetting();
|
||||
}
|
||||
|
||||
Future<void> _loadVolumePrecisionModeSetting() async {
|
||||
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
_volumePrecisionModeEnabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
void _enterVolumePrecisionMode() {
|
||||
if (_inVolumePrecisionMode) return;
|
||||
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||
setState(() {
|
||||
_inVolumePrecisionMode = true;
|
||||
_volumePrecisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||
_volumePrecisionStartX = _lastVolumeLocalX; // Capture finger position at entry
|
||||
});
|
||||
}
|
||||
|
||||
void _exitVolumePrecisionMode() {
|
||||
_volumePrecisionTimer?.cancel();
|
||||
_volumePrecisionTimer = null;
|
||||
if (!_inVolumePrecisionMode) return;
|
||||
setState(() {
|
||||
_inVolumePrecisionMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
Timer? _queueRefreshTimer;
|
||||
@@ -215,8 +267,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_controller.dispose();
|
||||
_queuePanelController.dispose();
|
||||
_slideController.dispose();
|
||||
_slideOffsetNotifier.dispose();
|
||||
_positionSubscription?.cancel();
|
||||
_queueRefreshTimer?.cancel();
|
||||
_volumePrecisionTimer?.cancel();
|
||||
_progressNotifier.dispose();
|
||||
_seekPositionNotifier.dispose();
|
||||
super.dispose();
|
||||
@@ -229,6 +283,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Animation durations - asymmetric for snappier collapse
|
||||
static const Duration _expandDuration = Duration(milliseconds: 280);
|
||||
static const Duration _collapseDuration = Duration(milliseconds: 200);
|
||||
static const Duration _queueOpenDuration = Duration(milliseconds: 250);
|
||||
static const Duration _queueCloseDuration = Duration(milliseconds: 180);
|
||||
|
||||
void expand() {
|
||||
if (_isVerticalDragging) return;
|
||||
@@ -241,9 +297,15 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
void collapse() {
|
||||
if (_isVerticalDragging) return;
|
||||
// If queue panel is open, close it first instead of collapsing player
|
||||
// This prevents both from closing on a single back press
|
||||
if (isQueuePanelOpen) {
|
||||
closeQueuePanel();
|
||||
return;
|
||||
}
|
||||
AnimationDebugger.startSession('playerCollapse');
|
||||
// Instantly hide queue panel when collapsing to avoid visual glitches
|
||||
// during Android's predictive back gesture
|
||||
// during Android's predictive back gesture (only reached if queue already closed)
|
||||
_queuePanelController.value = 0;
|
||||
_controller.duration = _collapseDuration;
|
||||
_controller.reverse().then((_) {
|
||||
@@ -514,14 +576,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
void _toggleQueuePanel() {
|
||||
if (_queuePanelController.isAnimating) return;
|
||||
if (_queuePanelController.value == 0) {
|
||||
_queuePanelController.duration = _queueOpenDuration;
|
||||
_queuePanelController.forward();
|
||||
} else {
|
||||
_queuePanelController.duration = _queueCloseDuration;
|
||||
_queuePanelController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
bool get isQueuePanelOpen => _queuePanelController.value > 0.5;
|
||||
|
||||
/// Close queue panel if open (for external access via GlobalPlayerOverlay)
|
||||
void closeQueuePanel() {
|
||||
if (isQueuePanelOpen && !_queuePanelController.isAnimating) {
|
||||
_queuePanelController.duration = _queueCloseDuration;
|
||||
_queuePanelController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update favorite status when track changes
|
||||
void _updateFavoriteStatus(dynamic currentTrack) {
|
||||
if (currentTrack == null) {
|
||||
@@ -771,32 +843,35 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final normalizedDelta = delta / containerWidth;
|
||||
final newSlideOffset = (_slideOffset + normalizedDelta).clamp(-1.0, 1.0);
|
||||
|
||||
// Update peek player based on drag direction (must be inside setState to trigger rebuild)
|
||||
setState(() {
|
||||
_slideOffset = newSlideOffset;
|
||||
if (_slideOffset != 0) {
|
||||
_updatePeekPlayerState(maProvider, _slideOffset);
|
||||
}
|
||||
});
|
||||
// Update slideOffset via ValueNotifier (no setState needed - avoids 60fps rebuilds)
|
||||
_slideOffset = newSlideOffset;
|
||||
|
||||
// Only trigger setState when peek player changes (not every frame)
|
||||
if (_slideOffset != 0) {
|
||||
_updatePeekPlayerState(maProvider, _slideOffset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update peek player state variables (called inside setState)
|
||||
/// Update peek player state variables - only calls setState when peek player changes
|
||||
void _updatePeekPlayerState(MusicAssistantProvider maProvider, double dragDirection) {
|
||||
// dragDirection < 0 means swiping left (next player)
|
||||
// dragDirection > 0 means swiping right (previous player)
|
||||
final isNext = dragDirection < 0;
|
||||
final newPeekPlayer = _getAdjacentPlayer(maProvider, next: isNext);
|
||||
|
||||
// Only setState when peek player actually changes (prevents unnecessary rebuilds)
|
||||
if (newPeekPlayer?.playerId != _peekPlayer?.playerId) {
|
||||
_peekPlayer = newPeekPlayer;
|
||||
// Get the peek player's current track image if available
|
||||
if (_peekPlayer != null) {
|
||||
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
||||
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
||||
} else {
|
||||
_peekTrack = null;
|
||||
_peekImageUrl = null;
|
||||
}
|
||||
setState(() {
|
||||
_peekPlayer = newPeekPlayer;
|
||||
// Get the peek player's current track image if available
|
||||
if (_peekPlayer != null) {
|
||||
_peekTrack = maProvider.getCachedTrackForPlayer(_peekPlayer.playerId);
|
||||
_peekImageUrl = _peekTrack != null ? maProvider.getImageUrl(_peekTrack, size: 512) : null;
|
||||
} else {
|
||||
_peekTrack = null;
|
||||
_peekImageUrl = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,9 +926,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
void animateToTarget() {
|
||||
if (!mounted) return;
|
||||
final curvedValue = Curves.easeOutCubic.transform(_slideController.value);
|
||||
setState(() {
|
||||
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
||||
});
|
||||
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
||||
_slideOffset = startOffset + (targetOffset - startOffset) * curvedValue;
|
||||
}
|
||||
|
||||
_slideController.addListener(animateToTarget);
|
||||
@@ -879,8 +953,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Move peek content to center position (slideOffset = 0) while keeping it visible.
|
||||
// The main content is hidden via _inTransition flag.
|
||||
// This creates a seamless visual - peek content is already at center.
|
||||
_slideOffset = 0.0; // ValueNotifier triggers AnimatedBuilder rebuild
|
||||
setState(() {
|
||||
_slideOffset = 0.0;
|
||||
_isSliding = false;
|
||||
// Keep _inTransition = true and peek data intact
|
||||
});
|
||||
@@ -974,19 +1048,20 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
void animateBack() {
|
||||
if (!mounted) return;
|
||||
final curvedValue = Curves.easeOutBack.transform(_slideController.value);
|
||||
setState(() {
|
||||
_slideOffset = startOffset * (1.0 - curvedValue);
|
||||
});
|
||||
// ValueNotifier triggers AnimatedBuilder rebuild - no setState needed
|
||||
_slideOffset = startOffset * (1.0 - curvedValue);
|
||||
}
|
||||
|
||||
_slideController.addListener(animateBack);
|
||||
_slideController.duration = const Duration(milliseconds: 300);
|
||||
_slideController.duration = const Duration(milliseconds: 220); // Snappier snap-back
|
||||
|
||||
_slideController.forward().then((_) {
|
||||
if (!mounted) return;
|
||||
_slideController.removeListener(animateBack);
|
||||
// Update slideOffset via ValueNotifier (triggers rebuild)
|
||||
_slideOffset = 0.0;
|
||||
// Other state changes still need setState
|
||||
setState(() {
|
||||
_slideOffset = 0.0;
|
||||
_isSliding = false;
|
||||
_peekPlayer = null;
|
||||
_peekImageUrl = null;
|
||||
@@ -1040,22 +1115,40 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
});
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation]),
|
||||
builder: (context, _) {
|
||||
// If no track is playing, show device selector bar
|
||||
if (currentTrack == null) {
|
||||
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
||||
// Handle Android back button - close queue panel first, then collapse player
|
||||
// Guard against re-entry during animations to prevent double-close
|
||||
return PopScope(
|
||||
canPop: !isQueuePanelOpen && !isExpanded,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
// Ignore if already animating (prevents double-processing)
|
||||
if (_queuePanelController.isAnimating || _controller.isAnimating) return;
|
||||
|
||||
if (isQueuePanelOpen) {
|
||||
_toggleQueuePanel();
|
||||
} else if (isExpanded) {
|
||||
collapse();
|
||||
}
|
||||
}
|
||||
return _buildMorphingPlayer(
|
||||
context,
|
||||
maProvider,
|
||||
selectedPlayer,
|
||||
currentTrack,
|
||||
imageUrl,
|
||||
themeProvider,
|
||||
);
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
// Include _slideOffsetNotifier to rebuild during finger-following drags
|
||||
animation: Listenable.merge([_expandAnimation, _queuePanelAnimation, _slideOffsetNotifier]),
|
||||
builder: (context, _) {
|
||||
// If no track is playing, show device selector bar
|
||||
if (currentTrack == null) {
|
||||
return _buildDeviceSelectorBar(context, maProvider, selectedPlayer, themeProvider);
|
||||
}
|
||||
return _buildMorphingPlayer(
|
||||
context,
|
||||
maProvider,
|
||||
selectedPlayer,
|
||||
currentTrack,
|
||||
imageUrl,
|
||||
themeProvider,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1195,10 +1288,12 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
final collapsedTextColor = themeProvider.adaptiveTheme && adaptiveScheme != null
|
||||
? adaptiveScheme.onPrimaryContainer
|
||||
: colorScheme.onPrimaryContainer;
|
||||
final expandedTextColor = adaptiveScheme?.onSurface ?? Colors.white;
|
||||
// Use colorScheme.onSurface as fallback instead of Colors.white for light theme support
|
||||
final expandedTextColor = adaptiveScheme?.onSurface ?? colorScheme.onSurface;
|
||||
final textColor = Color.lerp(collapsedTextColor, expandedTextColor, t)!;
|
||||
|
||||
final primaryColor = adaptiveScheme?.primary ?? Colors.white;
|
||||
// Use colorScheme.primary as fallback instead of Colors.white for light theme support
|
||||
final primaryColor = adaptiveScheme?.primary ?? colorScheme.primary;
|
||||
|
||||
// Always position above bottom nav bar
|
||||
// Overlap by 2px when expanded to eliminate any subpixel rendering gaps
|
||||
@@ -1276,9 +1371,8 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
);
|
||||
// Only recalculate if track name or width changed
|
||||
// Only recalculate when track changes (screen width doesn't change during animation)
|
||||
if (_lastMeasuredTrackName != currentTrack.name ||
|
||||
_lastMeasuredTitleWidth != expandedTitleWidth ||
|
||||
_cachedExpandedTitleHeight == null) {
|
||||
final titlePainter = TextPainter(
|
||||
text: TextSpan(text: currentTrack.name, style: titleStyle),
|
||||
@@ -1287,7 +1381,6 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
)..layout(maxWidth: expandedTitleWidth);
|
||||
_cachedExpandedTitleHeight = titlePainter.height;
|
||||
_lastMeasuredTrackName = currentTrack.name;
|
||||
_lastMeasuredTitleWidth = expandedTitleWidth;
|
||||
}
|
||||
final expandedTitleHeight = _cachedExpandedTitleHeight!;
|
||||
|
||||
@@ -1411,26 +1504,76 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Finish gesture-driven expansion
|
||||
_handleVerticalDragEnd(details);
|
||||
},
|
||||
onVerticalDragCancel: () {
|
||||
// Reset state if gesture is cancelled (e.g., system takes over)
|
||||
_isVerticalDragging = false;
|
||||
},
|
||||
onHorizontalDragStart: (details) {
|
||||
_horizontalDragStartX = details.globalPosition.dx;
|
||||
// Start volume drag if device reveal is visible
|
||||
if (widget.isDeviceRevealVisible && !isExpanded) {
|
||||
final currentVolume = (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final timeSinceLastDrag = now - _lastVolumeDragEndTime;
|
||||
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||
|
||||
// Use local volume if we have an override AND within window
|
||||
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||
|
||||
final startVolume = useLocalVolume
|
||||
? _dragVolumeLevel // Continue from where last swipe ended
|
||||
: (selectedPlayer.volumeLevel ?? 0).toDouble() / 100.0; // Fresh from player
|
||||
|
||||
setState(() {
|
||||
_isDraggingVolume = true;
|
||||
_dragVolumeLevel = currentVolume;
|
||||
_dragVolumeLevel = startVolume;
|
||||
});
|
||||
_lastVolumeDragPosition = details.globalPosition;
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
// Volume swipe when device reveal is visible
|
||||
if (widget.isDeviceRevealVisible && _isDraggingVolume && !isExpanded) {
|
||||
// Directional volume: drag right = increase, drag left = decrease
|
||||
// Multiple swipes accumulate - each starts from current volume
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / collapsedWidth;
|
||||
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||
final currentPosition = details.globalPosition;
|
||||
if (_volumePrecisionModeEnabled && _lastVolumeDragPosition != null) {
|
||||
final movement = (currentPosition - _lastVolumeDragPosition!).distance;
|
||||
|
||||
if (movement < _precisionStillnessThreshold) {
|
||||
// Finger is still - start precision timer if not already running
|
||||
if (_volumePrecisionTimer == null && !_inVolumePrecisionMode) {
|
||||
_volumePrecisionTimer = Timer(
|
||||
Duration(milliseconds: _precisionTriggerMs),
|
||||
_enterVolumePrecisionMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||
_volumePrecisionTimer?.cancel();
|
||||
_volumePrecisionTimer = null;
|
||||
}
|
||||
}
|
||||
_lastVolumeDragPosition = currentPosition;
|
||||
_lastVolumeLocalX = details.localPosition.dx;
|
||||
|
||||
double newVolume;
|
||||
|
||||
if (_inVolumePrecisionMode) {
|
||||
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||
// Full width of movement = precisionSensitivity (10%) change
|
||||
// e.g., at 40% center: moving full width right = 50%, full width left = 30%
|
||||
final offsetX = details.localPosition.dx - _volumePrecisionStartX;
|
||||
final normalizedOffset = offsetX / collapsedWidth; // -1.0 to +1.0 range
|
||||
final volumeChange = normalizedOffset * _precisionSensitivity;
|
||||
newVolume = (_volumePrecisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||
} else {
|
||||
// NORMAL MODE: Delta-based movement (full width = 100%)
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / collapsedWidth;
|
||||
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||
setState(() {
|
||||
_dragVolumeLevel = newVolume;
|
||||
@@ -1462,6 +1605,10 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
if (_isDraggingVolume) {
|
||||
// Send final volume on release
|
||||
maProvider.setVolume(selectedPlayer.playerId, (_dragVolumeLevel * 100).round());
|
||||
_lastVolumeDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||
_exitVolumePrecisionMode();
|
||||
_lastVolumeDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
@@ -1493,6 +1640,24 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
_handleHorizontalDragEnd(details, maProvider);
|
||||
}
|
||||
},
|
||||
onHorizontalDragCancel: () {
|
||||
// Reset state if gesture is cancelled (e.g., system takes over)
|
||||
_horizontalDragStartX = null;
|
||||
if (_isDraggingVolume) {
|
||||
_exitVolumePrecisionMode();
|
||||
_lastVolumeDragPosition = null;
|
||||
setState(() => _isDraggingVolume = false);
|
||||
}
|
||||
if (_isDragging) {
|
||||
_isDragging = false;
|
||||
_slideOffset = 0.0;
|
||||
setState(() {
|
||||
_peekPlayer = null;
|
||||
_peekImageUrl = null;
|
||||
_peekTrack = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
// Use foregroundDecoration for border so it renders ON TOP of content
|
||||
// This prevents the album art from clipping the yellow synced border
|
||||
@@ -1506,7 +1671,7 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
elevation: _lerpDouble(4, 0, t),
|
||||
shadowColor: Colors.black.withOpacity(0.3),
|
||||
shadowColor: _shadowColor,
|
||||
// Use hardEdge when fully expanded (no border radius) to avoid anti-alias artifacts
|
||||
// Use antiAlias when collapsed/animating to smooth rounded corners
|
||||
clipBehavior: borderRadius > 0.5 ? Clip.antiAlias : Clip.hardEdge,
|
||||
@@ -1537,18 +1702,21 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Fade out as we expand - use color alpha instead of Opacity widget
|
||||
final progressOpacity = (1.0 - (t / 0.5)).clamp(0.0, 1.0);
|
||||
final progressAreaWidth = width - _collapsedArtSize;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(borderRadius),
|
||||
bottomRight: Radius.circular(borderRadius),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: progressAreaWidth * progress,
|
||||
height: height,
|
||||
color: collapsedBg.withOpacity(progressOpacity),
|
||||
// RepaintBoundary isolates frequent progress updates from parent animation
|
||||
return RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(borderRadius),
|
||||
bottomRight: Radius.circular(borderRadius),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: progressAreaWidth * progress,
|
||||
height: height,
|
||||
color: collapsedBg.withOpacity(progressOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2118,19 +2286,30 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
|
||||
// Favorite button (expanded only) - hide when queue panel is open
|
||||
// GPU PERF: Use icon color alpha instead of Opacity
|
||||
// Scale pop animation when toggling favorite
|
||||
if (t > 0.3 && queueT < 0.5)
|
||||
Positioned(
|
||||
top: topPadding + 4,
|
||||
right: 52,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
|
||||
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
||||
size: 24,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
key: ValueKey(_isCurrentTrackFavorite),
|
||||
tween: Tween(begin: 1.3, end: 1.0),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutBack,
|
||||
builder: (context, scale, child) => Transform.scale(
|
||||
scale: scale,
|
||||
child: child,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isCurrentTrackFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: (_isCurrentTrackFavorite ? Colors.red : textColor)
|
||||
.withOpacity(((t - 0.3) / 0.7).clamp(0.0, 1.0) * (1 - queueT * 2).clamp(0.0, 1.0)),
|
||||
size: 24,
|
||||
),
|
||||
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
onPressed: () => _toggleCurrentTrackFavorite(currentTrack),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2213,24 +2392,32 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
// Volume swipe overlay (covers entire mini player when dragging volume)
|
||||
// Only visible when device reveal is open and user is dragging
|
||||
// Positioned last in Stack so it renders ON TOP of all content
|
||||
if (_isDraggingVolume && t < 0.5)
|
||||
// Uses AnimatedOpacity for smooth fade in/out transition
|
||||
if (t < 0.5)
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Unfilled (darker) background
|
||||
Container(color: collapsedBgUnplayed),
|
||||
// Filled (lighter) portion based on volume
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||
heightFactor: 1.0,
|
||||
child: Container(color: collapsedBg),
|
||||
),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isDraggingVolume,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isDraggingVolume ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Unfilled (darker) background
|
||||
Container(color: collapsedBgUnplayed),
|
||||
// Filled (lighter) portion based on volume
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _dragVolumeLevel.clamp(0.0, 1.0),
|
||||
heightFactor: 1.0,
|
||||
child: Container(color: collapsedBg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2343,11 +2530,14 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderArt(ColorScheme colorScheme, double t) {
|
||||
// Use theme-aware colors for both collapsed and expanded states
|
||||
final expandedBgColor = colorScheme.surfaceContainerHighest;
|
||||
final expandedIconColor = colorScheme.onSurface.withOpacity(0.24);
|
||||
return Container(
|
||||
color: Color.lerp(colorScheme.surfaceVariant, const Color(0xFF2a2a2a), t),
|
||||
color: Color.lerp(colorScheme.surfaceVariant, expandedBgColor, t),
|
||||
child: Icon(
|
||||
Icons.music_note_rounded,
|
||||
color: Color.lerp(colorScheme.onSurfaceVariant, Colors.white24, t),
|
||||
color: Color.lerp(colorScheme.onSurfaceVariant, expandedIconColor, t),
|
||||
size: _lerpDouble(24, 120, t),
|
||||
),
|
||||
);
|
||||
@@ -2527,12 +2717,11 @@ class ExpandablePlayerState extends State<ExpandablePlayer>
|
||||
return SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
icon: Icon(icon),
|
||||
child: AnimatedIconButton(
|
||||
icon: icon,
|
||||
color: color,
|
||||
iconSize: 22,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,15 @@ class GlobalPlayerOverlay extends StatefulWidget {
|
||||
static bool get isPlayerExpanded =>
|
||||
globalPlayerKey.currentState?.isExpanded ?? false;
|
||||
|
||||
/// Check if the queue panel is currently open
|
||||
static bool get isQueuePanelOpen =>
|
||||
globalPlayerKey.currentState?.isQueuePanelOpen ?? false;
|
||||
|
||||
/// Close the queue panel if open
|
||||
static void closeQueuePanel() {
|
||||
globalPlayerKey.currentState?.closeQueuePanel();
|
||||
}
|
||||
|
||||
/// Get the current expansion progress (0.0 to 1.0)
|
||||
static double get expansionProgress =>
|
||||
globalPlayerKey.currentState?.expansionProgress ?? 0.0;
|
||||
@@ -483,8 +492,10 @@ class _GlobalPlayerOverlayState extends State<GlobalPlayerOverlay>
|
||||
final navSelectedColor = _cachedNavColor.getAdjustedColor(sourceColor, isDark);
|
||||
|
||||
// Base background: use adaptive surface color if available, otherwise default surface
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && themeProvider.adaptiveSurfaceColor != null)
|
||||
? themeProvider.adaptiveSurfaceColor!
|
||||
// Use brightness-aware method to respect light/dark mode
|
||||
final adaptiveBg = themeProvider.getAdaptiveSurfaceColorFor(Theme.of(context).brightness);
|
||||
final baseBgColor = (themeProvider.adaptiveTheme && adaptiveBg != null)
|
||||
? adaptiveBg
|
||||
: colorScheme.surface;
|
||||
|
||||
return ValueListenableBuilder<PlayerExpansionState>(
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../services/debug_logger.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
|
||||
final _volumeLogger = DebugLogger();
|
||||
|
||||
/// A player card that matches the mini player's visual style.
|
||||
/// Used in the player reveal overlay when swiping down or tapping the device button.
|
||||
@@ -23,10 +28,16 @@ class PlayerCard extends StatefulWidget {
|
||||
final VoidCallback? onSkipNext;
|
||||
final VoidCallback? onPower;
|
||||
final ValueChanged<double>? onVolumeChange;
|
||||
final ValueChanged<bool>? onPrecisionModeChanged;
|
||||
|
||||
// Pastel yellow for grouped players
|
||||
static const Color groupBorderColor = Color(0xFFFFF59D);
|
||||
|
||||
// Precision mode settings
|
||||
static const int precisionTriggerMs = 800; // Hold still for 800ms to enter precision mode
|
||||
static const double precisionStillnessThreshold = 5.0; // Max pixels of movement considered "still"
|
||||
static const double precisionSensitivity = 0.1; // 10x more precise (full swipe = 10% change)
|
||||
|
||||
const PlayerCard({
|
||||
super.key,
|
||||
required this.player,
|
||||
@@ -43,6 +54,7 @@ class PlayerCard extends StatefulWidget {
|
||||
this.onSkipNext,
|
||||
this.onPower,
|
||||
this.onVolumeChange,
|
||||
this.onPrecisionModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,7 +66,69 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
double _dragVolumeLevel = 0.0;
|
||||
double _cardWidth = 0.0;
|
||||
int _lastVolumeUpdateTime = 0;
|
||||
int _lastDragEndTime = 0; // Track when last drag ended for consecutive swipes
|
||||
bool _hasLocalVolumeOverride = false; // True if we've set volume locally
|
||||
static const int _volumeThrottleMs = 150; // Only send volume updates every 150ms
|
||||
static const int _consecutiveSwipeWindowMs = 5000; // 5 seconds - extended window for consecutive swipes
|
||||
|
||||
// Precision mode state
|
||||
bool _inPrecisionMode = false;
|
||||
Timer? _precisionTimer;
|
||||
Offset? _lastDragPosition;
|
||||
double _lastLocalX = 0.0; // Last local X position during drag (for precision mode)
|
||||
bool _precisionModeEnabled = true; // From settings
|
||||
double _precisionZoomCenter = 0.0; // Volume level when precision mode started
|
||||
double _precisionStartX = 0.0; // Finger X position when precision mode started
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPrecisionModeSetting();
|
||||
}
|
||||
|
||||
Future<void> _loadPrecisionModeSetting() async {
|
||||
final enabled = await SettingsService.getVolumePrecisionMode();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_precisionModeEnabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_precisionTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _enterPrecisionMode() {
|
||||
if (_inPrecisionMode) return;
|
||||
HapticFeedback.mediumImpact(); // Vibrate to indicate precision mode
|
||||
setState(() {
|
||||
_inPrecisionMode = true;
|
||||
_precisionZoomCenter = _dragVolumeLevel; // Capture current volume as zoom center
|
||||
_precisionStartX = _lastLocalX; // Capture finger position at entry
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(true);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: ENTERED at ${(_precisionZoomCenter * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
void _exitPrecisionMode() {
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
if (!_inPrecisionMode) return;
|
||||
setState(() {
|
||||
_inPrecisionMode = false;
|
||||
});
|
||||
widget.onPrecisionModeChanged?.call(false);
|
||||
_volumeLogger.debug(
|
||||
'PRECISION_MODE [${widget.player.name}]: EXITED',
|
||||
context: 'Volume',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -67,7 +141,10 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
final filledColor = widget.backgroundColor;
|
||||
final unfilledColor = Color.lerp(widget.backgroundColor, Colors.black, 0.3)!;
|
||||
|
||||
return LayoutBuilder(
|
||||
// RepaintBoundary isolates this card's repaint from parent transform animations
|
||||
// This improves performance during the staggered reveal animation
|
||||
return RepaintBoundary(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_cardWidth = constraints.maxWidth;
|
||||
|
||||
@@ -133,6 +210,7 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,25 +360,83 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
}
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
// Get current volume from player
|
||||
final currentVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
// For consecutive swipes, use local volume (API may not have updated player state yet)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final timeSinceLastDrag = now - _lastDragEndTime;
|
||||
final isWithinWindow = timeSinceLastDrag < _consecutiveSwipeWindowMs;
|
||||
|
||||
// Use local volume if:
|
||||
// 1. We have a local override (we've swiped before) AND
|
||||
// 2. We're within the consecutive swipe window (5 seconds)
|
||||
// Otherwise, read fresh from player state
|
||||
final useLocalVolume = _hasLocalVolumeOverride && isWithinWindow;
|
||||
|
||||
final playerVolume = (widget.player.volumeLevel ?? 0).toDouble() / 100.0;
|
||||
final startVolume = useLocalVolume
|
||||
? _dragVolumeLevel // Continue from where last swipe ended
|
||||
: playerVolume; // Fresh from player
|
||||
|
||||
_volumeLogger.debug(
|
||||
'DRAG_START [${widget.player.name}]: '
|
||||
'useLocal=$useLocalVolume, hasOverride=$_hasLocalVolumeOverride, '
|
||||
'inWindow=$isWithinWindow (${timeSinceLastDrag}ms ago), '
|
||||
'localVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'playerVol=${(playerVolume * 100).round()}%, '
|
||||
'startVol=${(startVolume * 100).round()}%',
|
||||
context: 'Volume',
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isDraggingVolume = true;
|
||||
_dragVolumeLevel = currentVolume;
|
||||
_dragVolumeLevel = startVolume;
|
||||
});
|
||||
_lastDragPosition = details.globalPosition;
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details) {
|
||||
if (!_isDraggingVolume || _cardWidth <= 0) return;
|
||||
|
||||
// Directional volume: drag right = increase, drag left = decrease
|
||||
// Distance determines amount of change (full card width = 100%)
|
||||
// Multiple swipes accumulate - each starts from current volume
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / _cardWidth;
|
||||
// Track local X position for precision mode
|
||||
_lastLocalX = details.localPosition.dx;
|
||||
|
||||
final newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
// Check for stillness to trigger precision mode (only if enabled in settings)
|
||||
final currentPosition = details.globalPosition;
|
||||
if (_precisionModeEnabled && _lastDragPosition != null) {
|
||||
final movement = (currentPosition - _lastDragPosition!).distance;
|
||||
|
||||
if (movement < PlayerCard.precisionStillnessThreshold) {
|
||||
// Finger is still - start precision timer if not already running
|
||||
if (_precisionTimer == null && !_inPrecisionMode) {
|
||||
_precisionTimer = Timer(
|
||||
Duration(milliseconds: PlayerCard.precisionTriggerMs),
|
||||
_enterPrecisionMode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Finger moved - cancel timer (but don't exit precision mode if already in it)
|
||||
_precisionTimer?.cancel();
|
||||
_precisionTimer = null;
|
||||
}
|
||||
}
|
||||
_lastDragPosition = currentPosition;
|
||||
|
||||
double newVolume;
|
||||
|
||||
if (_inPrecisionMode) {
|
||||
// PRECISION MODE: Movement from entry point maps to zoomed range
|
||||
// Full card width of movement = precisionSensitivity (10%) change
|
||||
// e.g., at 40% center: moving full card right = 50%, full card left = 30%
|
||||
final offsetX = details.localPosition.dx - _precisionStartX;
|
||||
final normalizedOffset = offsetX / _cardWidth; // -1.0 to +1.0 range
|
||||
final volumeChange = normalizedOffset * PlayerCard.precisionSensitivity;
|
||||
newVolume = (_precisionZoomCenter + volumeChange).clamp(0.0, 1.0);
|
||||
} else {
|
||||
// NORMAL MODE: Delta-based movement (full card width = 100%)
|
||||
final dragDelta = details.delta.dx;
|
||||
final volumeDelta = dragDelta / _cardWidth;
|
||||
newVolume = (_dragVolumeLevel + volumeDelta).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Always update visual
|
||||
if ((newVolume - _dragVolumeLevel).abs() > 0.001) {
|
||||
@@ -319,7 +455,17 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
// Send final volume on release
|
||||
_volumeLogger.debug(
|
||||
'DRAG_END [${widget.player.name}]: '
|
||||
'finalVol=${(_dragVolumeLevel * 100).round()}%, '
|
||||
'precisionMode=$_inPrecisionMode',
|
||||
context: 'Volume',
|
||||
);
|
||||
widget.onVolumeChange?.call(_dragVolumeLevel);
|
||||
_lastDragEndTime = DateTime.now().millisecondsSinceEpoch; // Track for consecutive swipes
|
||||
_hasLocalVolumeOverride = true; // Mark that we have a local volume value
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
@@ -327,6 +473,8 @@ class _PlayerCardState extends State<PlayerCard> {
|
||||
}
|
||||
|
||||
void _onDragCancel() {
|
||||
_exitPrecisionMode();
|
||||
_lastDragPosition = null;
|
||||
setState(() {
|
||||
_isDraggingVolume = false;
|
||||
});
|
||||
|
||||
@@ -449,6 +449,7 @@ class PlayerRevealOverlayState extends State<PlayerRevealOverlay>
|
||||
// Use the elastic animation value directly (already has bounce)
|
||||
final slideOffset = distanceToTravel * (1.0 - t);
|
||||
|
||||
// Darken cards when:
|
||||
return Transform.translate(
|
||||
offset: Offset(0, slideOffset),
|
||||
child: Padding(
|
||||
|
||||
+300
-121
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:great_list_view/great_list_view.dart';
|
||||
import '../../providers/music_assistant_provider.dart';
|
||||
import '../../models/player.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
import '../common/empty_state.dart';
|
||||
|
||||
/// Panel that displays the current playback queue
|
||||
class QueuePanel extends StatelessWidget {
|
||||
/// Panel that displays the current playback queue with drag-to-reorder
|
||||
/// and swipe-left-to-delete functionality
|
||||
class QueuePanel extends StatefulWidget {
|
||||
final MusicAssistantProvider maProvider;
|
||||
final PlayerQueue? queue;
|
||||
final bool isLoading;
|
||||
@@ -31,49 +33,145 @@ class QueuePanel extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh_rounded, color: textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: onRefresh,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
State<QueuePanel> createState() => _QueuePanelState();
|
||||
}
|
||||
|
||||
// Queue content
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? Center(child: CircularProgressIndicator(color: primaryColor))
|
||||
: queue == null || queue!.items.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildQueueList(),
|
||||
),
|
||||
],
|
||||
class _QueuePanelState extends State<QueuePanel> {
|
||||
final AnimatedListController _listController = AnimatedListController();
|
||||
List<QueueItem> _items = [];
|
||||
|
||||
// Track pointer for right-swipe-to-close (bypasses gesture arena)
|
||||
Offset? _pointerStart;
|
||||
static const _swipeThreshold = 80.0; // Minimum horizontal distance for swipe
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = widget.queue?.items ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(QueuePanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
final newItems = widget.queue?.items ?? [];
|
||||
|
||||
// Sync items when:
|
||||
// 1. Player changed (different queue entirely)
|
||||
// 2. Item count changed (deletion confirmed by server, or track added)
|
||||
// 3. Item order changed (shuffle toggle, manual reorder)
|
||||
final playerChanged = widget.queue?.playerId != oldWidget.queue?.playerId;
|
||||
final countChanged = newItems.length != _items.length;
|
||||
final orderChanged = !_itemListsEqual(newItems, _items);
|
||||
|
||||
if (playerChanged || countChanged || orderChanged) {
|
||||
_items = newItems;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two item lists by queueItemId to detect reordering
|
||||
bool _itemListsEqual(List<QueueItem> a, List<QueueItem> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].queueItemId != b[i].queueItemId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _formatDuration(Duration? duration) {
|
||||
if (duration == null) return '';
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _handleDelete(QueueItem item) async {
|
||||
// DON'T update _items - let Dismissible handle the visual removal
|
||||
// Updating _items causes AutomaticAnimatedListView to also animate,
|
||||
// creating a double-animation bug
|
||||
|
||||
// Just call the API - fire and forget
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId != null) {
|
||||
widget.maProvider.api?.queueCommandDeleteItem(playerId, item.queueItemId);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleReorder(int oldIndex, int newIndex) async {
|
||||
// Reorder via API - use playerId as queue ID
|
||||
final playerId = widget.queue?.playerId;
|
||||
if (playerId != null && oldIndex != newIndex) {
|
||||
final item = _items[oldIndex];
|
||||
await widget.maProvider.api?.queueCommandMoveItem(playerId, item.queueItemId, newIndex);
|
||||
widget.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use Listener for raw pointer events to detect right-swipe-to-close
|
||||
// This bypasses the gesture arena so Dismissible doesn't block it
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
_pointerStart = event.position;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (_pointerStart != null) {
|
||||
final dx = event.position.dx - _pointerStart!.dx;
|
||||
final dy = (event.position.dy - _pointerStart!.dy).abs();
|
||||
// Right swipe: horizontal movement > threshold, mostly horizontal (not vertical)
|
||||
if (dx > _swipeThreshold && dx > dy * 2) {
|
||||
_pointerStart = null; // Reset to prevent multiple triggers
|
||||
widget.onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (_) => _pointerStart = null,
|
||||
onPointerCancel: (_) => _pointerStart = null,
|
||||
child: Container(
|
||||
color: widget.backgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: EdgeInsets.only(top: widget.topPadding + 4, left: 4, right: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back_rounded, color: widget.textColor, size: IconSizes.md),
|
||||
onPressed: widget.onClose,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Queue',
|
||||
style: TextStyle(
|
||||
color: widget.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh_rounded, color: widget.textColor.withOpacity(0.7), size: IconSizes.sm),
|
||||
onPressed: widget.onRefresh,
|
||||
padding: Spacing.paddingAll12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Queue content
|
||||
Expanded(
|
||||
child: widget.isLoading
|
||||
? Center(child: CircularProgressIndicator(color: widget.primaryColor))
|
||||
: widget.queue == null || _items.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildQueueList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -83,96 +181,177 @@ class QueuePanel extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildQueueList() {
|
||||
final currentIndex = queue!.currentIndex ?? 0;
|
||||
final items = queue!.items;
|
||||
final currentIndex = widget.queue!.currentIndex ?? 0;
|
||||
|
||||
return ListView.builder(
|
||||
return AutomaticAnimatedListView<QueueItem>(
|
||||
list: _items,
|
||||
padding: Spacing.paddingH8,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false, // Items don't need individual keep-alive
|
||||
addRepaintBoundaries: false, // We add RepaintBoundary manually
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
comparator: AnimatedListDiffListComparator<QueueItem>(
|
||||
sameItem: (a, b) => a.queueItemId == b.queueItemId,
|
||||
sameContent: (a, b) => a.track.name == b.track.name && a.track.duration == b.track.duration,
|
||||
),
|
||||
itemBuilder: (context, item, data) {
|
||||
final index = _items.indexOf(item);
|
||||
final isCurrentItem = index == currentIndex;
|
||||
final isPastItem = index < currentIndex;
|
||||
final imageUrl = maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
opacity: isPastItem ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(vertical: Spacing.xxs),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentItem ? primaryColor.withOpacity(0.15) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
// PERF: 4x cache size for 44x44 display, zero fade for scrolling
|
||||
memCacheWidth: 176,
|
||||
memCacheHeight: 176,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: textColor.withOpacity(0.3), size: 20),
|
||||
// Always return same widget structure (with Dismissible) for consistency
|
||||
// Only vary the measuring flag for image loading optimization
|
||||
return _buildDismissibleQueueItem(item, index, isCurrentItem, isPastItem, measuring: data.measuring);
|
||||
},
|
||||
listController: _listController,
|
||||
reorderModel: AnimatedListReorderModel(
|
||||
onReorderStart: (index, dx, dy) => true,
|
||||
onReorderFeedback: (index, dropIndex, offset, dx, dy) => null,
|
||||
onReorderMove: (index, dropIndex) => true,
|
||||
onReorderComplete: (index, dropIndex, slot) {
|
||||
_handleReorder(index, dropIndex);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
addLongPressReorderable: false, // We use custom drag handle instead
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDismissibleQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
|
||||
return Dismissible(
|
||||
key: ValueKey(item.queueItemId),
|
||||
direction: DismissDirection.endToStart, // Swipe left to delete only
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade700,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white, size: 24),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
// Don't allow deleting currently playing track
|
||||
return !isCurrentItem;
|
||||
},
|
||||
onDismissed: (direction) {
|
||||
_handleDelete(item);
|
||||
},
|
||||
child: _buildQueueItem(item, index, isCurrentItem, isPastItem, measuring: measuring),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueItem(QueueItem item, int index, bool isCurrentItem, bool isPastItem, {bool measuring = false}) {
|
||||
final imageUrl = widget.maProvider.api?.getImageUrl(item.track, size: 80);
|
||||
final duration = _formatDuration(item.track.duration);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Opacity(
|
||||
opacity: isPastItem ? 0.5 : 1.0,
|
||||
child: Container(
|
||||
// Use background color with zero radius to cover any grey from list package
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentItem ? widget.primaryColor.withOpacity(0.15) : widget.backgroundColor,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: imageUrl != null && !measuring
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 176,
|
||||
memCacheHeight: 176,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: widget.textColor.withOpacity(0.1),
|
||||
child: Icon(Icons.music_note, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
item.track.name,
|
||||
style: TextStyle(
|
||||
color: isCurrentItem ? primaryColor : textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
title: Text(
|
||||
item.track.name,
|
||||
style: TextStyle(
|
||||
color: isCurrentItem ? widget.primaryColor : widget.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: isCurrentItem ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||
? Text(
|
||||
item.track.artists!.first.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: item.track.artists != null && item.track.artists!.isNotEmpty
|
||||
? Text(
|
||||
item.track.artists!.first.name,
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (duration.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Text(
|
||||
duration,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.6),
|
||||
color: widget.textColor.withOpacity(0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
trailing: isCurrentItem
|
||||
? Icon(Icons.play_arrow_rounded, color: primaryColor, size: 20)
|
||||
: null,
|
||||
onTap: () {
|
||||
// TODO: Jump to this track in queue
|
||||
// Music Assistant API doesn't currently expose a skip_to_index or play_queue_item endpoint
|
||||
// Would need to add API method like skipToQueueIndex(queueId, index) if MA supports it
|
||||
// Alternative: Could use multiple next() calls but that's inefficient and unreliable
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isCurrentItem)
|
||||
Icon(Icons.play_arrow_rounded, color: widget.primaryColor, size: 20)
|
||||
else if (!measuring)
|
||||
Builder(
|
||||
builder: (itemContext) => GestureDetector(
|
||||
onVerticalDragStart: (details) {
|
||||
_listController.notifyStartReorder(
|
||||
itemContext,
|
||||
details.localPosition.dx,
|
||||
details.localPosition.dy,
|
||||
);
|
||||
},
|
||||
onVerticalDragUpdate: (details) {
|
||||
_listController.notifyUpdateReorder(
|
||||
details.localPosition.dx,
|
||||
details.localPosition.dy,
|
||||
);
|
||||
},
|
||||
onVerticalDragEnd: (details) {
|
||||
_listController.notifyStopReorder(false);
|
||||
},
|
||||
onVerticalDragCancel: () {
|
||||
_listController.notifyStopReorder(true);
|
||||
},
|
||||
child: Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.drag_handle, color: widget.textColor.withOpacity(0.3), size: 20),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// TODO: Jump to this track in queue if MA API supports it
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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 '../screens/playlist_details_screen.dart';
|
||||
import '../constants/hero_tags.dart';
|
||||
import '../theme/theme_provider.dart';
|
||||
import '../utils/page_transitions.dart';
|
||||
|
||||
class PlaylistCard extends StatelessWidget {
|
||||
final Playlist playlist;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const PlaylistCard({
|
||||
super.key,
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
// Update adaptive colors immediately on tap
|
||||
updateAdaptiveColorsFromImage(context, imageUrl);
|
||||
Navigator.push(
|
||||
context,
|
||||
FadeSlidePageRoute(
|
||||
child: PlaylistDetailsScreen(
|
||||
playlist: playlist,
|
||||
provider: playlist.provider,
|
||||
itemId: playlist.itemId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Playlist artwork
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.playlistCover + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Playlist title
|
||||
Hero(
|
||||
tag: HeroTags.playlistTitle + (playlist.uri ?? playlist.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
playlist.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Owner/track count
|
||||
Text(
|
||||
playlist.owner ?? (playlist.trackCount != null ? '${playlist.trackCount} tracks' : ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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 'playlist_card.dart';
|
||||
|
||||
class PlaylistRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<Playlist>> Function() loadPlaylists;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<Playlist>? Function()? getCachedPlaylists;
|
||||
|
||||
const PlaylistRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadPlaylists,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedPlaylists,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistRow> createState() => _PlaylistRowState();
|
||||
}
|
||||
|
||||
class _PlaylistRowState extends State<PlaylistRow> with AutomaticKeepAliveClientMixin {
|
||||
List<Playlist> _playlists = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPlaylists();
|
||||
}
|
||||
|
||||
Future<void> _loadPlaylists() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedPlaylists = widget.getCachedPlaylists?.call();
|
||||
if (cachedPlaylists != null && cachedPlaylists.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_playlists = cachedPlaylists;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load fresh data
|
||||
try {
|
||||
final freshPlaylists = await widget.loadPlaylists();
|
||||
if (mounted && freshPlaylists.isNotEmpty) {
|
||||
setState(() {
|
||||
_playlists = freshPlaylists;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precachePlaylistImages(freshPlaylists);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precachePlaylistImages(List<Playlist> playlists) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final playlistsToCache = playlists.take(10);
|
||||
|
||||
for (final playlist in playlistsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(playlist, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_playlists.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Calculate card dimensions
|
||||
// Default: 44 for title, remaining for content (artwork + 2 lines of text)
|
||||
final rowHeight = widget.rowHeight ?? 237.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Artwork is square, text takes ~50px (8 padding + title + owner)
|
||||
final artworkSize = contentHeight - 50;
|
||||
final cardWidth = artworkSize;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Horizontal list of playlists
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _playlists.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _playlists.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: PlaylistCard(
|
||||
playlist: playlist,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 '../constants/hero_tags.dart';
|
||||
|
||||
class RadioStationCard extends StatelessWidget {
|
||||
final MediaItem radioStation;
|
||||
final VoidCallback? onTap;
|
||||
final String? heroTagSuffix;
|
||||
final int? imageCacheSize;
|
||||
|
||||
const RadioStationCard({
|
||||
super.key,
|
||||
required this.radioStation,
|
||||
this.onTap,
|
||||
this.heroTagSuffix,
|
||||
this.imageCacheSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
final imageUrl = maProvider.api?.getImageUrl(radioStation, size: 256);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final suffix = heroTagSuffix != null ? '_$heroTagSuffix' : '';
|
||||
final cacheSize = imageCacheSize ?? 256;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap ?? () {
|
||||
HapticFeedback.mediumImpact();
|
||||
// Play the radio station on selected player
|
||||
final selectedPlayer = maProvider.selectedPlayer;
|
||||
if (selectedPlayer != null) {
|
||||
maProvider.api?.playRadioStation(selectedPlayer.playerId, radioStation);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Radio station artwork - circular for radio
|
||||
AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Hero(
|
||||
tag: HeroTags.radioCover + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: cacheSize,
|
||||
memCacheHeight: cacheSize,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
placeholder: (context, url) => const SizedBox(),
|
||||
errorWidget: (context, url, error) => Center(
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.radio_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Radio station name - centered for circular images
|
||||
Hero(
|
||||
tag: HeroTags.radioTitle + (radioStation.uri ?? radioStation.itemId) + suffix,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Text(
|
||||
radioStation.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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 'radio_station_card.dart';
|
||||
|
||||
class RadioStationRow extends StatefulWidget {
|
||||
final String title;
|
||||
final Future<List<MediaItem>> Function() loadRadioStations;
|
||||
final String? heroTagSuffix;
|
||||
final double? rowHeight;
|
||||
final List<MediaItem>? Function()? getCachedRadioStations;
|
||||
|
||||
const RadioStationRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.loadRadioStations,
|
||||
this.heroTagSuffix,
|
||||
this.rowHeight,
|
||||
this.getCachedRadioStations,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RadioStationRow> createState() => _RadioStationRowState();
|
||||
}
|
||||
|
||||
class _RadioStationRowState extends State<RadioStationRow> with AutomaticKeepAliveClientMixin {
|
||||
List<MediaItem> _radioStations = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasLoaded = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRadioStations();
|
||||
}
|
||||
|
||||
Future<void> _loadRadioStations() async {
|
||||
if (_hasLoaded) return;
|
||||
_hasLoaded = true;
|
||||
|
||||
// 1. Try to get cached data synchronously for instant display
|
||||
final cachedStations = widget.getCachedRadioStations?.call();
|
||||
if (cachedStations != null && cachedStations.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_radioStations = cachedStations;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load fresh data
|
||||
try {
|
||||
final freshStations = await widget.loadRadioStations();
|
||||
if (mounted && freshStations.isNotEmpty) {
|
||||
setState(() {
|
||||
_radioStations = freshStations;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Pre-cache images for smooth hero animations
|
||||
_precacheRadioImages(freshStations);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent failure - keep showing cached data
|
||||
}
|
||||
|
||||
if (mounted && _isLoading) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _precacheRadioImages(List<MediaItem> stations) {
|
||||
if (!mounted) return;
|
||||
final maProvider = context.read<MusicAssistantProvider>();
|
||||
|
||||
final stationsToCache = stations.take(10);
|
||||
|
||||
for (final station in stationsToCache) {
|
||||
final imageUrl = maProvider.api?.getImageUrl(station, size: 256);
|
||||
if (imageUrl != null) {
|
||||
precacheImage(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
context,
|
||||
).catchError((_) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
// Don't render if empty and done loading
|
||||
if (_radioStations.isEmpty && !_isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
// Calculate card dimensions - similar to ArtistRow for circular items
|
||||
// Default: 44 for title, remaining for content (circular image + name)
|
||||
final rowHeight = widget.rowHeight ?? 207.0;
|
||||
final titleHeight = 44.0;
|
||||
final contentHeight = rowHeight - titleHeight;
|
||||
// Circular artwork, text takes ~40px (8 padding + name lines)
|
||||
final artworkSize = contentHeight - 40;
|
||||
final cardWidth = artworkSize;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Horizontal list of radio stations
|
||||
SizedBox(
|
||||
height: contentHeight,
|
||||
child: _isLoading && _radioStations.isEmpty
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
itemCount: _radioStations.length,
|
||||
cacheExtent: 500,
|
||||
addAutomaticKeepAlives: false,
|
||||
itemBuilder: (context, index) {
|
||||
final station = _radioStations[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: SizedBox(
|
||||
width: cardWidth,
|
||||
child: RadioStationCard(
|
||||
radioStation: station,
|
||||
heroTagSuffix: widget.heroTagSuffix,
|
||||
imageCacheSize: 256,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
name: ensemble
|
||||
description: A minimalistic Music Assistant client built with Flutter
|
||||
publish_to: 'none'
|
||||
version: 2.8.1-beta+38
|
||||
version: 2.8.5-beta+43
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -49,6 +49,9 @@ dependencies:
|
||||
# Raw PCM Audio Playback (for Sendspin streaming)
|
||||
flutter_pcm_sound: ^3.3.3
|
||||
|
||||
# Animated list with drag-to-reorder
|
||||
great_list_view: ^0.2.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user