Search code examples
flutterdartstatefulwidget

How can I conditionally change text color in a Tile during audio playback


I am learning Dart and making an app where you should hear an audio playback onTap on a piece of text (Tile). While playback is on, if another Tile is tapped, I want the color of the previous unfinished recording to return to default, and the new playback tile to change.

This is my app at the moment:

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


List<String> titles = <String>[
  'class1',
  'class2',
  'class3',
];

List<String> sentences = <String>[
  'sentence1',
  'sentence2',
  'sentence3',
  'sentence4'
];

List<String> audio = <String>[
  '1.mp3',
  '2.mp3',
  '3.mp3',
  '4.mp3'
];


void main() => runApp(const AppBarApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
          colorSchemeSeed: const Color(0xff6750a4), useMaterial3: true),
      home: const AppBarExample(),
    );
  }
}

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

  @override
  State<AppBarExample> createState() => _AppBarExampleState();

}

class _AppBarExampleState extends State<AppBarExample> {
  @override
  Widget build(BuildContext context) {
    ...//styling details ...
    bool? isCurrentlyPlaying;

    return DefaultTabController(
      ...//tabbar setup details ...
          children: <Widget>[
            ListView.builder(
              itemCount: sentences.length,
              itemBuilder:(BuildContext context, int index) {
                return ListTile(
                  tileColor: index.isOdd
                   ? oddItemColor : evenItemColor,
                  title: AudioPlayerWidget(index: index,
                  isCurrentlyPlaying: false,
                  onPlay: () {setState (() {isCurrentlyPlaying = true;});},
                  ),
                );
              },
            ),
            ...//other items in the ListView ...
              },
            ),
          ],
        ),
      ),
    );
  }
}

// AudioPlayer 
final audioPlayer = AudioPlayer();

class AudioPlayerWidget extends StatefulWidget {
  final int index;
  final VoidCallback onPlay;
  final bool isCurrentlyPlaying;
  AudioPlayerWidget ({Key ? key, required this.index, required this.onPlay, required this.isCurrentlyPlaying}) : super(key: key);

  @override 
  _AudioPlayerWidgetState createState() => _AudioPlayerWidgetState();

}

class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
  bool isPlaying = false;

  void _playAudio() {
    audioPlayer.stop();
    audioPlayer.play(AssetSource('audio/${widget.index+1}.mp3'));
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        widget.onPlay();
        _playAudio();
        },
        child: Text(sentences[widget.index],
        style: TextStyle(
          color: widget.isCurrentlyPlaying ? Colors.indigo.shade900 : Colors.black,
        )
        ),
      );
  }
}

The logic is as follows:

  • iscurrentlyPlaying by default = false
  • onTap, I trigger onPlay() via widget.onPlay
  • this sets state of "iscurrentlyplaying" to "true" for the given tapped tile,
  • TextStyle color is controlled by isCurrentlyPlaying ? indigo : black

My issue is that onPlay() does not get triggered as expected, so the color remains black (default), and I am unable to diagnose why.

I previously successfully implemented alternative logic where colors change as expected if audio is allowed to play until the end. It is the interrupt logic, where I effectively want to have a value of "iscurrentlyplaying" assigned to each tile, that does not work.


Solution

  • After a day or so of hitting my head against the wall I found a way to achieve what I needed. In my original code I think I was confusing the state of AudioPlayer widget and the ListView widget. As a result, I was defining currentlyPlaying twice, overwriting with false each time.

    Posting my solution here:

    1. Define currentlyPlayingIndex var to track which widget is playing
    final bool isCurrentlyPlaying;
    
    1. Define a onPlay as well as onStop callback functions This allows for changing text color once playback is completed. It is also the function that is called in case of interrupting the original playback by tapping on another element in the UI. This way the state which controls font color can be turned both on and off easily.
    onPlay: () {
      setState(() {
        isCurrentlyPlaying = true;
        currentlyPlayingIndex = index;
      });
    },
    onStop: () {
      setState(() {
        isCurrentlyPlaying = false;
        currentlyPlayingIndex = null;
      });
    },
    
    
    1. Use "and" to make sure isCurrentlyPlaying is only true when the tapped widget is playing, and automatically false otherwise
    isCurrentlyPlaying: isCurrentlyPlaying && currentlyPlayingIndex == index
    
    1. In _playAudio function, use onPlay rather than widget.onPlay, and make use of onStop as well
    void _playAudio() {
     audioPlayer.stop();
     onPlay();
     audioPlayer.play(AssetSource('audio/${index + 1}.mp3'));
     audioPlayer.onPlayerComplete.listen((event) {
      onStop();
    });
    

    Full code for reference

    import 'package:flutter/material.dart';
    import 'package:audioplayers/audioplayers.dart';
    
    List<String> titles = <String>[
      'class1',
      'class2',
      'class3',
    ];
    
    List<String> sentences = <String>[
      'sentence1',
      'sentence2',
      'sentence3',
      'sentence4'
    ];
    
    List<String> audio = <String>[
      '1.mp3',
      '2.mp3',
      '3.mp3',
      '4.mp3'
    ];
    
    void main() => runApp(const AppBarApp());
    
    class AppBarApp extends StatelessWidget {
      const AppBarApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          theme: ThemeData(
              colorSchemeSeed: const Color(0xff6750a4), useMaterial3: true),
          home: const AppBarExample(),
        );
      }
    }
    
    class AppBarExample extends StatefulWidget {
      const AppBarExample({super.key});
    
      @override
      State<AppBarExample> createState() => _AppBarExampleState();
    }
    
    class _AppBarExampleState extends State<AppBarExample> {
      // >> These vars control playback <<
      bool isCurrentlyPlaying = false;
      int? currentlyPlayingIndex;
    
      @override
      Widget build(BuildContext context) {
        //...styling goes here
    
        return DefaultTabController(
          //... styling etc.
          child: Scaffold(
            appBar: AppBar(
              title: const Text('My app'),
              //...styling etc.
            body: TabBarView(
              children: <Widget>[
                ListView.builder(
                  itemCount: sentences.length,
                 // >> Key widget playback logic <<
                  itemBuilder: (BuildContext context, int index) {
                    return ListTile(
                      tileColor: index.isOdd ? oddItemColor : evenItemColor,
                      title: AudioPlayerWidget(
                        index: index,
                        onPlay: () {
                          setState(() {
                            isCurrentlyPlaying = true;
                            currentlyPlayingIndex = index;
                          });
                        },
                        onStop: () {
                          setState(() {
                            isCurrentlyPlaying = false;
                            currentlyPlayingIndex = null;
                          });
                        },
                        isCurrentlyPlaying: isCurrentlyPlaying && currentlyPlayingIndex == index,
                      ),
                    );
                  },
                ),
                //...other ListView items (tiles)
    
    // >> audioplayer definitions <<
    final audioPlayer = AudioPlayer();
    
    class AudioPlayerWidget extends StatelessWidget {
      final int index;
      final VoidCallback onPlay;
      final VoidCallback onStop;
      final bool isCurrentlyPlaying;
      AudioPlayerWidget({Key? key, required this.index, required this.onPlay, required this.onStop, required this.isCurrentlyPlaying}) : super(key: key);
    
      void _playAudio() {
        // interrupt upon tap
        audioPlayer.stop();
        onPlay();
        audioPlayer.play(AssetSource('audio/${index + 1}.mp3'));
        // return to original color once playback finished
        audioPlayer.onPlayerComplete.listen((event) {
          onStop();
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: () {
            // >>conditionally kill the playback, and use `onStop` to update state and conditionally recolour font to default<<
            if (isCurrentlyPlaying) {
              audioPlayer.stop();
              onStop();
            } else {
              _playAudio();
            }
          },
          child: Text(
            sentences[index],
            style: >> font conditional coloring <<
              color: isCurrentlyPlaying ? Colors.orange : Colors.blue,
            ),
          ),
        );
      }
    }
    
    

    I hope this helps somebody :)