Files
PinePods-nix/PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
2026-03-03 10:57:43 -05:00

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;
}
}