device_audio_query 0.0.2 copy "device_audio_query: ^0.0.2" to clipboard
device_audio_query: ^0.0.2 copied to clipboard

A Flutter plugin to query audio files and their metadata from the device's storage. It provides an easy-to-use API for retrieving information about songs, albums, artists, and playlists, making it ide [...]

example/lib/main.dart

import 'dart:typed_data';

import 'package:device_audio_query/device_audio_query.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const DeviceAudioQueryExampleApp());
}

class DeviceAudioQueryExampleApp extends StatelessWidget {
  const DeviceAudioQueryExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Device Audio Query',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6C63FF),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        fontFamily: 'sans-serif',
      ),
      home: const HomePage(),
    );
  }
}

// ─── Home Page ────────────────────────────────────────────────────────────────

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  final _plugin = DeviceAudioQuery();
  late final TabController _tabController;

  PermissionStatus? _permission;
  bool _loading = false;

  List<SongModel> _songs = [];
  List<AlbumModel> _albums = [];
  List<ArtistModel> _artists = [];
  List<PlaylistModel> _playlists = [];

  String _searchQuery = '';
  final _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 4, vsync: this);
    _checkPermission();
  }

  @override
  void dispose() {
    _tabController.dispose();
    _searchController.dispose();
    super.dispose();
  }

  Future<void> _checkPermission() async {
    setState(() => _loading = true);
    final status = await _plugin.checkPermission();
    setState(() {
      _permission = status;
      _loading = false;
    });
    if (status == PermissionStatus.granted) {
      await _loadAll();
    }
  }

  Future<void> _requestPermission() async {
    setState(() => _loading = true);
    final status = await _plugin.requestPermission();
    setState(() {
      _permission = status;
      _loading = false;
    });
    if (status == PermissionStatus.granted) {
      await _loadAll();
    }
  }

  Future<void> _loadAll() async {
    setState(() => _loading = true);
    final results = await Future.wait([
      _plugin.querySongs(sortType: SongSortType.title),
      _plugin.queryAlbums(sortType: AlbumSortType.album),
      _plugin.queryArtists(sortType: ArtistSortType.artist),
      _plugin.queryPlaylists(sortType: PlaylistSortType.playlist),
    ]);
    setState(() {
      _songs = results[0] as List<SongModel>;
      _albums = results[1] as List<AlbumModel>;
      _artists = results[2] as List<ArtistModel>;
      _playlists = results[3] as List<PlaylistModel>;
      _loading = false;
    });
  }

  List<SongModel> get _filteredSongs => _searchQuery.isEmpty
      ? _songs
      : _songs
          .where((s) =>
              (s.title).toLowerCase().contains(_searchQuery) ||
              (s.artist ?? '').toLowerCase().contains(_searchQuery))
          .toList();

  List<AlbumModel> get _filteredAlbums => _searchQuery.isEmpty
      ? _albums
      : _albums
          .where((a) =>
              a.album.toLowerCase().contains(_searchQuery) ||
              (a.artist ?? '').toLowerCase().contains(_searchQuery))
          .toList();

  List<ArtistModel> get _filteredArtists => _searchQuery.isEmpty
      ? _artists
      : _artists
          .where(
              (a) => a.artist.toLowerCase().contains(_searchQuery))
          .toList();

  List<PlaylistModel> get _filteredPlaylists => _searchQuery.isEmpty
      ? _playlists
      : _playlists
          .where((p) =>
              p.playlist.toLowerCase().contains(_searchQuery))
          .toList();

  // ─── Build ─────────────────────────────────────────────────────────────────

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Scaffold(
      backgroundColor: cs.surface,
      body: _permission == null || _permission != PermissionStatus.granted
          ? _buildPermissionGate(cs)
          : _buildMain(cs),
    );
  }

  // ─── Permission gate ───────────────────────────────────────────────────────

  Widget _buildPermissionGate(ColorScheme cs) {
    return Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [
            cs.primaryContainer,
            cs.surface,
            cs.secondaryContainer,
          ],
        ),
      ),
      child: SafeArea(
        child: Center(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 36),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Container(
                  width: 100,
                  height: 100,
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      colors: [cs.primary, cs.tertiary],
                    ),
                    shape: BoxShape.circle,
                    boxShadow: [
                      BoxShadow(
                        color: cs.primary.withValues(alpha: 0.4),
                        blurRadius: 32,
                        offset: const Offset(0, 12),
                      ),
                    ],
                  ),
                  child: const Icon(
                    Icons.library_music_rounded,
                    color: Colors.white,
                    size: 48,
                  ),
                ),
                const SizedBox(height: 32),
                Text(
                  'Device Audio Query',
                  style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                        color: cs.onSurface,
                        fontWeight: FontWeight.bold,
                      ),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 12),
                Text(
                  _permission == PermissionStatus.deniedForever
                      ? 'Media permission was permanently denied.\nPlease enable it in Settings.'
                      : 'Grant access to your music library to explore songs, albums, artists, and playlists.',
                  style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                        color: cs.onSurface.withValues(alpha: 0.7),
                      ),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 40),
                if (_permission != PermissionStatus.deniedForever)
                  _GradientButton(
                    onPressed: _loading ? null : _requestPermission,
                    child: _loading
                        ? const SizedBox(
                            width: 22,
                            height: 22,
                            child: CircularProgressIndicator(
                              strokeWidth: 2,
                              color: Colors.white,
                            ),
                          )
                        : const Text(
                            'Grant Permission',
                            style: TextStyle(
                              fontSize: 16,
                              fontWeight: FontWeight.w600,
                              color: Colors.white,
                            ),
                          ),
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  // ─── Main content ──────────────────────────────────────────────────────────

  Widget _buildMain(ColorScheme cs) {
    return NestedScrollView(
      headerSliverBuilder: (context, innerBoxIsScrolled) => [
        SliverAppBar(
          pinned: true,
          floating: true,
          expandedHeight: 120,
          backgroundColor: cs.surface,
          flexibleSpace: FlexibleSpaceBar(
            titlePadding:
                const EdgeInsets.only(left: 20, bottom: 56),
            title: Text(
              'My Library',
              style: TextStyle(
                fontSize: 24,
                fontWeight: FontWeight.bold,
                color: cs.onSurface,
              ),
            ),
          ),
          actions: [
            if (_loading)
              Padding(
                padding: const EdgeInsets.only(right: 16),
                child: Center(
                  child: SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(
                      strokeWidth: 2,
                      color: cs.primary,
                    ),
                  ),
                ),
              )
            else
              IconButton(
                icon: const Icon(Icons.refresh_rounded),
                onPressed: _loadAll,
                tooltip: 'Refresh',
              ),
          ],
          bottom: PreferredSize(
            preferredSize: const Size.fromHeight(56),
            child: Padding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
              child: _SearchBar(
                controller: _searchController,
                onChanged: (q) => setState(
                    () => _searchQuery = q.toLowerCase()),
              ),
            ),
          ),
        ),
        SliverPersistentHeader(
          pinned: true,
          delegate: _TabBarDelegate(
            TabBar(
              controller: _tabController,
              tabs: const [
                Tab(text: 'Songs'),
                Tab(text: 'Albums'),
                Tab(text: 'Artists'),
                Tab(text: 'Playlists'),
              ],
              labelColor: cs.primary,
              unselectedLabelColor: cs.onSurface.withValues(alpha: 0.5),
              indicatorColor: cs.primary,
              indicatorSize: TabBarIndicatorSize.label,
              dividerColor: cs.surfaceContainerHighest,
            ),
          ),
        ),
      ],
      body: TabBarView(
        controller: _tabController,
        children: [
          _SongList(songs: _filteredSongs, plugin: _plugin),
          _AlbumGrid(albums: _filteredAlbums, plugin: _plugin),
          _ArtistList(artists: _filteredArtists),
          _PlaylistList(playlists: _filteredPlaylists),
        ],
      ),
    );
  }
}

// ─── Tab bar delegate ─────────────────────────────────────────────────────────

class _TabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar tabBar;
  const _TabBarDelegate(this.tabBar);

  @override
  double get minExtent => tabBar.preferredSize.height;
  @override
  double get maxExtent => tabBar.preferredSize.height;

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return ColoredBox(
      color: Theme.of(context).colorScheme.surface,
      child: tabBar,
    );
  }

  @override
  bool shouldRebuild(covariant _TabBarDelegate oldDelegate) => false;
}

// ─── Search bar ───────────────────────────────────────────────────────────────

class _SearchBar extends StatelessWidget {
  final TextEditingController controller;
  final ValueChanged<String> onChanged;

  const _SearchBar({required this.controller, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return TextField(
      controller: controller,
      onChanged: onChanged,
      style: TextStyle(color: cs.onSurface),
      decoration: InputDecoration(
        hintText: 'Search…',
        hintStyle: TextStyle(color: cs.onSurface.withValues(alpha: 0.4)),
        prefixIcon: Icon(Icons.search_rounded,
            color: cs.onSurface.withValues(alpha: 0.4)),
        suffixIcon: controller.text.isNotEmpty
            ? IconButton(
                icon: Icon(Icons.clear_rounded,
                    color: cs.onSurface.withValues(alpha: 0.4)),
                onPressed: () {
                  controller.clear();
                  onChanged('');
                },
              )
            : null,
        filled: true,
        fillColor: cs.surfaceContainerHighest,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(14),
          borderSide: BorderSide.none,
        ),
        contentPadding: const EdgeInsets.symmetric(vertical: 0),
      ),
    );
  }
}

// ─── Song list ────────────────────────────────────────────────────────────────

class _SongList extends StatelessWidget {
  final List<SongModel> songs;
  final DeviceAudioQuery plugin;

  const _SongList({required this.songs, required this.plugin});

  String _formatDuration(int? ms) {
    if (ms == null) return '--:--';
    final s = ms ~/ 1000;
    final m = s ~/ 60;
    final sec = s % 60;
    return '$m:${sec.toString().padLeft(2, '0')}';
  }

  @override
  Widget build(BuildContext context) {
    if (songs.isEmpty) return const _EmptyState(label: 'No songs found');
    final cs = Theme.of(context).colorScheme;
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 8),
      itemCount: songs.length,
      itemBuilder: (_, i) {
        final song = songs[i];
        return ListTile(
          leading: _ArtworkThumbnail(
            id: song.id,
            type: ArtworkType.audio,
            plugin: plugin,
            size: 48,
            radius: 8,
          ),
          title: Text(
            song.title,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(fontWeight: FontWeight.w500),
          ),
          subtitle: Text(
            song.artist ?? 'Unknown Artist',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: TextStyle(color: cs.onSurface.withValues(alpha: 0.55)),
          ),
          trailing: Text(
            _formatDuration(song.duration),
            style: TextStyle(
              color: cs.onSurface.withValues(alpha: 0.45),
              fontSize: 12,
            ),
          ),
          contentPadding:
              const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
        );
      },
    );
  }
}

// ─── Album grid ───────────────────────────────────────────────────────────────

class _AlbumGrid extends StatelessWidget {
  final List<AlbumModel> albums;
  final DeviceAudioQuery plugin;

  const _AlbumGrid({required this.albums, required this.plugin});

  @override
  Widget build(BuildContext context) {
    if (albums.isEmpty) return const _EmptyState(label: 'No albums found');
    final cs = Theme.of(context).colorScheme;
    return GridView.builder(
      padding: const EdgeInsets.all(16),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        childAspectRatio: 0.78,
        crossAxisSpacing: 12,
        mainAxisSpacing: 12,
      ),
      itemCount: albums.length,
      itemBuilder: (_, i) {
        final album = albums[i];
        return Container(
          decoration: BoxDecoration(
            color: cs.surfaceContainerLow,
            borderRadius: BorderRadius.circular(16),
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ClipRRect(
                borderRadius: const BorderRadius.vertical(
                    top: Radius.circular(16)),
                child: _ArtworkThumbnail(
                  id: album.id,
                  type: ArtworkType.audio,
                  plugin: plugin,
                  size: 50,
                  radius: 0,
                  aspectRatio: 1,
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(10, 10, 10, 4),
                child: Text(
                  album.album,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                      fontWeight: FontWeight.w600, fontSize: 13),
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
                child: Text(
                  album.artist ?? 'Unknown Artist',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: TextStyle(
                    fontSize: 11,
                    color: cs.onSurface.withValues(alpha: 0.55),
                  ),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

// ─── Artist list ──────────────────────────────────────────────────────────────

class _ArtistList extends StatelessWidget {
  final List<ArtistModel> artists;
  const _ArtistList({required this.artists});

  @override
  Widget build(BuildContext context) {
    if (artists.isEmpty) return const _EmptyState(label: 'No artists found');
    final cs = Theme.of(context).colorScheme;
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 8),
      itemCount: artists.length,
      itemBuilder: (_, i) {
        final artist = artists[i];
        return ListTile(
          leading: CircleAvatar(
            backgroundColor: cs.primaryContainer,
            child: Text(
              artist.artist.isNotEmpty
                  ? artist.artist[0].toUpperCase()
                  : '?',
              style: TextStyle(
                color: cs.onPrimaryContainer,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
          title: Text(
            artist.artist,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
          ),
          subtitle: Text(
            '${artist.numberOfTracks ?? 0} tracks · '
            '${artist.numberOfAlbums ?? 0} albums',
            style: TextStyle(color: cs.onSurface.withValues(alpha: 0.55)),
          ),
        );
      },
    );
  }
}

// ─── Playlist list ────────────────────────────────────────────────────────────

class _PlaylistList extends StatelessWidget {
  final List<PlaylistModel> playlists;
  const _PlaylistList({required this.playlists});

  @override
  Widget build(BuildContext context) {
    if (playlists.isEmpty) {
      return const _EmptyState(label: 'No playlists found');
    }
    final cs = Theme.of(context).colorScheme;
    return ListView.builder(
      padding: const EdgeInsets.symmetric(vertical: 8),
      itemCount: playlists.length,
      itemBuilder: (_, i) {
        final playlist = playlists[i];
        return ListTile(
          leading: Container(
            width: 48,
            height: 48,
            decoration: BoxDecoration(
              gradient: LinearGradient(
                colors: [cs.primary, cs.tertiary],
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
              ),
              borderRadius: BorderRadius.circular(10),
            ),
            child: const Icon(Icons.queue_music_rounded,
                color: Colors.white, size: 24),
          ),
          title: Text(
            playlist.playlist,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
          ),
          subtitle: Text(
            '${playlist.numOfSongs ?? 0} songs',
            style: TextStyle(color: cs.onSurface.withValues(alpha: 0.55)),
          ),
        );
      },
    );
  }
}

// ─── Artwork thumbnail ────────────────────────────────────────────────────────

class _ArtworkThumbnail extends StatefulWidget {
  final int id;
  final ArtworkType type;
  final DeviceAudioQuery plugin;
  final double size;
  final double radius;
  final double? aspectRatio;

  const _ArtworkThumbnail({
    required this.id,
    required this.type,
    required this.plugin,
    required this.size,
    required this.radius,
    this.aspectRatio,
  });

  @override
  State<_ArtworkThumbnail> createState() => _ArtworkThumbnailState();
}

class _ArtworkThumbnailState extends State<_ArtworkThumbnail> {
  Uint8List? _bytes;

  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    try {
      final bytes = await widget.plugin.queryArtwork(
        id: widget.id,
        type: widget.type,
        size: 200,
      );
      if (mounted) setState(() { _bytes = bytes; });
    } catch (_) {
      // Ignore
    }
  }

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    Widget child;

    if (_bytes != null) {
      child = Image.memory(_bytes!, fit: BoxFit.cover);
    } else {
      child = Container(
        color: cs.surfaceContainerHighest,
        child: Icon(
          Icons.music_note_rounded,
          color: cs.onSurface.withValues(alpha: 0.3),
          size: 22,
        ),
      );
    }

    if (widget.aspectRatio != null) {
      child = AspectRatio(aspectRatio: widget.aspectRatio!, child: child);
    } else {
      child = SizedBox(width: widget.size.toDouble(),
          height: widget.size.toDouble(), child: child);
    }

    return ClipRRect(
      borderRadius: BorderRadius.circular(widget.radius),
      child: child,
    );
  }
}

// ─── Empty state ──────────────────────────────────────────────────────────────

class _EmptyState extends StatelessWidget {
  final String label;
  const _EmptyState({required this.label});

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.music_off_rounded,
              size: 64, color: cs.onSurface.withValues(alpha: 0.2)),
          const SizedBox(height: 16),
          Text(
            label,
            style: TextStyle(
              color: cs.onSurface.withValues(alpha: 0.4),
              fontSize: 15,
            ),
          ),
        ],
      ),
    );
  }
}

// ─── Gradient button ──────────────────────────────────────────────────────────

class _GradientButton extends StatelessWidget {
  final Widget child;
  final VoidCallback? onPressed;

  const _GradientButton({required this.child, this.onPressed});

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return GestureDetector(
      onTap: onPressed,
      child: AnimatedOpacity(
        opacity: onPressed == null ? 0.5 : 1.0,
        duration: const Duration(milliseconds: 200),
        child: Container(
          height: 54,
          decoration: BoxDecoration(
            gradient: LinearGradient(
              colors: [cs.primary, cs.tertiary],
            ),
            borderRadius: BorderRadius.circular(16),
            boxShadow: [
              BoxShadow(
                color: cs.primary.withValues(alpha: 0.4),
                blurRadius: 20,
                offset: const Offset(0, 8),
              ),
            ],
          ),
          alignment: Alignment.center,
          child: child,
        ),
      ),
    );
  }
}
1
likes
140
points
54
downloads

Documentation

API reference

Publisher

verified publisherbustify.dev

Weekly Downloads

A Flutter plugin to query audio files and their metadata from the device's storage. It provides an easy-to-use API for retrieving information about songs, albums, artists, and playlists, making it ideal for music player applications or any app that needs to access audio content on the device.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on device_audio_query

Packages that implement device_audio_query