171 lines
6.0 KiB
Dart
171 lines
6.0 KiB
Dart
// 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 = <Chapter>[];
|
|
|
|
ChapterSelector({
|
|
super.key,
|
|
required this.episode,
|
|
}) {
|
|
chapters = episode.chapters.where((c) => c.toc).toList(growable: false);
|
|
}
|
|
|
|
@override
|
|
State<ChapterSelector> createState() => _ChapterSelectorState();
|
|
}
|
|
|
|
class _ChapterSelectorState extends State<ChapterSelector> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
final audioBloc = Provider.of<AudioBloc>(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<AudioBloc>(context);
|
|
|
|
return StreamBuilder<Episode?>(
|
|
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;
|
|
}
|
|
}
|