Files
AudiOhm/docs/plans/2025-01-18-search-page.md
T
root a89c7894cf Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube
Backend:
- FastAPI avec PostgreSQL et Redis
- Authentification JWT complète
- API REST pour musique, playlists, recherche
- Streaming audio via yt-dlp
- SQLAlchemy 2.0 async

Frontend:
- Flutter avec thème néon cyberpunk
- State management Riverpod
- Layout adaptatif desktop/mobile
- Lecteur audio avec mini-player

Infrastructure:
- Docker Compose (PostgreSQL + Redis)
- Scripts d'installation automatisés
- Scripts de build pour exécutables

Fichiers ajoutés:
- BUILD_CLIENT_*.bat/sh: Scripts de compilation
- BUILD_CLIENT_README.md: Documentation compilation
- CHECK_FLUTTER.sh: Vérificateur d'environnement
- requirements.txt mis à jour pour Python 3.13
- Modèles SQLAlchemy corrigés (metadata -> extra_metadata)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 20:08:36 +00:00

26 KiB

Search Page Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a complete search page with real-time search, results display, and filtering for tracks, artists, and albums.

Architecture: Search input with debouncing → API call → Results grouped by type (tracks/artists/albums) → Tap to view details or play. Uses existing MusicApiService and follows neon cyberpunk theme.

Tech Stack: Flutter, Riverpod, MusicApiService (already exists), Debouncer utility


Task 1: Create Search State and Notifier

Files:

  • Create: frontend/lib/presentation/providers/search_provider.dart

Step 1: Write the search state model

/// Search state
class SearchState {
  final String query;
  final bool isSearching;
  final List<Map<String, dynamic>> tracks;
  final List<Map<String, dynamic>> artists;
  final List<Map<String, dynamic>> albums;
  final String? error;

  const SearchState({
    this.query = '',
    this.isSearching = false,
    this.tracks = const [],
    this.artists = const [],
    this.albums = const [],
    this.error,
  });

  SearchState copyWith({
    String? query,
    bool? isSearching,
    List<Map<String, dynamic>>? tracks,
    List<Map<String, dynamic>>? artists,
    List<Map<String, dynamic>>? albums,
    String? error,
  }) {
    return SearchState(
      query: query ?? this.query,
      isSearching: isSearching ?? this.isSearching,
      tracks: tracks ?? this.tracks,
      artists: artists ?? this.artists,
      albums: albums ?? this.albums,
      error: error,
    );
  }

  int get totalResults => tracks.length + artists.length + albums.length;
}

Step 2: Write the search notifier with debouncing

class SearchNotifier extends StateNotifier<SearchState> {
  SearchNotifier(this._musicApiService) : super(const SearchState());

  final MusicApiService _musicApiService;
  Timer? _debounceTimer;

  static const _debounceDuration = Duration(milliseconds: 500);

  void search(String query) {
    if (query.trim().isEmpty) {
      state = const SearchState();
      _debounceTimer?.cancel();
      return;
    }

    _debounceTimer?.cancel();
    state = state.copyWith(query: query, isSearching: true);

    _debounceTimer = Timer(_debounceDuration, () => _performSearch(query));
  }

  Future<void> _performSearch(String query) async {
    try {
      final results = await _musicApiService.search(
        query,
        type: 'all',
        limit: 20,
      );

      state = SearchState(
        query: query,
        tracks: results['tracks'] ?? [],
        artists: results['artists'] ?? [],
        albums: results['albums'] ?? [],
      );
    } catch (e) {
      state = SearchState(
        query: query,
        error: e.toString(),
      );
    } finally {
      // Keep isSearching false if this was the latest search
      if (state.query == query) {
        state = state.copyWith(isSearching: false);
      }
    }
  }

  void clear() {
    _debounceTimer?.cancel();
    state = const SearchState();
  }

  @override
  void dispose() {
    _debounceTimer?.cancel();
    super.dispose();
  }
}

Step 3: Create the provider

final searchProvider = StateNotifierProvider<SearchNotifier, SearchState>((ref) {
  final musicApiService = ref.watch(musicApiServiceProvider);
  return SearchNotifier(musicApiService);
});

Step 4: Commit

git add frontend/lib/presentation/providers/search_provider.dart
git commit -m "feat: add search state management with debouncing"

Task 2: Create Search Page UI

Files:

  • Create: frontend/lib/presentation/pages/search/search_page.dart
  • Create: frontend/lib/presentation/pages/search/search_desktop_page.dart
  • Create: frontend/lib/presentation/pages/search/search_mobile_page.dart
  • Modify: frontend/lib/presentation/adaptive/adaptive_layout.dart (add search route handling)

Step 1: Write the main search page widget

class SearchPage extends ConsumerWidget {
  const SearchPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 800) {
          return const SearchDesktopPage();
        } else {
          return const SearchMobilePage();
        }
      },
    );
  }
}

Step 2: Write desktop search page

class SearchDesktopPage extends ConsumerStatefulWidget {
  const SearchDesktopPage({super.key});

  @override
  ConsumerState<SearchDesktopPage> createState() => _SearchDesktopPageState();
}

class _SearchDesktopPageState extends ConsumerState<SearchDesktopPage> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();

  @override
  void dispose() {
    _controller.dispose();
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Search bar
        Padding(
          padding: const EdgeInsets.all(24),
          child: _buildSearchBar(),
        ),
        // Results
        Expanded(
          child: _buildResults(),
        ),
      ],
    );
  }

  Widget _buildSearchBar() {
    return Container(
      decoration: BoxDecoration(
        color: AppColors.surfaceVariant,
        borderRadius: BorderRadius.circular(28),
        border: Border.all(
          color: AppColors.cyan.withOpacity(0.2),
          width: 2,
        ),
      ),
      child: TextField(
        controller: _controller,
        focusNode: _focusNode,
        style: const TextStyle(color: AppColors.onSurface, fontSize: 16),
        decoration: InputDecoration(
          hintText: 'What do you want to listen to?',
          hintStyle: TextStyle(color: AppColors.muted),
          prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
          suffixIcon: _controller.text.isNotEmpty
              ? IconButton(
                  icon: const Icon(Icons.clear, color: AppColors.muted),
                  onPressed: () {
                    _controller.clear();
                    ref.read(searchProvider.notifier).clear();
                  },
                )
              : null,
          border: InputBorder.none,
          contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
        ),
        onChanged: (value) {
          ref.read(searchProvider.notifier).search(value);
        },
      ),
    );
  }

  Widget _buildResults() {
    final searchState = ref.watch(searchProvider);

    if (searchState.query.isEmpty) {
      return _buildEmptyState();
    }

    if (searchState.isSearching) {
      return const Center(
        child: CircularProgressIndicator(color: AppColors.cyan),
      );
    }

    if (searchState.error != null) {
      return _buildErrorState(searchState.error!);
    }

    if (searchState.totalResults == 0) {
      return _buildNoResultsState();
    }

    return CustomScrollView(
      slivers: [
        // Tracks section
        if (searchState.tracks.isNotEmpty)
          SliverToBoxAdapter(
            child: _buildSection('Tracks', searchState.tracks),
          ),

        // Artists section
        if (searchState.artists.isNotEmpty)
          SliverToBoxAdapter(
            child: _buildSection('Artists', searchState.artists),
          ),

        // Albums section
        if (searchState.albums.isNotEmpty)
          SliverToBoxAdapter(
            child: _buildSection('Albums', searchState.albums),
          ),
      ],
    );
  }

  Widget _buildSection(String title, List<dynamic> items) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
          child: Text(
            title,
            style: const TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.w600,
              color: AppColors.cyan,
            ),
          ),
        ),
        GridView.builder(
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          padding: const EdgeInsets.symmetric(horizontal: 24),
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 4,
            mainAxisSpacing: 16,
            crossAxisSpacing: 16,
            childAspectRatio: 1,
          ),
          itemCount: items.length,
          itemBuilder: (context, index) {
            return _buildResultCard(items[index]);
          },
        ),
      ],
    );
  }

  Widget _buildResultCard(dynamic item) {
    // Implementation depends on item type
    return Card(
      child: Center(child: Text(item.toString())),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.search,
            size: 64,
            color: AppColors.muted,
          ),
          const SizedBox(height: 16),
          Text(
            'Search for your favorite music',
            style: TextStyle(
              fontSize: 18,
              color: AppColors.muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildErrorState(String error) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.error_outline,
            size: 64,
            color: AppColors.error,
          ),
          const SizedBox(height: 16),
          Text(
            'Something went wrong',
            style: TextStyle(
              fontSize: 18,
              color: AppColors.error,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            error,
            style: TextStyle(
              fontSize: 14,
              color: AppColors.muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildNoResultsState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.music_off,
            size: 64,
            color: AppColors.muted,
          ),
          const SizedBox(height: 16),
          Text(
            'No results found',
            style: TextStyle(
              fontSize: 18,
              color: AppColors.muted,
            ),
          ),
        ],
      ),
    );
  }
}

Step 3: Write mobile search page (simplified layout)

class SearchMobilePage extends ConsumerStatefulWidget {
  const SearchMobilePage({super.key});

  @override
  ConsumerState<SearchMobilePage> createState() => _SearchMobilePageState();
}

class _SearchMobilePageState extends ConsumerState<SearchMobilePage> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Search bar
        Padding(
          padding: const EdgeInsets.all(16),
          child: _buildSearchBar(),
        ),
        // Results (same as desktop but different layout)
        Expanded(
          child: _buildResults(),
        ),
      ],
    );
  }

  Widget _buildSearchBar() {
    // Similar to desktop but smaller
    return Container(
      decoration: BoxDecoration(
        color: AppColors.surfaceVariant,
        borderRadius: BorderRadius.circular(24),
      ),
      child: TextField(
        controller: _controller,
        onChanged: (value) {
          ref.read(searchProvider.notifier).search(value);
        },
        decoration: InputDecoration(
          hintText: 'Search...',
          prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
          border: InputBorder.none,
          contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
        ),
      ),
    );
  }

  Widget _buildResults() {
    // Reuse desktop logic but with 2-column grid
    final searchState = ref.watch(searchProvider);

    if (searchState.query.isEmpty) {
      return _buildEmptyState();
    }

    // ... similar to desktop
    return const SizedBox.shrink();
  }

  Widget _buildEmptyState() {
    return Center(
      child: Icon(Icons.search, size: 48, color: AppColors.muted),
    );
  }
}

Step 4: Update adaptive layout to include search

Widget _buildCurrentPage(String page) {
  switch (page) {
    case 'home':
      return const HomePage();
    case 'search':
      return const SearchPage(); // Add this
    case 'library':
      return const _PlaceholderPage(title: 'Library');
    case 'settings':
      return const _PlaceholderPage(title: 'Settings');
    default:
      return const HomePage();
  }
}

Step 5: Commit

git add frontend/lib/presentation/pages/search/
git commit -m "feat: add search page with desktop and mobile layouts"

Task 3: Create Search Result Cards

Files:

  • Create: frontend/lib/presentation/widgets/search/search_track_card.dart
  • Create: frontend/lib/presentation/widgets/search/search_artist_card.dart
  • Create: frontend/lib/presentation/widgets/search/search_album_card.dart

Step 1: Write track search result card

class SearchTrackCard extends StatelessWidget {
  final Map<String, dynamic> track;
  final VoidCallback? onTap;

  const SearchTrackCard({
    required this.track,
    this.onTap,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        decoration: BoxDecoration(
          gradient: AppColors.primaryGradient,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: AppColors.cyan.withOpacity(0.3),
          ),
        ),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              // Thumbnail or icon
              Expanded(
                child: Icon(
                  Icons.music_note,
                  color: AppColors.onBackground.withOpacity(0.8),
                  size: 32,
                ),
              ),
              // Track info
              Text(
                track['title'] ?? 'Unknown Track',
                style: const TextStyle(
                  color: AppColors.onBackground,
                  fontWeight: FontWeight.w600,
                  fontSize: 16,
                ),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
              const SizedBox(height: 4),
              Text(
                track['artist'] ?? 'Unknown Artist',
                style: const TextStyle(
                  color: AppColors.onBackground.withOpacity(0.7),
                  fontSize: 14,
                ),
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Step 2: Write artist search result card

class SearchArtistCard extends StatelessWidget {
  final Map<String, dynamic> artist;
  final VoidCallback? onTap;

  const SearchArtistCard({
    required this.artist,
    this.onTap,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        decoration: BoxDecoration(
          color: AppColors.surface,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: AppColors.violet.withOpacity(0.3),
          ),
        ),
        child: Column(
          children: [
            // Artist image or placeholder
            Expanded(
              child: Container(
                width: double.infinity,
                decoration: BoxDecoration(
                  gradient: AppColors.accentGradient,
                  borderRadius: const BorderRadius.vertical(
                    top: Radius.circular(11),
                  ),
                ),
                child: Icon(
                  Icons.person,
                  color: AppColors.onBackground.withOpacity(0.8),
                  size: 40,
                ),
              ),
            ),
            // Artist name
            Padding(
              padding: const EdgeInsets.all(8),
              child: Text(
                artist['name'] ?? 'Unknown Artist',
                style: const TextStyle(
                  color: AppColors.onSurface,
                  fontWeight: FontWeight.w500,
                  fontSize: 14,
                ),
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Step 3: Write album search result card

class SearchAlbumCard extends StatelessWidget {
  final Map<String, dynamic> album;
  final VoidCallback? onTap;

  const SearchAlbumCard({
    required this.album,
    this.onTap,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        decoration: BoxDecoration(
          color: AppColors.surface,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color: AppColors.rose.withOpacity(0.3),
          ),
        ),
        child: Column(
          children: [
            // Album cover or placeholder
            Expanded(
              child: Container(
                width: double.infinity,
                decoration: BoxDecoration(
                  gradient: AppColors.fullGradient,
                  borderRadius: const BorderRadius.vertical(
                    top: Radius.circular(11),
                  ),
                ),
                child: Icon(
                  Icons.album,
                  color: AppColors.onBackground.withOpacity(0.8),
                  size: 40,
                ),
              ),
            ),
            // Album info
            Padding(
              padding: const EdgeInsets.all(8),
              child: Column(
                children: [
                  Text(
                    album['title'] ?? 'Unknown Album',
                    style: const TextStyle(
                      color: AppColors.onSurface,
                      fontWeight: FontWeight.w500,
                      fontSize: 14,
                    ),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  if (album['artist'] != null)
                    Text(
                      album['artist']!,
                      style: const TextStyle(
                        color: AppColors.muted,
                        fontSize: 12,
                      ),
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Step 4: Export widgets

Create frontend/lib/presentation/widgets/search/search_widgets.dart:

export 'search_track_card.dart';
export 'search_artist_card.dart';
export 'search_album_card.dart';

Step 5: Commit

git add frontend/lib/presentation/widgets/search/
git commit -m "feat: add search result cards for tracks, artists, albums"

Task 4: Connect Search Results to Player

Files:

  • Modify: frontend/lib/presentation/pages/search/search_desktop_page.dart
  • Modify: frontend/lib/presentation/providers/music_provider.dart (add queue management)

Step 1: Add play on tap functionality to search cards

// In _buildResultCard method
Widget _buildResultCard(dynamic item) {
  final playerNotifier = ref.read(playerProvider.notifier);

  if (item.containsKey('title') && item.containsKey('artist')) {
    // It's a track
    return SearchTrackCard(
      track: item,
      onTap: () => _playTrack(item, playerNotifier),
    );
  } else if (item.containsKey('name')) {
    // It's an artist or album
    return SearchArtistCard(
      artist: item,
      onTap: () => _showArtistDetails(item),
    );
  }

  return const SizedBox.shrink();
}

void _playTrack(Map<String, dynamic> track, PlayerNotifier playerNotifier) {
  // Add to queue and play
  playerNotifier.setQueue([
    Track(
      id: track['id'] ?? '',
      title: track['title'] ?? '',
      artist: track['artist'],
      duration: track['duration'],
      imageUrl: track['image_url'],
      youtubeId: track['youtube_id'],
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
    ),
  ]);

  playerNotifier.play();
}

void _showArtistDetails(Map<String, dynamic> artist) {
  // Navigate to artist details (not implemented yet)
  print('Show artist: ${artist['name']}');
}

Step 2: Commit

git add frontend/lib/presentation/pages/search/
git commit -m "feat: connect search results to player"

Task 5: Add Search to Navigation

Files:

  • Modify: frontend/lib/presentation/widgets/desktop/desktop_sidebar.dart
  • Modify: frontend/lib/presentation/adaptive/adaptive_layout.dart

Step 1: Update nav items to include search

// In adaptive_layout.dart
final List<_NavItem> _navItems = const [
  _NavItem(page: 'home', label: 'Home', icon: Icons.home_outlined),
  _NavItem(page: 'search', label: 'Search', icon: Icons.search_outlined),
  _NavItem(page: 'library', label: 'Library', icon: Icons.library_music_outlined),
  _NavItem(page: 'settings', label: 'Settings', icon: Icons.settings_outlined),
];

Step 2: Commit

git add frontend/lib/presentation/adaptive/
git commit -m "feat: add search to navigation"

Task 6: Create Search Page Tests

Files:

  • Create: frontend/test/presentation/providers/search_provider_test.dart
  • Create: frontend/test/presentation/pages/search/search_page_test.dart

Step 1: Write search provider tests

void main() {
  group('SearchNotifier', () {
    late SearchNotifier notifier;
    late MockMusicApiService mockApi;

    setUp(() {
      mockApi = MockMusicApiService();
      notifier = SearchNotifier(mockApi);
    });

    tearDown(() {
      notifier.dispose();
    });

    test('initial state is empty', () {
      expect(notifier.state.query, '');
      expect(notifier.state.isSearching, false);
      expect(notifier.state.tracks, isEmpty);
    });

    test('search updates state with results', () async {
      when(mockApi.search('test query'))
          .thenAnswer((_) async => {
            'tracks': [
              {'id': '1', 'title': 'Test Track', 'artist': 'Test Artist'},
            ],
            'artists': [],
            'albums': [],
            'total': 1,
          });

      notifier.search('test query');

      await Future.delayed(const Duration(milliseconds: 600));

      expect(notifier.state.query, 'test query');
      expect(notifier.state.tracks.length, 1);
      expect(notifier.state.tracks.first['title'], 'Test Track');
    });

    test('clear resets state', () {
      notifier.search('test');
      notifier.clear();

      expect(notifier.state.query, '');
      expect(notifier.state.isSearching, false);
    });
  });
}

Step 2: Write search page widget tests

void main() {
  testWidgets('SearchPage shows desktop layout on wide screen', (tester) async {
    await tester.pumpWidget(
      MediaQuery(
        data: const MediaQueryData(size: Size(1200, 800)),
        child: MaterialApp(
          home: ProviderScope(
            overrides: [
              searchProvider.overrideWithProvider((ref) => SearchNotifier(mockApi)),
            ],
            child: const SearchPage(),
          ),
        ),
      ),
    );

    expect(find.text('What do you want to listen to?'), findsOneWidget);
  });

  testWidgets('SearchPage shows mobile layout on narrow screen', (tester) async {
    await tester.pumpWidget(
      MediaQuery(
        data: const MediaQueryData(size: Size(400, 800)),
        child: MaterialApp(
          home: ProviderScope(
            overrides: [
              searchProvider.overrideWithProvider((ref) => SearchNotifier(mockApi)),
            ],
            child: const SearchPage(),
          ),
        ),
      ),
    );

    expect(find.text('Search...'), findsOneWidget);
  });
}

Step 3: Commit

git add frontend/test/
git commit -m "test: add search page and provider tests"

Task 7: Update Export Files

Files:

  • Modify: frontend/lib/presentation/pages/pages.dart (create if doesn't exist)

Step 1: Create pages export file

export 'home/home_page.dart';
export 'home/mobile_home_page.dart';
export 'auth/login_page.dart';
export 'search/search_page.dart';

Step 2: Commit

git add frontend/lib/presentation/pages/pages.dart
git commit -m "chore: export search page"

Task 8: Documentation

Files:

  • Create: docs/search-feature.md

Step 1: Write documentation

# Search Feature

## Overview
Real-time search with debouncing across tracks, artists, and albums.

## Usage
- Access from sidebar (desktop) or bottom nav (mobile)
- Type to search with 500ms debounce
- Results grouped by type
- Tap track to play immediately
- Tap artist/album for details (TODO)

## Components
- `SearchProvider` - State management
- `SearchPage` - Adaptive UI
- `SearchTrackCard`, `SearchArtistCard`, `SearchAlbumCard` - Result cards

Step 2: Commit

git add docs/search-feature.md
git commit -m "docs: add search feature documentation"

Testing Checklist

Before marking this feature complete, verify:

# Backend API is running
curl http://localhost:8000/api/v1/music/search?q=test

# Frontend search works
flutter test test/presentation/providers/search_provider_test.dart
flutter test test/presentation/pages/search/search_page_test.dart

# Manual test
flutter run
# Navigate to search, type query, verify results
# Tap track, verify it plays

  • API: frontend/lib/infrastructure/datasources/remote/music_api_service.dart
  • Provider: frontend/lib/presentation/providers/music_provider.dart
  • Theme: frontend/lib/core/theme/colors.dart
  • Design: docs/plans/2025-01-18-ui-ux-design.md

  1. Library Page - Liked songs, albums, artists
  2. Player Enhancements - Queue view, shuffle, repeat
  3. Artist Details Page - Full artist info, tracks, albums
  4. Album Details Page - Tracklist, play all
  5. Settings Page - User profile, audio quality, cache management