Who remembers good old audio tapes? In my opinion although they are technically outdated, they are still very fascinating. Compared to modern media, one could actually see what’s happening inside.
To appreciate this awesome invention, let’s implement an actually working audio player that mimics the visuals and behavior of a good old tape!
The goal
The above animation describes our goal better than 1000 words, but let’s define the features more formally:
- There is a tape at the top and a control panel below
- One can choose an audio file from the phone by tapping the eject button (the one on the right)
- The title and author are extracted from the meta information and written on the blank label of the tape
- When a song is chosen and the play button is tapped, the pins of the tape rotate counter-clockwise (and the song actually plays)
- The left tape reel shrink whereas the right tape reel grow according to the current position of the song
- The pause button just pauses the song. When tapping “play” afterwards, it’s possible to resume
- The stop button rewinds the song to the beginning
- A button of the control panel indicates being tapped by shrinking. When one button is tapped, every other button goes back to the original size
The implementation
We are going to start with the basic skeleton: a Tape
widget. This widget will have the responsibility of painting the tape using the parent’s space.
1class Tape extends StatefulWidget {
2 @override
3 _TapeState createState() => _TapeState();
4}
5
6class _TapeState extends State<Tape> with SingleTickerProviderStateMixin {
7 AnimationController _controller;
8
9 @override
10 void initState() {
11 super.initState();
12
13 _controller = new AnimationController(
14 duration: const Duration(milliseconds: 2000),
15 vsync: this
16 );
17
18 Tween<double> tween = Tween<double>(
19 begin: 0.0, end: 1.0
20 );
21
22 tween.animate(_controller);
23 }
24
25 @override
26 void dispose() {
27 _controller.dispose();
28 super.dispose();
29 }
30
31 @override
32 Widget build(BuildContext context) {
33 return Column(
34 mainAxisSize: MainAxisSize.min,
35 children: [
36 SizedBox(
37 width: 300,
38 height: 200,
39 child: AnimatedBuilder(
40 builder: (BuildContext context, Widget child) {
41 return CustomPaint(
42 painter: TapePainter(),
43 );
44 },
45 animation: _controller,
46 ),
47 ),
48 ],
49 );
50 }
51
52 void stop() {
53 }
54
55 void pause() {
56 }
57
58 void play() {
59 }
60
61 void choose() {
62 }
63}
This is the first basic iteration of our Tape class. We already know we want to rotate the pins to make it look like the cassette is being played. That’s why we initialize an AnimationController
with a duration of 2 seconds and attach a tween animating a double from 0.0 to 1.0 that represents the amount of rotation.
The build
method returns a SizedBox
with a width ratio of 3:2 whose child is an AnimatedBuilder
containing our CustomPaint
. AnimatedBuilder
because we want our tape to be repainted every time the animation is updated so that we have the rotating pins at the center.
Drawing the tape
The code won’t compile because we’re missing the actual TapePainter
. Let’s take care of that.
1class TapePainter extends CustomPainter {
2 double holeRadius;
3 Offset leftHolePosition;
4 Offset rightHolePosition;
5 Path leftHole;
6 Path rightHole;
7 Path centerWindowPath;
8
9 Paint paintObject;
10 Size size;
11 Canvas canvas;
12
13 @override
14 void paint(Canvas canvas, Size size) {
15 this.size = size;
16 this.canvas = canvas;
17
18 holeRadius = size.height / 12;
19 paintObject = Paint();
20
21 _initHoles();
22 _initCenterWindow();
23 }
24
25
26 void _initCenterWindow() {
27 Rect centerWindow = Rect.fromLTRB(size.width * 0.4, size.height * 0.37, size.width * 0.6, size.height * 0.55);
28 centerWindowPath = Path()..addRect(centerWindow);
29 }
30
31 void _initHoles() {
32 leftHolePosition = Offset(size.width * 0.3, size.height * 0.46);
33 rightHolePosition = Offset(size.width * 0.7, size.height * 0.46);
34
35 leftHole = Path()..addOval(
36 Rect.fromCircle(
37 center: leftHolePosition,
38 radius: holeRadius
39 )
40 );
41
42 rightHole = Path()..addOval(
43 Rect.fromCircle(
44 center: rightHolePosition,
45 radius: holeRadius
46 )
47 );
48 }
49
50 @override
51 bool shouldRepaint(CustomPainter oldDelegate) {
52 return true;
53 }
54}
Before we actually draw the cassette, we need to initialize certain things. That’s because we want to extract the draw process of every part of the tape into a single method to keep the code readable and maintainable. Because we don’t want to provide the very same arguments over and over again to every method, we initialize them at the beginning and make them member variables of the class.
We let the position of the holes depend on the width we have. This way, we prevent a completely static painting. The center window also depends on the measurements we’re given.
The holes and their radii are needed for drawing several parts because the holes will be cut through them. Same for the window.
1_drawTape() {
2 RRect tape = RRect.fromRectAndRadius(
3 Rect.fromLTRB(0, 0, size.width, size.height),
4 Radius.circular(16)
5 );
6
7 Path tapePath = Path()..addRRect(tape);
8
9 tapePath = Path.combine(PathOperation.difference, tapePath, leftHole);
10 tapePath = Path.combine(PathOperation.difference, tapePath, rightHole);
11 tapePath = Path.combine(PathOperation.difference, tapePath, centerWindowPath);
12
13 canvas.drawShadow(tapePath, Colors.black, 3.0, false);
14 paintObject.color = Colors.black;
15 paintObject.color = Color(0xff522f19).withOpacity(0.8);
16 canvas.drawPath(tapePath, paintObject);
17}
We start by adding a new private method called _drawTape
that actually draws the tape which is nothing else but a rounded rectangle covering the hole size with the holes and the window being cut into the shape. We also draw a shadow to make it look a little bit more realistic.
1...
2Path _cutCenterWindowIntoPath(Path path) {
3 return Path.combine(PathOperation.difference, path, centerWindowPath);
4}
5
6_cutHolesIntoPath(Path path) {
7 path = Path.combine(PathOperation.difference, path, leftHole);
8 path = Path.combine(PathOperation.difference, path, rightHole);
9
10 return path;
11}
12...
Because we are going to need the same cutting algorithm for other parts of the tape as well, we are extracting it into separate methods called _cutCenterWindowIntoPath()
and _cutHolesIntoPath()
. Then we replace the Path.combine
calls by our new methods.
Next, we are going to paint the label on top of the tape.
1void _drawLabel() {
2 double labelPadding = size.width * 0.05;
3 Rect label = Rect.fromLTWH(labelPadding, labelPadding, size.width - labelPadding * 2, size.height * 0.7);
4 Path labelPath = Path()..addRect(label);
5 labelPath = _cutHolesIntoPath(labelPath);
6 labelPath = _cutCenterWindowIntoPath(labelPath);
7
8 Rect labelTop = Rect.fromLTRB(label.left, label.top + label.height * 0.2, label.right, label.bottom - label.height * 0.1);
9 Path labelTopPath = Path()..addRect(labelTop);
10 labelTopPath = _cutHolesIntoPath(labelTopPath);
11 labelTopPath = _cutCenterWindowIntoPath(labelTopPath);
12
13 paintObject.color = Color(0xffd3c5ae);
14 canvas.drawPath(labelPath, paintObject);
15 paintObject.color = Colors.red;
16 canvas.drawPath(labelTopPath, paintObject);
17}
The label consists of a grayish rectangle that provides some space for the actual text label describing the current song at the top and a red body. The label also needs to be cut at the position of the holes and the window.
It’s starting to look like an actual cassette. Let’s add the window which is nothing but a hole yet. On top of that, let’s add this black rectangle that is a typical visual part of an audio tape.
1void _drawCenterWindow() {
2 paintObject.color = Colors.black38;
3 canvas.drawPath(centerWindowPath, paintObject);
4}
5
6void _drawBlackRect() {
7 Rect blackRect = Rect.fromLTWH(size.width * 0.2, size.height * 0.31, size.width * 0.6, size.height * 0.3);
8 Path blackRectPath = Path()
9 ..addRRect(
10 RRect.fromRectXY(blackRect, 4, 4)
11 );
12
13 blackRectPath = Path.combine(PathOperation.difference, blackRectPath, leftHole);
14 blackRectPath = Path.combine(PathOperation.difference, blackRectPath, rightHole);
15 blackRectPath = _cutCenterWindowIntoPath(blackRectPath);
16
17 paintObject.color = Colors.black.withOpacity(0.8);
18 canvas.drawPath(blackRectPath, paintObject);
19}
Since we already have the path of the window, drawing a semi-transparent rectangle on top of that is an easy task.
The rectangle is also a very basic shape. Just a rounded rectangle whose dimensions are defined by fractions of the given size. The holes and the window need to be cut here as well.
We’re almost done with painting the basic components of the cassette. A tiny detail that’s still missing are the circles in which the pins will be rotating. So let’s indicate that by painting a white ring where the holes are located.
1void _drawHoleRings() {
2 Path leftHoleRing = Path()..addOval(
3 Rect.fromCircle(
4 center: leftHolePosition,
5 radius: holeRadius * 1.1
6 )
7 );
8
9 Path rightHoleRing = Path()..addOval(
10 Rect.fromCircle(
11 center: rightHolePosition,
12 radius: holeRadius * 1.1
13 )
14 );
15
16 leftHoleRing = Path.combine(PathOperation.difference, leftHoleRing, leftHole);
17 rightHoleRing = Path.combine(PathOperation.difference, rightHoleRing, rightHole);
18
19 paintObject.color = Colors.white;
20 canvas.drawPath(leftHoleRing, paintObject);
21 canvas.drawPath(rightHoleRing, paintObject);
22}
Nothing really special here. We take the hole positions we initialized at the beginning and draw circles that have a slightly higher radius than the holes and then we subtract the holes from these shapes which results in only the outlines.
Now we’re done with the static part of the tape. The dynamic parts are: the rotating pins, the tape reels and the text label showing author and title.
Adding animated parts
We’re going to prepare the tape so that it expects values that are going to be defined by the animation later but can be static for now.
1...
2AnimatedBuilder(
3 builder: (BuildContext context, Widget child) {
4 return CustomPaint(
5 painter: TapePainter(
6 rotationValue: _controller.value,
7 title: 'Rick Astley - Never Gonna Give You Up',
8 progress: 0
9 ),
10 );
11 },
12 animation: _controller,
13),
14...
15class TapePainter extends CustomPainter {
16 TapePainter({
17 @required this.rotationValue,
18 @required this.title,
19 @required this.progress,
20 });
21
22 double rotationValue;
23 String title;
24 double progress;
25...
Let’s take care of the label first as it’s the easiest part.
1void _drawTextLabel() {
2 TextSpan span = new TextSpan(style: new TextStyle(color: Colors.black), text: title);
3 TextPainter textPainter = TextPainter(
4 text: span,
5 textDirection: TextDirection.ltr,
6 textAlign: TextAlign.center
7 );
8
9 double labelPadding = size.width * 0.05;
10
11 textPainter.layout(
12 minWidth: 0,
13 maxWidth: size.width - labelPadding * 2,
14 );
15
16 final offset = Offset(
17 (size.width - textPainter.width) * 0.5,
18 (size.height - textPainter.height) * 0.12
19 );
20
21 textPainter.paint(canvas, offset);
22}
We are using a TextPainter
to achieve what we want. We let the text be drawn across the width of the label with a little padding. The text has a center alignment.
Next, we’ll take care of the rotating pins.
1void _drawTapePins() {
2 paintObject.color = Colors.white;
3 final int pinCount = 8;
4
5 for (var i = 0; i < pinCount; i++) {
6 _drawTapePin(leftHolePosition, rotationValue + i / pinCount);
7 _drawTapePin(rightHolePosition, rotationValue + i / pinCount);
8 }
9}
10
11void _drawTapePin(Offset center, double angle) {
12 _drawRotated(Offset(center.dx, center.dy), -angle, () {
13 canvas.drawRect(
14 Rect.fromLTWH(
15 center.dx - 2,
16 center.dy - holeRadius,
17 4,
18 holeRadius / 4,
19 ),
20 paintObject
21 );
22 });
23}
24
25void _drawRotated(Offset center, double angle, Function drawFunction) {
26 canvas.save();
27 canvas.translate(center.dx, center.dy);
28 canvas.rotate(angle * pi * 2);
29 canvas.translate(-center.dx, -center.dy);
30 drawFunction();
31 canvas.restore();
32}
This one is a little bit trickier so I’m gonna explain everything step by step.
In general, we need the possibility to draw something being rotated by a certain angle. Then we can draw the same pin several times but each time rotated a little bit more around the center which in sum generates the whole. In order to do that, we add a new method called _drawRotated()
which rotates the canvas before drawing in order to achieve the effect of something being drawn rotated by a given angle.
If you want to know more about rotating objects on canvas, read the respective article about rotation on canvas.
_drawTapePin()
draws a pin which is nothing more than a rectangle going from the (given) center up. The height is the holeRadius
we initialized earlier. We use minus angle because we want it to rotate counter-clockwise.
The _drawTapePin()
method is called as often as the pin count (in our case 8). In the loop the rotation value is multiplied with the current loop index divided by the pin count. This way, the pins are evenly spread across the circle.
The last dynamic parts are the moving tape reels.
1void _drawTapeReels() {
2 Path leftTapeRoll = Path()..addOval(
3 Rect.fromCircle(
4 center: leftHolePosition,
5 radius: holeRadius * (1 - progress) * 5
6 )
7 );
8
9 Path rightTapeRoll = Path()..addOval(
10 Rect.fromCircle(
11 center: rightHolePosition,
12 radius: holeRadius * progress * 5
13 )
14 );
15
16 leftTapeRoll = Path.combine(PathOperation.difference, leftTapeRoll, leftHole);
17 rightTapeRoll = Path.combine(PathOperation.difference, rightTapeRoll, rightHole);
18
19 paintObject.color = Colors.black;
20 canvas.drawPath(leftTapeRoll, paintObject);
21 canvas.drawPath(rightTapeRoll, paintObject);
22}
It’s simple: we just draw two black circles that mimic the tape reels. The radius of the circles directly depend on the progress argument we added earlier. It’s supposed to range from 0 to 1. The left one’s radius is defined by (1 - progress)
, which makes it shrink, the right one just uses progress
which makes it grow.
Adding the control panel
Now we have a tape that can theoretically animate based on a song that is played. Unfortunately, there is no user interaction possible yet because we have no UI parts that support it. Next step: adding a control panel.
1class TapeButton extends StatelessWidget {
2 TapeButton({
3 @required this.icon,
4 @required this.onTap,
5 this.isTapped = false
6 });
7
8 final IconData icon;
9 final Function onTap;
10 final bool isTapped;
11
12 @override
13 Widget build(BuildContext context) {
14 return GestureDetector(
15 child: Container(
16 width: isTapped ? 53.2 : 56,
17 height: isTapped ? 60.8 : 64,
18 decoration: BoxDecoration(
19 color: Colors.black,
20 borderRadius: BorderRadius.all(Radius.circular(8))
21 ),
22 child: Center(
23 child: Icon(icon, color: Colors.white)
24 ),
25 ),
26 onTap: onTap
27 );
28 }
29}
First, we need a tappable button. Whether it’s tapped or not will be determined by the parent widget because we want only one button to be tapped at a time. Once another button is tapped, every other button is untapped. Being tapped is visualized by a smaller button.
1enum TapeStatus { initial, playing, pausing, stopping, choosing }
2
3class Tape extends StatefulWidget {
4 @override
5 _TapeState createState() => _TapeState();
6}
7
8class _TapeState extends State<Tape> with SingleTickerProviderStateMixin {
9 AnimationController _controller;
10 TapeStatus _status = TapeStatus.initial;
11 ...
12 @override
13 Widget build(BuildContext context) {
14 return Column(
15 mainAxisSize: MainAxisSize.min,
16 children: [
17 SizedBox(
18 width: 300,
19 height: 200,
20 child: AnimatedBuilder(
21 builder: (BuildContext context, Widget child) {
22 return CustomPaint(
23 painter: TapePainter(
24 rotationValue: _controller.value,
25 title: 'Rick Astley - Never Gonna Give You Up',
26 progress: 0
27 ),
28 );
29 },
30 animation: _controller,
31 ),
32 ),
33 SizedBox(height: 40),
34 Row(
35 mainAxisAlignment: MainAxisAlignment.center,
36 children: [
37 TapeButton(icon: Icons.play_arrow, onTap: play, isTapped: _status == TapeStatus.playing),
38 SizedBox(width: 8),
39 TapeButton(icon: Icons.pause, onTap: pause, isTapped: _status == TapeStatus.pausing),
40 SizedBox(width: 8),
41 TapeButton(icon: Icons.stop, onTap: stop, isTapped: _status == TapeStatus.stopping),
42 SizedBox(width: 8),
43 TapeButton(icon: Icons.eject, onTap: choose, isTapped: _status == TapeStatus.choosing),
44 ],
45 )
46 ],
47 );
48 }
We create a new enum that holds the different possible states. We use this enum to trigger the isTapped
argument of the constructors of the TapeButton
widget we have just created.
Awesome, we have a control panel now. What’s left is to give the buttons actual functionality.
1dependencies:
2 flutter:
3 sdk: flutter
4 file_picker: ^1.13.3
5 audioplayers: ^0.15.1
6 flutter_ffmpeg: ^0.2.10
For this, we’re going to need three new dependencies. file_picker
is used to let the user pick an audio file when the eject button is tapped. audioplayers
lets us play, pause and stop the audio file. flutter_ffmpeg
can extract meta information from a file. This is necessary because we want to display the author and the title of the current song.
1import 'package:audioplayers/audioplayers.dart';
2import 'package:file_picker/file_picker.dart';
3import 'package:flutter/material.dart';
4import 'package:flutter_retro_audioplayer/tape_button.dart';
5import 'package:flutter_ffmpeg/flutter_ffmpeg.dart';
6...
7class _TapeState extends State<Tape> with SingleTickerProviderStateMixin {
8 AnimationController _controller;
9 TapeStatus _status = TapeStatus.initial;
10 AudioPlayer _audioPlayer;
11 String _url;
12 String _title;
13 double _currentPosition = 0.0;
14...
15 void stop() {
16 setState(() {
17 _status = TapeStatus.stopping;
18 _currentPosition = 0.0;
19 });
20 _controller.stop();
21 _audioPlayer.stop();
22 }
23
24 void pause() {
25 setState(() {
26 _status = TapeStatus.pausing;
27 });
28 _controller.stop();
29 _audioPlayer.pause();
30 }
31
32 void play() async {
33 if (_url == null) {
34 return;
35 }
36
37 setState(() {
38 _status = TapeStatus.playing;
39 });
40 _controller.repeat();
41 _audioPlayer.play(_url);
42 }
Stop, Play and Pause are fairly simple compared to the choose()
method so we start with them.
By tapping “stop” we want the status to be TapeStatus.stopping
. That will make every other button release. We set _currentPosition
to 0 which influences our visual audio tape and makes the tape reels reset. We want the _controller
to stop. This stops the animation of rotating pins. Lastly, we let the audio playback stop.
Pause behaves accordingly with the difference of not setting everything to 0 so that the playback can be resumed from there.
Play stats the animation and the playback. It has a check for _url
which is the file url that is returned when picking a file. If no file is there then no playback should happen. It starts _controller.repeat()
because we want the animation of the pins to be ongoing.
1choose() async {
2 stop();
3
4 setState(() {
5 _status = TapeStatus.choosing;
6 });
7 File file = await FilePicker.getFile(
8 type: FileType.audio
9 );
10
11 _url = file.path;
12 _audioPlayer.setUrl(_url);
13
14 final FlutterFFprobe _flutterFFprobe = new FlutterFFprobe();
15 Map<dynamic, dynamic> mediaInfo = await _flutterFFprobe.getMediaInformation(_url);
16
17 String title = mediaInfo['metadata']['title'];
18 String artist = mediaInfo['metadata']['artist'];
19 int duration = mediaInfo['duration'];
20
21 _audioPlayer.onPlayerCompletion.listen((event) {
22 stop();
23 });
24
25 _audioPlayer.onAudioPositionChanged.listen((event) {
26 _currentPosition = event.inMilliseconds / duration;
27 });
28
29 setState(() {
30 _title = "$artist - $title";
31 _status = TapeStatus.initial;
32 });
33}
The executing of the choose method starts with executing stop()
. That’s because we want everything to be reset before we play the new song. We then use FilePicker
to let the user pick a file. The resulting url is set as a member variable and then used to gather media information using FlutterFFprobe
. We then start listening to two events: when the playback is completed, the tape should rewind. That’s done by calling stop()
. Whenever the position is changed, the _currentPosition
variable should be updated with a relative value from 0 to 1. This is necessary for the painting of our tape. Lastly, the _title
string is updated with actual metadata from the audio file.
Now that we have any dynamic value available, we replace the static values and call the constructor of our painter with the actual values.
Conclusion
That’s it, we’re done with the implementation.
With the help of three packages and a CustomPainter
, we were able to implement a cross-platform audio player that mimics an audio tape. The audio tape has animating parts that depend on the current position of the song.
Comment this 🤌