diff --git a/musicus/lib/screens/program.dart b/musicus/lib/screens/program.dart index e0fc2b5..13d929c 100644 --- a/musicus/lib/screens/program.dart +++ b/musicus/lib/screens/program.dart @@ -3,10 +3,95 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../backend.dart'; +import '../database.dart'; import '../music_library.dart'; import '../widgets/play_pause_button.dart'; import '../widgets/recording_tile.dart'; +/// Data class to bundle information from the database on one track. +class ProgramItem { + /// ID of the recording. + /// + /// We don't need the real recording, as the [RecordingTile] widget handles + /// that for us. If the recording is the same one, as the one from the + /// previous track, this will be null. + final int recordingId; + + /// List of work parts contained in this track. + /// + /// This will include the parts linked in the track as well as all parents of + /// them, if there are gaps between them (i.e. some parts are missing). + final List workParts; + + ProgramItem({ + this.recordingId, + this.workParts, + }); +} + +/// Widget displaying a [ProgramItem]. +class ProgramTile extends StatelessWidget { + final ProgramItem item; + final bool isPlaying; + + ProgramTile({ + this.item, + this.isPlaying, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Padding( + padding: const EdgeInsets.all(4.0), + child: isPlaying + ? const Icon(Icons.play_arrow) + : SizedBox( + width: 24.0, + height: 24.0, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (item.recordingId != null) ...[ + RecordingTile( + recordingId: item.recordingId, + ), + SizedBox( + height: 8.0, + ), + ], + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final part in item.workParts) + Padding( + padding: EdgeInsets.only( + left: 8.0 + part.partLevel * 8.0, + ), + child: Text( + part.title, + style: TextStyle( + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} + class ProgramScreen extends StatefulWidget { @override _ProgramScreenState createState() => _ProgramScreenState(); @@ -14,6 +99,10 @@ class ProgramScreen extends StatefulWidget { class _ProgramScreenState extends State { BackendState backend; + + StreamSubscription> playlistSubscription; + List items = []; + StreamSubscription positionSubscription; double position = 0.0; bool seeking = false; @@ -24,6 +113,14 @@ class _ProgramScreenState extends State { backend = Backend.of(context); + if (playlistSubscription != null) { + playlistSubscription.cancel(); + } + + playlistSubscription = backend.player.playlist.listen((playlist) { + updateProgram(playlist); + }); + if (positionSubscription != null) { positionSubscription.cancel(); } @@ -37,6 +134,103 @@ class _ProgramScreenState extends State { }); } + /// Go through the tracks of [playlist] and preprocess them for displaying. + Future updateProgram(List playlist) async { + List newItems = []; + + // The following variables exist to adapt the resulting ProgramItem to its + // predecessor. + + // If the previous recording was the same, we won't need to include the + // recording data again. + int lastRecordingId; + + // If the previous work was the same, we won't need to retrieve its parts + // from the database again. + int lastWorkId; + + // We need to keep track of the last work part to be able to check for the + // parent work parts that were left out between the two last tracks. + int lastPartId; + + // This will always contain the parts of the current work. + List workParts = []; + + for (var i = 0; i < playlist.length; i++) { + // The data that will be stored in the resulting ProgramItem. + int newRecordingId; + List newWorkParts = []; + + final track = playlist[i]; + final recordingId = track.track.recordingId; + final partIds = track.track.partIds; + + // newRecordingId will be null, if the recording ID is the same. This + // also means, that the work is the same, so workParts doesn't have to + // be updated either. + if (recordingId != lastRecordingId) { + lastRecordingId = recordingId; + newRecordingId = recordingId; + + final recording = + await backend.db.recordingById(recordingId).getSingle(); + + if (recording.work != lastWorkId) { + workParts = await backend.db.workParts(recording.work).get(); + } + + lastWorkId = recording.work; + lastPartId = null; + } + + /// Search for all parent work parts of [partId] starting from the part + /// with the ID [startId] and add them to the part list. + void addParentParts(int startId, int partId) { + final level = workParts[partId].partLevel; + final List parents = List.filled(level - 1, null); + + for (var i = startId; i < partId; i++) { + final part = workParts[i]; + if (part.partLevel < parents.length) { + parents[part.partLevel] = part; + } + } + + newWorkParts.addAll(parents); + } + + for (final partId in partIds) { + // We will need to include all parent work parts first, if there were + // work parts left out between the last two tracks or if the current + // work part comes before the previous one. + if (lastPartId != null) { + if (partIds.first > lastPartId + 1) { + addParentParts(lastPartId + 1, partId); + } + } else if (partIds.first > 0) { + addParentParts(0, partId); + } + + newWorkParts.add(workParts[partId]); + + lastPartId = partId; + } + + newItems.add(ProgramItem( + recordingId: newRecordingId, + workParts: newWorkParts, + )); + } + + // Check, whether we are still a part of the widget tree, because this + // function might take some time. + if (mounted) { + setState(() { + items = newItems; + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -47,41 +241,22 @@ class _ProgramScreenState extends State { ), title: Text('Program'), ), - body: StreamBuilder>( - stream: backend.player.playlist, + body: StreamBuilder( + stream: backend.player.currentIndex, builder: (context, snapshot) { - final playlist = snapshot.data; - - if (playlist != null && playlist.isNotEmpty) { - return StreamBuilder( - stream: backend.player.currentIndex, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.builder( - itemCount: playlist.length, - itemBuilder: (context, index) { - - final track = playlist[index]; - - return ListTile( - leading: index == snapshot.data - ? const Icon(Icons.play_arrow) - : SizedBox( - width: 24.0, - height: 24.0, - ), - title: RecordingTile( - recordingId: track.track.recordingId, - ), - onTap: () { - backend.player.skipTo(index); - }, - ); - }, - ); - } else { - return Container(); - } + if (snapshot.hasData) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + return InkWell( + child: ProgramTile( + item: items[index], + isPlaying: index == snapshot?.data, + ), + onTap: () { + backend.player.skipTo(index); + }, + ); }, ); } else { @@ -162,6 +337,7 @@ class _ProgramScreenState extends State { @override void dispose() { super.dispose(); + playlistSubscription.cancel(); positionSubscription.cancel(); } }