spotikit 1.0.0 copy "spotikit: ^1.0.0" to clipboard
spotikit: ^1.0.0 copied to clipboard

PlatformAndroid

Flutter plugin for Spotify integration with Android App Remote and Web API support.

example/lib/main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:spotikit/models/auth_state.dart';
import 'package:spotikit/models/spotify/playback_state.dart';
import 'package:spotikit/spotikit.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load();
  runApp(const SpotikitExampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Spotikit Demo',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1DB954), // Spotify green
          brightness: Brightness.dark,
        ),
      ),
      home: const SpotikitHomePage(),
    );
  }
}

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

  @override
  State<SpotikitHomePage> createState() => _SpotikitHomePageState();
}

class _SpotikitHomePageState extends State<SpotikitHomePage> {
  // Spotikit instance
  final Spotikit _spotikit = Spotikit.instance;

  // Stream subscriptions
  StreamSubscription<AuthState>? _authSub;
  StreamSubscription<SpotifyPlaybackState>? _playbackSub;

  // State variables
  AuthState? _authState;
  SpotifyPlaybackState? _playbackState;
  bool _isInitialized = false;
  bool _isRemoteConnected = false;
  bool _isLoading = false;

  // Controllers
  final TextEditingController _searchController = TextEditingController();
  final TextEditingController _uriController = TextEditingController(
    text: 'spotify:track:4cOdK2wGLETKBW3PvgPWqT', // Never Gonna Give You Up
  );

  // Progress ticker for smooth playback progress
  Timer? _progressTicker;

  @override
  void initState() {
    super.initState();
    _setupListeners();
    _initialize();
  }

  /// Sets up auth and playback state listeners
  void _setupListeners() {
    _authSub = _spotikit.onAuthStateChanged.listen(_handleAuthState);
    _playbackSub = _spotikit.onPlaybackStateChanged.listen((state) {
      setState(() => _playbackState = state);
    });
  }

  /// Handles authentication state changes
  void _handleAuthState(AuthState state) {
    setState(() => _authState = state);

    switch (state) {
      case AuthSuccess():
        _showSnackBar('✓ Authenticated successfully', isSuccess: true);
        _connectToRemote();
      case AuthFailure(:final error, :final message):
        _showSnackBar('Authentication failed: ${message ?? error}');
      case AuthCancelled():
        _showSnackBar('Authentication cancelled');
    }
  }

  /// Initialize Spotikit with credentials from .env
  Future<void> _initialize() async {
    if (_isInitialized) return;

    setState(() => _isLoading = true);

    try {
      //TODO: PASTE YOUR CREDENTIALS IN .env FILE OR REPLACE BELOW WITH STRINGS
      final clientId = dotenv.env['SPOTIFY_CLIENT_ID'] ?? '';
      final redirectUri = dotenv.env['SPOTIFY_REDIRECT_URI'] ?? '';
      final clientSecret = dotenv.env['SPOTIFY_CLIENT_SECRET'] ?? '';

      if (clientId.isEmpty || redirectUri.isEmpty || clientSecret.isEmpty) {
        _showSnackBar('Missing credentials in .env file');
        return;
      }

      _spotikit.configureLogging(loggingEnabled: true);

      await _spotikit.initialize(
        clientId: clientId,
        redirectUri: redirectUri,
        clientSecret: clientSecret,
      );

      setState(() => _isInitialized = true);
      await _spotikit.authenticateSpotify();
    } catch (e) {
      _showSnackBar('Initialization failed: $e');
    } finally {
      setState(() => _isLoading = false);
    }
  }

  /// Connect to Spotify App Remote
  Future<void> _connectToRemote() async {
    setState(() => _isLoading = true);
    try {
      final connected = await _spotikit.connectToSpotify();
      setState(() => _isRemoteConnected = connected);
      if (connected) {
        _showSnackBar('✓ Connected to Spotify', isSuccess: true);
        _startProgressTicker();
      } else {
        _showSnackBar('Failed to connect to Spotify');
      }
    } finally {
      setState(() => _isLoading = false);
    }
  }

  /// Starts a timer to update progress bar smoothly
  void _startProgressTicker() {
    _progressTicker?.cancel();
    _progressTicker = Timer.periodic(const Duration(milliseconds: 200), (_) {
      if (_playbackState != null && !_playbackState!.isPaused) {
        setState(() {});
      }
    });
  }

  /// Shows a snackbar with the given message
  void _showSnackBar(String message, {bool isSuccess = false}) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: isSuccess ? Colors.green.shade700 : null,
        behavior: SnackBarBehavior.floating,
        duration: const Duration(seconds: 2),
      ),
    );
  }

  // Playback Controls
  Future<void> _togglePlayPause() async {
    if (_playbackState == null) return;
    if (_playbackState!.isPaused) {
      await _spotikit.resume();
    } else {
      await _spotikit.pause();
    }
  }

  Future<void> _seekTo(double value) async {
    if (_playbackState == null) return;
    final positionMs = (value * _playbackState!.durationMs).round();
    await _spotikit.seekTo(positionMs: positionMs);
  }

  Future<void> _playUri() async {
    final uri = _uriController.text.trim();
    if (uri.isEmpty) {
      _showSnackBar('Please enter a Spotify URI');
      return;
    }
    await _spotikit.playUri(spotifyUri: uri);
  }

  Future<void> _searchAndPlay() async {
    final query = _searchController.text.trim();
    if (query.isEmpty) {
      _showSnackBar('Please enter a search query');
      return;
    }
    setState(() => _isLoading = true);
    try {
      await _spotikit.playSong(query: query);
    } finally {
      setState(() => _isLoading = false);
    }
  }

  @override
  void dispose() {
    _authSub?.cancel();
    _playbackSub?.cancel();
    _progressTicker?.cancel();
    _searchController.dispose();
    _uriController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: _isLoading && !_isInitialized
            ? const _LoadingView()
            : CustomScrollView(
                slivers: [
                  _buildAppBar(),
                  SliverPadding(
                    padding: const EdgeInsets.all(16),
                    sliver: SliverList.list(
                      children: [
                        _buildConnectionStatus(),
                        const SizedBox(height: 20),
                        if (_playbackState != null) ...[
                          _buildNowPlaying(),
                          const SizedBox(height: 20),
                        ],
                        _buildSearchSection(),
                        const SizedBox(height: 16),
                        _buildPlayByUriSection(),
                        const SizedBox(height: 24),
                        _buildQuickActions(),
                      ],
                    ),
                  ),
                ],
              ),
      ),
    );
  }

  Widget _buildAppBar() {
    return SliverAppBar(
      floating: true,
      title: Row(
        children: [
          Icon(Icons.music_note, color: Theme.of(context).colorScheme.primary),
          const SizedBox(width: 8),
          const Text('Spotikit Demo'),
        ],
      ),
      actions: [
        if (_isLoading)
          const Padding(
            padding: EdgeInsets.all(16),
            child: SizedBox(
              width: 20,
              height: 20,
              child: CircularProgressIndicator(strokeWidth: 2),
            ),
          ),
        IconButton(
          icon: Icon(
            _isRemoteConnected ? Icons.bluetooth_connected : Icons.bluetooth,
            color: _isRemoteConnected ? Colors.green : null,
          ),
          tooltip: _isRemoteConnected ? 'Connected' : 'Reconnect',
          onPressed: _connectToRemote,
        ),
        PopupMenuButton(
          itemBuilder: (context) => [
            PopupMenuItem(
              onTap: () => _spotikit.logout(),
              child: const Row(
                children: [
                  Icon(Icons.logout),
                  SizedBox(width: 8),
                  Text('Logout'),
                ],
              ),
            ),
            PopupMenuItem(
              onTap: () => _spotikit.disconnect(),
              child: const Row(
                children: [
                  Icon(Icons.link_off),
                  SizedBox(width: 8),
                  Text('Disconnect'),
                ],
              ),
            ),
          ],
        ),
      ],
    );
  }

  Widget _buildConnectionStatus() {
    final authStatus = switch (_authState) {
      AuthSuccess() => ('Authenticated', Colors.green),
      AuthFailure() => ('Auth Failed', Colors.red),
      AuthCancelled() => ('Auth Cancelled', Colors.orange),
      _ => ('Not Authenticated', Colors.grey),
    };

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            _StatusIndicator(
              label: 'Auth',
              status: authStatus.$1,
              color: authStatus.$2,
            ),
            const SizedBox(width: 24),
            _StatusIndicator(
              label: 'Remote',
              status: _isRemoteConnected ? 'Connected' : 'Disconnected',
              color: _isRemoteConnected ? Colors.green : Colors.grey,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildNowPlaying() {
    final ps = _playbackState!;
    final progress = ps.progress.clamp(0.0, 1.0);

    return Card(
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Album art and track info
          Row(
            children: [
              // Album artwork
              Container(
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  color: Theme.of(context).colorScheme.surfaceContainerHighest,
                ),
                child: ps.imageUrl != null
                    ? Image.network(
                        ps.imageUrl!,
                        fit: BoxFit.cover,
                        errorBuilder: (_, __, ___) =>
                            const Icon(Icons.album, size: 48),
                      )
                    : const Icon(Icons.album, size: 48),
              ),
              const SizedBox(width: 16),
              // Track info
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          Container(
                            padding: const EdgeInsets.symmetric(
                              horizontal: 8,
                              vertical: 4,
                            ),
                            decoration: BoxDecoration(
                              color: ps.isPaused
                                  ? Colors.orange.withValues(alpha: 0.2)
                                  : Colors.green.withValues(alpha: 0.2),
                              borderRadius: BorderRadius.circular(12),
                            ),
                            child: Text(
                              ps.isPaused ? 'PAUSED' : 'NOW PLAYING',
                              style: TextStyle(
                                fontSize: 10,
                                fontWeight: FontWeight.bold,
                                color: ps.isPaused
                                    ? Colors.orange
                                    : Colors.green,
                              ),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 8),
                      Text(
                        ps.name,
                        style: Theme.of(context).textTheme.titleMedium
                            ?.copyWith(fontWeight: FontWeight.bold),
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        ps.artist,
                        style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                          color: Theme.of(
                            context,
                          ).colorScheme.onSurface.withValues(alpha: 0.7),
                        ),
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
          // Progress bar
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Column(
              children: [
                SliderTheme(
                  data: SliderTheme.of(context).copyWith(
                    trackHeight: 4,
                    thumbShape: const RoundSliderThumbShape(
                      enabledThumbRadius: 6,
                    ),
                  ),
                  child: Slider(
                    value: progress.isNaN ? 0 : progress,
                    onChanged: _seekTo,
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 8),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(
                        _formatDuration(ps.positionMs),
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                      Text(
                        _formatDuration(ps.durationMs),
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(height: 8),
          // Playback controls
          Padding(
            padding: const EdgeInsets.only(bottom: 16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                IconButton(
                  icon: const Icon(Icons.replay_10),
                  tooltip: 'Rewind 10s',
                  onPressed: () => _spotikit.skipBackward(seconds: 10),
                ),
                IconButton(
                  icon: const Icon(Icons.skip_previous),
                  iconSize: 36,
                  tooltip: 'Previous',
                  onPressed: () => _spotikit.previousTrack(),
                ),
                const SizedBox(width: 8),
                FilledButton(
                  onPressed: _togglePlayPause,
                  style: FilledButton.styleFrom(
                    shape: const CircleBorder(),
                    padding: const EdgeInsets.all(16),
                  ),
                  child: Icon(
                    ps.isPaused ? Icons.play_arrow : Icons.pause,
                    size: 32,
                  ),
                ),
                const SizedBox(width: 8),
                IconButton(
                  icon: const Icon(Icons.skip_next),
                  iconSize: 36,
                  tooltip: 'Next',
                  onPressed: () => _spotikit.skipTrack(),
                ),
                IconButton(
                  icon: const Icon(Icons.forward_10),
                  tooltip: 'Forward 10s',
                  onPressed: () => _spotikit.skipForward(seconds: 10),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSearchSection() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Search & Play',
              style: Theme.of(
                context,
              ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: 'Artist, song, or album...',
                prefixIcon: const Icon(Icons.search),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.play_arrow),
                  onPressed: _searchAndPlay,
                ),
              ),
              textInputAction: TextInputAction.search,
              onSubmitted: (_) => _searchAndPlay(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildPlayByUriSection() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Play by URI',
              style: Theme.of(
                context,
              ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _uriController,
              decoration: InputDecoration(
                hintText: 'spotify:track:...',
                prefixIcon: const Icon(Icons.link),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                suffixIcon: IconButton(
                  icon: const Icon(Icons.play_arrow),
                  onPressed: _playUri,
                ),
              ),
              onSubmitted: (_) => _playUri(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildQuickActions() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Quick Play',
              style: Theme.of(
                context,
              ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _QuickPlayChip(
                  label: 'Bohemian Rhapsody',
                  onTap: () =>
                      _spotikit.playSong(query: 'Bohemian Rhapsody Queen'),
                ),
                _QuickPlayChip(
                  label: 'Blinding Lights',
                  onTap: () =>
                      _spotikit.playSong(query: 'Blinding Lights The Weeknd'),
                ),
                _QuickPlayChip(
                  label: 'Shape of You',
                  onTap: () =>
                      _spotikit.playSong(query: 'Shape of You Ed Sheeran'),
                ),
                _QuickPlayChip(
                  label: 'Bad Guy',
                  onTap: () =>
                      _spotikit.playSong(query: 'Bad Guy Billie Eilish'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  String _formatDuration(int ms) {
    final duration = Duration(milliseconds: ms);
    final minutes = duration.inMinutes;
    final seconds = duration.inSeconds % 60;
    return '$minutes:${seconds.toString().padLeft(2, '0')}';
  }
}

// Helper Widgets

class _LoadingView extends StatelessWidget {
  const _LoadingView();

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CircularProgressIndicator(),
          SizedBox(height: 16),
          Text('Initializing Spotikit...'),
        ],
      ),
    );
  }
}

class _StatusIndicator extends StatelessWidget {
  final String label;
  final String status;
  final Color color;

  const _StatusIndicator({
    required this.label,
    required this.status,
    required this.color,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: Theme.of(context).textTheme.bodySmall?.copyWith(
            color: Theme.of(
              context,
            ).colorScheme.onSurface.withValues(alpha: 0.6),
          ),
        ),
        const SizedBox(height: 4),
        Row(
          children: [
            Container(
              width: 8,
              height: 8,
              decoration: BoxDecoration(color: color, shape: BoxShape.circle),
            ),
            const SizedBox(width: 6),
            Text(
              status,
              style: Theme.of(
                context,
              ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
            ),
          ],
        ),
      ],
    );
  }
}

class _QuickPlayChip extends StatelessWidget {
  final String label;
  final VoidCallback onTap;

  const _QuickPlayChip({required this.label, required this.onTap});

  @override
  Widget build(BuildContext context) {
    return ActionChip(
      avatar: const Icon(Icons.play_arrow, size: 18),
      label: Text(label),
      onPressed: onTap,
    );
  }
}
0
likes
160
points
226
downloads

Publisher

verified publisherardakoksal.tech

Weekly Downloads

Flutter plugin for Spotify integration with Android App Remote and Web API support.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

dio, flutter, http, logger, path, plugin_platform_interface, yaml

More

Packages that depend on spotikit

Packages that implement spotikit