// Copyright 2020 Ben Hills and the project contributors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart'; import 'package:pinepods_mobile/entities/chapter.dart'; import 'package:pinepods_mobile/entities/episode.dart'; import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; /// A [Widget] for displaying a list of Podcast chapters for those /// podcasts that support that chapter tag. // ignore: must_be_immutable class ChapterSelector extends StatefulWidget { final ItemScrollController itemScrollController = ItemScrollController(); Episode episode; Chapter? chapter; StreamSubscription? positionSubscription; var chapters = []; ChapterSelector({ super.key, required this.episode, }) { chapters = episode.chapters.where((c) => c.toc).toList(growable: false); } @override State createState() => _ChapterSelectorState(); } class _ChapterSelectorState extends State { @override void initState() { super.initState(); final audioBloc = Provider.of(context, listen: false); Chapter? lastChapter; bool first = true; // Listen for changes in position. If the change in position results in // a change in chapter we scroll to it. This ensures that the current // chapter is always visible. // TODO: Jump only if current chapter is not visible. widget.positionSubscription = audioBloc.playPosition!.listen((event) { var episode = event.episode; if (widget.itemScrollController.isAttached) { lastChapter ??= episode!.currentChapter; if (lastChapter != episode!.currentChapter) { lastChapter = episode.currentChapter; if (!episode.chaptersLoading && episode.chapters.isNotEmpty) { var index = widget.chapters.indexWhere((element) => element == lastChapter); if (index >= 0) { if (first) { widget.itemScrollController.jumpTo(index: index); first = false; } // Removed auto-scroll to current chapter during playback // to prevent annoying bouncing behavior } } } } }); } @override Widget build(BuildContext context) { final audioBloc = Provider.of(context); return StreamBuilder( stream: audioBloc.nowPlaying, builder: (context, snapshot) { return !snapshot.hasData || snapshot.data!.chaptersLoading ? const Align( alignment: Alignment.center, child: PlatformProgressIndicator(), ) : ScrollablePositionedList.builder( initialScrollIndex: _initialIndex(snapshot.data), itemScrollController: widget.itemScrollController, itemCount: widget.chapters.length, itemBuilder: (context, i) { final index = i < 0 ? 0 : i; final chapter = widget.chapters[index]; final chapterSelected = chapter == snapshot.data!.currentChapter; final textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith( fontSize: 14, fontWeight: FontWeight.normal, ); /// We should be able to use the selectedTileColor property but, if we do, when /// we scroll the currently selected item out of view, the selected colour is /// still visible behind the transport control. This is a little hack, but fixes /// the issue until I can get ListTile to work correctly. return Padding( padding: const EdgeInsets.fromLTRB(4.0, 0.0, 4.0, 0.0), child: ListTile( selectedTileColor: Theme.of(context).cardTheme.color, onTap: () { audioBloc.transitionPosition(chapter.startTime); }, selected: chapterSelected, leading: Padding( padding: const EdgeInsets.all(4.0), child: Text( '${index + 1}.', style: textStyle, ), ), title: Text( widget.chapters[index].title.trim(), overflow: TextOverflow.ellipsis, softWrap: false, maxLines: 3, style: textStyle, ), trailing: Text( _formatStartTime(widget.chapters[index].startTime), style: textStyle, ), ), ); }, ); }); } @override void dispose() { widget.positionSubscription?.cancel(); super.dispose(); } int _initialIndex(Episode? e) { var init = 0; if (e != null && e.currentChapter != null) { init = widget.chapters.indexWhere((c) => c == e.currentChapter); if (init < 0) { init = 0; } } return init; } String _formatStartTime(double startTime) { var time = Duration(seconds: startTime.ceil()); var result = ''; if (time.inHours > 0) { result = '${time.inHours}:${time.inMinutes.remainder(60).toString().padLeft(2, '0')}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}'; } else { result = '${time.inMinutes}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}'; } return result; } }