added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,170 @@
// 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;
}
}

View File

@@ -0,0 +1,49 @@
// 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 'package:flutter/widgets.dart';
/// Custom [Decoration] for the chapters, episode & notes tab selector
/// shown in the [NowPlaying] page.
class DotDecoration extends Decoration {
final Color colour;
const DotDecoration({required this.colour});
@override
BoxPainter createBoxPainter([void Function()? onChanged]) {
return _DotDecorationPainter(decoration: this);
}
}
class _DotDecorationPainter extends BoxPainter {
final DotDecoration decoration;
_DotDecorationPainter({required this.decoration});
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
const double pillWidth = 8.0;
const double pillHeight = 3.0;
final center = configuration.size!.center(offset);
final height = configuration.size!.height;
final newOffset = Offset(center.dx, height - 8);
final paint = Paint();
paint.color = decoration.colour;
paint.style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromLTRBR(
newOffset.dx - pillWidth,
newOffset.dy - pillHeight,
newOffset.dx + pillWidth,
newOffset.dy + pillHeight,
const Radius.circular(12.0),
),
paint);
}
}

View File

@@ -0,0 +1,101 @@
// 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 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
/// This class renders the more info widget that is accessed from the 'more'
/// button on an episode.
///
/// The widget is displayed as a draggable, scrollable sheet. This contains
/// episode icon and play/pause control, below which the episode title, show
/// notes and person(s) details (if available).
class EpisodeDetails extends StatefulWidget {
final Episode episode;
const EpisodeDetails({
super.key,
required this.episode,
});
@override
State<EpisodeDetails> createState() => _EpisodeDetailsState();
}
class _EpisodeDetailsState extends State<EpisodeDetails> {
@override
Widget build(BuildContext context) {
final episode = widget.episode;
/// Ensure we do not highlight this as a new episode
episode.highlight = false;
return DraggableScrollableSheet(
initialChildSize: 0.6,
expand: false,
builder: (BuildContext context, ScrollController scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ExpansionTile(
key: const Key('episodemoreinfo'),
trailing: PlayControl(
episode: episode,
),
leading: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl!,
size: 56.0,
highlight: episode.highlight,
),
subtitle: EpisodeSubtitle(episode),
title: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: Theme.of(context).textTheme.bodyMedium,
)),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
episode.title!,
style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold),
),
),
),
if (episode.persons.isNotEmpty)
SizedBox(
height: 120.0,
child: ListView.builder(
itemCount: episode.persons.length,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
return PersonAvatar(person: episode.persons[index]);
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: PodcastHtml(content: episode.content ?? episode.description!),
)
],
),
);
});
}
}

View File

@@ -0,0 +1,222 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is responsible for rendering the funding menu on the podcast details page.
///
/// It returns either a Material or Cupertino style menu instance depending upon which
/// platform we are running on.
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
class FundingMenu extends StatelessWidget {
final List<Funding>? funding;
const FundingMenu(
this.funding, {
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _MaterialFundingMenu(funding);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _CupertinoFundingMenu(funding);
}
}
}
/// This is the material design version of the context menu. This will be rendered
/// for all platforms that are not iOS.
class _MaterialFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _MaterialFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Semantics(
label: L.of(context)!.podcast_funding_dialog_header,
child: PopupMenuButton<String>(
onSelected: (url) {
FundingLink.fundingLink(
url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
});
},
icon: const Icon(
Icons.payment,
),
itemBuilder: (BuildContext context) {
return List<PopupMenuEntry<String>>.generate(funding!.length, (index) {
return PopupMenuItem<String>(
value: funding![index].url,
enabled: true,
child: Text(funding![index].value),
);
});
},
),
);
});
}
}
/// This is the Cupertino context menu and is rendered only when running on
/// an iOS device.
class _CupertinoFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _CupertinoFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return IconButton(
tooltip: L.of(context)!.podcast_funding_dialog_header,
icon: const Icon(Icons.payment),
visualDensity: VisualDensity.compact,
onPressed: () => showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
...List<CupertinoActionSheetAction>.generate(funding!.length, (index) {
return CupertinoActionSheetAction(
onPressed: () {
FundingLink.fundingLink(
funding![index].url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
if (context.mounted) {
Navigator.of(context).pop('Cancel');
}
});
},
child: Text(funding![index].value),
);
}),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_option_label),
),
);
},
),
);
});
}
}
class FundingLink {
/// Check the consent status. If this is the first time we have been
/// requested to open a funding link, present the user with and
/// information dialog first to make clear that the link is provided
/// by the podcast owner and not Pinepods.
static Future<bool> fundingLink(String url, bool consent, BuildContext context) async {
bool? result = false;
if (consent) {
result = true;
final uri = Uri.parse(url);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
} else {
result = await showPlatformDialog<bool>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Semantics(
header: true,
child: Text(L.of(context)!.podcast_funding_dialog_header),
),
content: Text(L.of(context)!.consent_message),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.go_back_button_label,
),
onPressed: () {
Navigator.pop(context, false);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.continue_button_label,
),
iosIsDefaultAction: true,
onPressed: () {
Navigator.pop(context, true);
},
),
],
),
);
if (result!) {
var uri = Uri.parse(url);
unawaited(
canLaunchUrl(uri).then((value) => launchUrl(uri)),
);
}
}
return Future.value(result);
}
}

View File

@@ -0,0 +1,360 @@
// 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 'dart:ui';
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Displays a mini podcast player widget if a podcast is playing or paused.
///
/// If stopped a zero height box is built instead. Tapping on the mini player
/// will open the main player window.
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
super.key,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
initialData: AudioState.stopped,
builder: (context, snapshot) {
return snapshot.data != AudioState.stopped &&
snapshot.data != AudioState.none &&
snapshot.data != AudioState.error
? _MiniPlayerBuilder()
: const SizedBox(
height: 0.0,
);
});
}
}
class _MiniPlayerBuilder extends StatefulWidget {
@override
_MiniPlayerBuilderState createState() => _MiniPlayerBuilderState();
}
class _MiniPlayerBuilderState extends State<_MiniPlayerBuilder>
with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
@override
void initState() {
super.initState();
_playPauseController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
_playPauseController.value = 1;
_audioStateListener();
}
@override
void dispose() {
_audioStateSubscription.cancel();
_playPauseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final width = MediaQuery.of(context).size.width;
final placeholderBuilder = PlaceholderBuilder.of(context);
return Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) async {
await _audioStateSubscription.cancel();
audioBloc.transitionState(TransitionState.stop);
return true;
},
direction: DismissDirection.startToEnd,
background: Container(
color: Theme.of(context).colorScheme.surface,
height: 64.0,
),
child: GestureDetector(
key: const Key('miniplayergesture'),
onTap: () async {
await _audioStateSubscription.cancel();
if (context.mounted) {
showModalBottomSheet<void>(
context: context,
routeSettings: const RouteSettings(name: 'nowplaying'),
isScrollControlled: true,
builder: (BuildContext modalContext) {
final contextPadding = MediaQuery.of(context).padding.top;
final modalPadding = MediaQuery.of(modalContext).padding.top;
// Get the actual system safe area from the window (works on both iOS and Android)
final window = PlatformDispatcher.instance.views.first;
final systemPadding = window.padding.top / window.devicePixelRatio;
// Use the best available padding value
double topPadding;
if (contextPadding > 0) {
topPadding = contextPadding;
} else if (modalPadding > 0) {
topPadding = modalPadding;
} else {
// Fall back to system padding if both contexts have 0
topPadding = systemPadding;
}
return Padding(
padding: EdgeInsets.only(top: topPadding),
child: const NowPlaying(),
);
},
).then((_) {
_audioStateListener();
});
}
},
child: Semantics(
header: true,
label: L.of(context)!.semantics_mini_player_header,
child: Container(
height: 66,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: Divider.createBorderSide(context,
width: 1.0, color: Theme.of(context).dividerColor),
bottom: Divider.createBorderSide(context,
width: 0.0, color: Theme.of(context).dividerColor),
)),
child: Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
initialData: audioBloc.nowPlaying?.valueOrNull,
builder: (context, snapshot) {
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, stateSnapshot) {
var playing =
stateSnapshot.data == AudioState.playing;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
SizedBox(
height: 58.0,
width: 58.0,
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: snapshot.hasData
? PodcastImage(
key: Key(
'mini${snapshot.data!.imageUrl}'),
url: snapshot.data!.imageUrl!,
width: 58.0,
height: 58.0,
borderRadius: 4.0,
placeholder: placeholderBuilder !=
null
? placeholderBuilder
.builder()(context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
errorPlaceholder:
placeholderBuilder != null
? placeholderBuilder
.errorBuilder()(
context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
)
: Container(),
),
),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
snapshot.data?.title ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium,
),
Padding(
padding:
const EdgeInsets.only(top: 4.0),
child: Text(
snapshot.data?.author ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall,
),
),
],
)),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
audioBloc.transitionState(
TransitionState.fastforward);
}
},
child: Icon(
Icons.forward_30,
semanticLabel: L
.of(context)!
.fast_forward_button_label,
size: 36.0,
),
),
),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
_pause(audioBloc);
} else {
_play(audioBloc);
}
},
child: AnimatedIcon(
semanticLabel: playing
? L.of(context)!.pause_button_label
: L.of(context)!.play_button_label,
size: 48.0,
icon: AnimatedIcons.play_pause,
color:
Theme.of(context).iconTheme.color,
progress: _playPauseController,
),
),
),
],
);
});
}),
StreamBuilder<PositionState>(
stream: audioBloc.playPosition,
initialData: audioBloc.playPosition?.valueOrNull,
builder: (context, snapshot) {
var cw = 0.0;
var position = snapshot.hasData
? snapshot.data!.position
: const Duration(seconds: 0);
var length = snapshot.hasData
? snapshot.data!.length
: const Duration(seconds: 0);
if (length.inSeconds > 0) {
final pc = length.inSeconds / position.inSeconds;
cw = width / pc;
}
return Container(
width: cw,
height: 1.0,
color: Theme.of(context).primaryColor,
);
}),
],
),
),
),
),
),
);
}
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
/// little odd.
void _audioStateListener() {
if (mounted) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var firstEvent = true;
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (firstEvent) {
_playPauseController.value = 1;
firstEvent = false;
} else {
_playPauseController.forward();
}
} else {
if (firstEvent) {
_playPauseController.value = 0;
firstEvent = false;
} else {
_playPauseController.reverse();
}
}
});
}
}
void _play(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _pause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
}

View File

@@ -0,0 +1,654 @@
// 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/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/chapter_selector.dart';
import 'package:pinepods_mobile/ui/podcast/dot_decoration.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing_floating_player.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing_options.dart';
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
import 'package:pinepods_mobile/ui/podcast/playback_error_listener.dart';
import 'package:pinepods_mobile/ui/podcast/player_position_controls.dart';
import 'package:pinepods_mobile/ui/podcast/player_transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/delayed_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This is the full-screen player Widget which is invoked by touching the mini player.
///
/// This is the parent widget of the now playing screen(s). If we are running on a mobile in
/// portrait mode, we display the episode details, controls and additional options
/// as a draggable view. For tablets in portrait or on desktop, we display a split
/// screen. The main details and controls appear in one pane with the additional
/// controls in another.
///
/// TODO: The fade in/out transition applied when scrolling the queue is the first implementation.
/// Using [Opacity] is a very inefficient way of achieving this effect, but will do as a place
/// holder until a better animation can be achieved.
class NowPlaying extends StatefulWidget {
const NowPlaying({
super.key,
});
@override
State<NowPlaying> createState() => _NowPlayingState();
}
class _NowPlayingState extends State<NowPlaying> with WidgetsBindingObserver {
late StreamSubscription<AudioState> playingStateSubscription;
var textGroup = AutoSizeGroup();
double scrollPos = 0.0;
double opacity = 0.0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var popped = false;
// If the episode finishes we can close.
playingStateSubscription =
audioBloc.playingState!.where((state) => state == AudioState.stopped).listen((playingState) async {
// Prevent responding to multiple stop events after we've popped and lost context.
if (!popped) {
popped = true;
if (mounted) {
Navigator.of(context).pop();
}
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
playingStateSubscription.cancel();
super.dispose();
}
bool isMobilePortrait(BuildContext context) {
final query = MediaQuery.of(context);
return (query.orientation == Orientation.portrait || query.size.width <= 1000);
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final playerBuilder = PlayerControlsBuilder.of(context);
return Semantics(
header: false,
label: L.of(context)!.semantics_main_player_header,
explicitChildNodes: true,
child: StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
var duration = snapshot.data == null ? 0 : snapshot.data!.duration;
final WidgetBuilder? transportBuilder = playerBuilder?.builder(duration);
return isMobilePortrait(context)
? NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
setState(() {
if (notification.extent > (notification.minExtent)) {
opacity = 1 - (notification.maxExtent - notification.extent);
scrollPos = 1.0;
} else {
opacity = 0.0;
scrollPos = 0.0;
}
});
return true;
},
child: Stack(
fit: StackFit.expand,
children: [
// We need to hide the main player when the floating player is visible to prevent
// screen readers from reading both parts of the stack.
Visibility(
visible: opacity < 1,
child: NowPlayingTabs(
episode: snapshot.data!,
transportBuilder: transportBuilder,
),
),
SizedBox.expand(
child: SafeArea(
child: Column(
children: [
/// Sized boxes without a child are 'invisible' so they do not prevent taps below
/// the stack but are still present in the layout. We have a sized box here to stop
/// the draggable panel from jumping as you start to pull it up. I am really looking
/// forward to the Dart team fixing the nested scroll issues with [DraggableScrollableSheet]
SizedBox(
height: 64.0,
child: scrollPos == 1
? Opacity(
opacity: opacity,
child: const FloatingPlayer(),
)
: null,
),
if (MediaQuery.of(context).orientation == Orientation.portrait)
const Expanded(
child: NowPlayingOptionsSelector(),
),
],
),
)),
],
),
)
: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
flex: 1,
child: NowPlayingTabs(episode: snapshot.data!, transportBuilder: transportBuilder),
),
const Expanded(
flex: 1,
child: NowPlayingOptionsSelectorWide(),
),
],
);
}),
);
}
}
/// This widget displays the episode logo, episode title and current
/// chapter if available.
///
/// If running in portrait this will be in a vertical format; if in
/// landscape this will be in a horizontal format. The actual displaying
/// of the episode text is handed off to [NowPlayingEpisodeDetails].
class NowPlayingEpisode extends StatelessWidget {
final String? imageUrl;
final Episode episode;
final AutoSizeGroup? textGroup;
const NowPlayingEpisode({
super.key,
required this.imageUrl,
required this.episode,
required this.textGroup,
});
@override
Widget build(BuildContext context) {
final placeholderBuilder = PlaceholderBuilder.of(context);
return OrientationBuilder(
builder: (context, orientation) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: MediaQuery.of(context).orientation == Orientation.portrait || MediaQuery.of(context).size.width >= 1000
? Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: Semantics(
label: L.of(context)!.semantic_podcast_artwork_label,
child: PodcastImage(
key: Key('nowplaying$imageUrl'),
url: imageUrl!,
width: MediaQuery.of(context).size.width * .75,
height: MediaQuery.of(context).size.height * .75,
fit: BoxFit.contain,
borderRadius: 6.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: DelayedCircularProgressIndicator(),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
),
),
),
Expanded(
flex: 3,
child: NowPlayingEpisodeDetails(
episode: episode,
textGroup: textGroup,
),
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(
left: 8.0,
bottom: 8.0,
),
child: PodcastImage(
key: Key('nowplaying$imageUrl'),
url: imageUrl!,
height: 280,
width: 280,
fit: BoxFit.contain,
borderRadius: 8.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: DelayedCircularProgressIndicator(),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
),
),
),
Expanded(
flex: 5,
child: NowPlayingEpisodeDetails(
episode: episode,
textGroup: textGroup,
),
),
],
),
);
},
);
}
}
/// This widget is responsible for displaying the main episode details.
///
/// This displays the current episode title and, if available, the
/// current chapter title and optional link.
class NowPlayingEpisodeDetails extends StatelessWidget {
final Episode? episode;
final AutoSizeGroup? textGroup;
static const minFontSize = 14.0;
const NowPlayingEpisodeDetails({
super.key,
this.episode,
this.textGroup,
});
@override
Widget build(BuildContext context) {
final chapterTitle = episode?.currentChapter?.title ?? '';
final chapterUrl = episode?.currentChapter?.url ?? '';
return Column(
children: [
Expanded(
flex: 5,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0),
child: Semantics(
container: true,
child: AutoSizeText(
episode?.title ?? '',
group: textGroup,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
minFontSize: minFontSize,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24.0,
),
maxLines: episode!.hasChapters ? 3 : 4,
),
),
),
),
if (episode!.hasChapters)
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 0.0, 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Semantics(
label: L.of(context)!.semantic_current_chapter_label,
container: true,
child: AutoSizeText(
chapterTitle,
group: textGroup,
minFontSize: minFontSize,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey[300],
fontWeight: FontWeight.normal,
fontSize: 16.0,
),
maxLines: 2,
),
),
),
chapterUrl.isEmpty
? const SizedBox(
height: 0,
width: 0,
)
: Semantics(
label: L.of(context)!.semantic_chapter_link_label,
container: true,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(
Icons.link,
),
color: Theme.of(context).primaryIconTheme.color,
onPressed: () {
_chapterLink(chapterUrl);
}),
),
],
),
),
),
],
);
}
void _chapterLink(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch chapter link: $url';
}
}
}
/// This widget handles the displaying of the episode show notes.
///
/// This consists of title, show notes and person details
/// (where available).
class NowPlayingShowNotes extends StatelessWidget {
final Episode? episode;
const NowPlayingShowNotes({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 16.0,
),
child: Text(
episode!.title!,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (episode!.persons.isNotEmpty)
SizedBox(
height: 120.0,
child: ListView.builder(
itemCount: episode!.persons.length,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
return PersonAvatar(person: episode!.persons[index]);
},
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 8.0,
right: 8.0,
),
child: PodcastHtml(content: episode?.content ?? episode?.description ?? ''),
),
],
),
),
);
}
}
/// Widget for rendering main episode tabs.
///
/// This will be episode details and show notes. If the episode supports chapters
/// this will be included also. This is the parent widget. The tabs are
/// rendered via [EpisodeTabBar] and the tab contents via. [EpisodeTabBarView].
class NowPlayingTabs extends StatelessWidget {
const NowPlayingTabs({
super.key,
required this.transportBuilder,
required this.episode,
});
final WidgetBuilder? transportBuilder;
final Episode episode;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: episode.hasChapters ? 3 : 2,
initialIndex: episode.hasChapters ? 1 : 0,
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context)
.appBarTheme
.systemOverlayStyle!
.copyWith(systemNavigationBarColor: Theme.of(context).secondaryHeaderColor),
child: Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0.0,
leading: IconButton(
tooltip: L.of(context)!.minimise_player_window_button_label,
icon: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).primaryIconTheme.color,
),
onPressed: () => {
Navigator.pop(context),
},
),
flexibleSpace: PlaybackErrorListener(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
EpisodeTabBar(
chapters: episode.hasChapters,
),
],
),
),
),
body: Column(
children: [
Expanded(
flex: 5,
child: EpisodeTabBarView(
episode: episode,
chapters: episode.hasChapters,
),
),
transportBuilder != null
? transportBuilder!(context)
: const SizedBox(
height: 148.0,
child: NowPlayingTransport(),
),
if (MediaQuery.of(context).orientation == Orientation.portrait)
const Expanded(
flex: 1,
child: NowPlayingOptionsScaffold(),
),
],
),
),
));
}
}
/// This class is responsible for rendering the tab selection at the top of the screen.
///
/// It displays two or three tabs depending upon whether the current episode supports
/// (and contains) chapters.
class EpisodeTabBar extends StatelessWidget {
final bool chapters;
const EpisodeTabBar({
super.key,
this.chapters = false,
});
@override
Widget build(BuildContext context) {
return TabBar(
isScrollable: true,
indicatorSize: TabBarIndicatorSize.tab,
indicator: DotDecoration(colour: Theme.of(context).primaryColor),
tabs: [
if (chapters)
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.chapters_label),
),
),
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.episode_label),
),
),
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.notes_label),
),
),
],
);
}
}
/// This class is responsible for rendering the tab bodies.
///
/// This includes the chapter selection view (if the episode supports chapters),
/// the episode details (image and description) and the show notes view.
class EpisodeTabBarView extends StatelessWidget {
final Episode? episode;
final AutoSizeGroup? textGroup;
final bool chapters;
const EpisodeTabBarView({
super.key,
this.episode,
this.textGroup,
this.chapters = false,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
return TabBarView(
children: [
if (chapters)
ChapterSelector(
episode: episode!,
),
StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
final e = snapshot.hasData ? snapshot.data! : episode!;
return NowPlayingEpisode(
episode: e,
imageUrl: e.positionalImageUrl,
textGroup: textGroup,
);
}),
NowPlayingShowNotes(episode: episode),
],
);
}
}
/// This is the parent widget for the episode position and transport
/// controls.
class NowPlayingTransport extends StatelessWidget {
const NowPlayingTransport({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: <Widget>[
Divider(
height: 0.0,
),
PlayerPositionControls(),
PlayerTransportControls(),
],
);
}
}
/// This widget allows users to inject their own transport controls
/// into the app.
///
/// When rendering the controls, Pinepods will check if a PlayerControlsBuilder
/// is in the tree. If so, it will use the builder rather than its own
/// transport controls.
class PlayerControlsBuilder extends InheritedWidget {
final WidgetBuilder Function(int duration) builder;
const PlayerControlsBuilder({
super.key,
required this.builder,
required super.child,
});
static PlayerControlsBuilder? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PlayerControlsBuilder>();
}
@override
bool updateShouldNotify(PlayerControlsBuilder oldWidget) {
return builder != oldWidget.builder;
}
}

View File

@@ -0,0 +1,219 @@
// 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/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget is based upon [MiniPlayer] and provides an additional play/pause control when
/// the episode queue is expanded.
///
/// At some point we should try to merge the common code between this and [MiniPlayer].
class FloatingPlayer extends StatelessWidget {
const FloatingPlayer({
super.key,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, snapshot) {
return (snapshot.hasData &&
!(snapshot.data == AudioState.stopped ||
snapshot.data == AudioState.none ||
snapshot.data == AudioState.error))
? _FloatingPlayerBuilder()
: const SizedBox(
height: 0.0,
);
});
}
}
class _FloatingPlayerBuilder extends StatefulWidget {
@override
_FloatingPlayerBuilderState createState() => _FloatingPlayerBuilderState();
}
class _FloatingPlayerBuilderState extends State<_FloatingPlayerBuilder> with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
@override
void initState() {
super.initState();
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
_playPauseController.value = 1;
_audioStateListener();
}
@override
void dispose() {
_audioStateSubscription.cancel();
_playPauseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final placeholderBuilder = PlaceholderBuilder.of(context);
return Container(
height: 64,
color: Theme.of(context).canvasColor,
child: StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, stateSnapshot) {
var playing = stateSnapshot.data == AudioState.playing;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: snapshot.hasData
? PodcastImage(
key: Key('float${snapshot.data!.imageUrl}'),
url: snapshot.data!.imageUrl!,
width: 58.0,
height: 58.0,
borderRadius: 4.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
)
: Container(),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
snapshot.data?.title ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium,
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
snapshot.data?.author ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall,
),
),
],
)),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
),
onPressed: () {
if (playing) {
audioBloc.transitionState(TransitionState.fastforward);
}
},
child: const Icon(
Icons.forward_30,
size: 36.0,
),
),
),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
),
onPressed: () {
if (playing) {
_pause(audioBloc);
} else {
_play(audioBloc);
}
},
child: AnimatedIcon(
semanticLabel:
playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
size: 48.0,
icon: AnimatedIcons.play_pause,
color: Theme.of(context).iconTheme.color,
progress: _playPauseController,
),
),
),
],
);
});
}),
);
}
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
/// little odd.
void _audioStateListener() {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var firstEvent = true;
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (firstEvent) {
_playPauseController.value = 1;
firstEvent = false;
} else {
_playPauseController.forward();
}
} else {
if (firstEvent) {
_playPauseController.value = 0;
firstEvent = false;
} else {
_playPauseController.reverse();
}
}
});
}
void _play(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _pause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
}

View File

@@ -0,0 +1,317 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/podcast/transcript_view.dart';
import 'package:pinepods_mobile/ui/podcast/up_next_view.dart';
import 'package:pinepods_mobile/ui/podcast/pinepods_up_next_view.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class gives us options that can be dragged up from the bottom of the main player
/// window.
///
/// Currently these options are Up Next & Transcript.
///
/// This class is an initial version and should by much simpler than it is; however,
/// a [NestedScrollView] is the widget we need to implement this UI, there is a current
/// issue whereby the scroll view and [DraggableScrollableSheet] clash and therefore cannot
/// be used together.
///
/// See issues [64157](https://github.com/flutter/flutter/issues/64157)
/// [67219](https://github.com/flutter/flutter/issues/67219)
///
/// If anyone can come up with a more elegant solution (and one that does not throw
/// an overflow error in debug) please raise and issue/submit a PR.
///
class NowPlayingOptionsSelector extends StatefulWidget {
final double? scrollPos;
static const baseSize = 68.0;
const NowPlayingOptionsSelector({super.key, this.scrollPos});
@override
State<NowPlayingOptionsSelector> createState() => _NowPlayingOptionsSelectorState();
}
class _NowPlayingOptionsSelectorState extends State<NowPlayingOptionsSelector> {
DraggableScrollableController? draggableController;
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
final theme = Theme.of(context);
final windowHeight = MediaQuery.of(context).size.height;
final minSize = NowPlayingOptionsSelector.baseSize / (windowHeight - NowPlayingOptionsSelector.baseSize);
return DraggableScrollableSheet(
initialChildSize: minSize,
minChildSize: minSize,
maxChildSize: 1.0,
controller: draggableController,
// Snap doesn't work as the sheet and scroll controller just don't get along
// snap: true,
// snapSizes: [minSize, maxSize],
builder: (BuildContext context, ScrollController scrollController) {
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, queueSnapshot) {
final hasTranscript = queueSnapshot.hasData &&
queueSnapshot.data?.playing != null &&
queueSnapshot.data!.playing!.hasTranscripts;
return DefaultTabController(
animationDuration: !draggableController!.isAttached || draggableController!.size <= minSize
? const Duration(seconds: 0)
: kTabScrollDuration,
length: hasTranscript ? 2 : 1,
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
return SingleChildScrollView(
controller: scrollController,
child: ConstrainedBox(
constraints: BoxConstraints.expand(
height: constraints.maxHeight,
),
child: Material(
color: theme.secondaryHeaderColor,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).highlightColor,
width: 0.0,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18.0),
topRight: Radius.circular(18.0),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SliderHandle(
label: optionsSliderOpen()
? L.of(context)!.semantic_playing_options_collapse_label
: L.of(context)!.semantic_playing_options_expand_label,
onTap: () {
if (draggableController != null) {
if (draggableController!.size < 1.0) {
draggableController!.animateTo(
1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
} else {
draggableController!.animateTo(
0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
}
}
},
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.0),
border: Border(
bottom: draggableController != null &&
(!draggableController!.isAttached || draggableController!.size <= minSize)
? BorderSide.none
: BorderSide(color: Colors.grey[800]!, width: 1.0),
),
),
child: TabBar(
onTap: (index) {
DefaultTabController.of(ctx).animateTo(index);
if (draggableController != null && draggableController!.size < 1.0) {
draggableController!.animateTo(
1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
}
},
automaticIndicatorColorAdjustment: false,
indicatorPadding: EdgeInsets.zero,
/// Little hack to hide the indicator when closed
indicatorColor: draggableController != null &&
(!draggableController!.isAttached || draggableController!.size <= minSize)
? Theme.of(context).secondaryHeaderColor
: null,
tabs: [
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.up_next_queue_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
if (hasTranscript)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.transcript_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
),
const Padding(padding: EdgeInsets.only(bottom: 12.0)),
Expanded(
child: Consumer<SettingsBloc>(
builder: (context, settingsBloc, child) {
final settings = settingsBloc.currentSettings;
final isPinepodsConnected = settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null;
return TabBarView(
children: [
isPinepodsConnected
? const PinepodsUpNextView()
: const UpNextView(),
if (hasTranscript)
const TranscriptView(),
],
);
},
),
),
],
),
),
),
);
}),
);
});
},
);
}
bool optionsSliderOpen() {
return (draggableController != null && draggableController!.isAttached && draggableController!.size == 1.0);
}
@override
void initState() {
draggableController = DraggableScrollableController();
super.initState();
}
}
class NowPlayingOptionsScaffold extends StatelessWidget {
const NowPlayingOptionsScaffold({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: NowPlayingOptionsSelector.baseSize - 8.0,
);
}
}
/// This implementation displays the additional options in a tab set outside of a
/// draggable sheet.
///
/// Currently these options are Up Next & Transcript.
class NowPlayingOptionsSelectorWide extends StatefulWidget {
final double? scrollPos;
static const baseSize = 68.0;
const NowPlayingOptionsSelectorWide({super.key, this.scrollPos});
@override
State<NowPlayingOptionsSelectorWide> createState() => _NowPlayingOptionsSelectorWideState();
}
class _NowPlayingOptionsSelectorWideState extends State<NowPlayingOptionsSelectorWide> {
DraggableScrollableController? draggableController;
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
final theme = Theme.of(context);
final scrollController = ScrollController();
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, queueSnapshot) {
final hasTranscript = queueSnapshot.hasData &&
queueSnapshot.data?.playing != null &&
queueSnapshot.data!.playing!.hasTranscripts;
return DefaultTabController(
length: hasTranscript ? 2 : 1,
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
return SingleChildScrollView(
controller: scrollController,
child: ConstrainedBox(
constraints: BoxConstraints.expand(
height: constraints.maxHeight,
),
child: Material(
color: theme.secondaryHeaderColor,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.0),
border: Border(
bottom: BorderSide(color: Colors.grey[800]!, width: 1.0),
),
),
child: TabBar(
automaticIndicatorColorAdjustment: false,
tabs: [
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
child: Text(
L.of(context)!.up_next_queue_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
if (hasTranscript)
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
child: Text(
L.of(context)!.transcript_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
),
Expanded(
child: TabBarView(
children: [
const UpNextView(),
if (hasTranscript)
const TranscriptView(),
],
),
),
],
),
),
),
);
}),
);
});
}
}

View File

@@ -0,0 +1,82 @@
// 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.
// ignore_for_file: must_be_immutable
import 'dart:async';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// This Widget handles rendering of a person avatar.
///
/// The data comes from the <person> tag in the Podcasting 2.0 namespace.
///
/// https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person
class PersonAvatar extends StatelessWidget {
final Person person;
String initials = '';
String role = '';
PersonAvatar({
super.key,
required this.person,
}) {
if (person.name.isNotEmpty) {
var parts = person.name.split(' ');
for (var i in parts) {
if (i.isNotEmpty) {
initials += i.substring(0, 1).toUpperCase();
}
}
}
if (person.role.isNotEmpty) {
role = person.role.substring(0, 1).toUpperCase() + person.role.substring(1);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: person.link != null && person.link!.isNotEmpty
? () {
final uri = Uri.parse(person.link!);
unawaited(
canLaunchUrl(uri).then((value) => launchUrl(uri)),
);
}
: null,
child: SizedBox(
width: 96,
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 32,
foregroundImage: ExtendedImage.network(
person.image!,
cache: true,
).image,
child: Text(initials),
),
Text(
person.name,
maxLines: 3,
textAlign: TextAlign.center,
),
Text(role),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,390 @@
// lib/ui/podcast/pinepods_up_next_view.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
import 'package:provider/provider.dart';
import 'dart:async';
/// PinePods version of the Up Next queue that shows the server queue.
///
/// This replaces the local queue functionality with server-based queue management.
class PinepodsUpNextView extends StatefulWidget {
const PinepodsUpNextView({
Key? key,
}) : super(key: key);
@override
State<PinepodsUpNextView> createState() => _PinepodsUpNextViewState();
}
class _PinepodsUpNextViewState extends State<PinepodsUpNextView> {
final PinepodsService _pinepodsService = PinepodsService();
List<PinepodsEpisode> _queuedEpisodes = [];
bool _isLoading = true;
String? _errorMessage;
StreamSubscription? _episodeSubscription;
@override
void initState() {
super.initState();
_loadQueue();
_listenToEpisodeChanges();
}
@override
void dispose() {
_episodeSubscription?.cancel();
super.dispose();
}
/// Listen to episode changes to refresh queue when episodes advance
void _listenToEpisodeChanges() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
final episodeStream = audioPlayerService.episodeEvent;
// Check if episodeEvent stream is available
if (episodeStream == null) {
print('Episode event stream not available');
return;
}
String? lastEpisodeGuid;
_episodeSubscription = episodeStream.listen((episode) {
// Only refresh if the episode actually changed (avoid unnecessary refreshes)
if (episode != null && episode.guid != lastEpisodeGuid && mounted) {
lastEpisodeGuid = episode.guid;
// Add a small delay to ensure server queue has been updated
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_loadQueue();
}
});
}
});
} catch (e) {
// Provider not available, continue without episode listening
print('Could not set up episode change listener: $e');
}
}
Future<void> _loadQueue() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final episodes = await _pinepodsService.getQueuedEpisodes(settings.pinepodsUserId!);
setState(() {
_queuedEpisodes = episodes;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Future<void> _reorderQueue(int oldIndex, int newIndex) async {
// Adjust indices if moving down the list
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Update local state immediately for smooth UI
setState(() {
final episode = _queuedEpisodes.removeAt(oldIndex);
_queuedEpisodes.insert(newIndex, episode);
});
// Get episode IDs in new order
final episodeIds = _queuedEpisodes.map((e) => e.episodeId).toList();
// Call API to update order on server
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
await _loadQueue(); // Reload to restore original order
return;
}
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to update queue order')),
);
await _loadQueue(); // Reload to restore original order
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error updating queue: $e')),
);
await _loadQueue(); // Reload to restore original order
}
}
Future<void> _removeFromQueue(int index) async {
final episode = _queuedEpisodes[index];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
return;
}
try {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_queuedEpisodes.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Removed from queue')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to remove from queue')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error removing from queue: $e')),
);
}
}
Future<void> _clearQueue() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Queue'),
content: const Text('Are you sure you want to clear the entire queue?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
// Remove all episodes from queue
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
return;
}
try {
// Remove each episode from the queue
for (final episode in _queuedEpisodes) {
await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
}
setState(() {
_queuedEpisodes.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error clearing queue: $e')),
);
await _loadQueue(); // Reload to get current state
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header with title and clear button
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
child: Text(
'Up Next',
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: TextButton(
onPressed: _queuedEpisodes.isEmpty ? null : _clearQueue,
child: Text(
'Clear',
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: _queuedEpisodes.isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).primaryColor,
),
),
),
),
],
),
// Content area
if (_isLoading)
const Padding(
padding: EdgeInsets.all(24.0),
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
'Error loading queue',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadQueue,
child: const Text('Retry'),
),
],
),
)
else if (_queuedEpisodes.isEmpty)
Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Your queue is empty. Add episodes to see them here.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
)
else
Expanded(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: _queuedEpisodes.length,
itemBuilder: (BuildContext context, int index) {
final episode = _queuedEpisodes[index];
return Dismissible(
key: ValueKey('queue_${episode.episodeId}'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
_removeFromQueue(index);
},
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete,
color: Colors.white,
),
),
child: Container(
key: ValueKey('episode_${episode.episodeId}'),
margin: const EdgeInsets.only(bottom: 4),
child: DraggableQueueEpisodeCard(
episode: episode,
index: index,
onTap: () {
// Could navigate to episode details if needed
},
onPlayPressed: () {
// Could implement play functionality if needed
},
),
),
);
},
onReorder: _reorderQueue,
),
),
],
);
}
}

View File

@@ -0,0 +1,70 @@
// 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/l10n/L.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Listens for errors on the audio BLoC.
///
/// We receive a code which we then map to an error message. This needs to be placed
/// below a [Scaffold].
class PlaybackErrorListener extends StatefulWidget {
final Widget child;
const PlaybackErrorListener({
super.key,
required this.child,
});
@override
State<PlaybackErrorListener> createState() => _PlaybackErrorListenerState();
}
class _PlaybackErrorListenerState extends State<PlaybackErrorListener> {
StreamSubscription<int>? errorSubscription;
@override
Widget build(BuildContext context) {
return widget.child;
}
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
errorSubscription = audioBloc.playbackError!.listen((code) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_codeToMessage(context, code))));
}
});
}
@override
void dispose() {
errorSubscription?.cancel();
super.dispose();
}
/// Ideally the BLoC would pass us the message to display; however, as we need a
/// context to fetch the correct version of any text string we need to work it out here.
String _codeToMessage(BuildContext context, int code) {
var result = '';
switch (code) {
case 401:
result = L.of(context)!.error_no_connection;
break;
case 501:
result = L.of(context)!.error_playback_fail;
break;
}
return result;
}
}

View File

@@ -0,0 +1,170 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class handles the rendering of the positional controls: the current playback
/// time, time remaining and the time [Slider].
class PlayerPositionControls extends StatefulWidget {
const PlayerPositionControls({
super.key,
});
@override
State<PlayerPositionControls> createState() => _PlayerPositionControlsState();
}
class _PlayerPositionControlsState extends State<PlayerPositionControls> {
/// Current playback position
var currentPosition = 0;
/// Indicates the user is moving the position slide. We should ignore
/// position updates until the user releases the slide.
var dragging = false;
/// Seconds left of this episode.
var timeRemaining = 0;
/// The length of the episode in seconds.
var episodeLength = 0;
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
final screenReader = MediaQuery.of(context).accessibleNavigation;
return StreamBuilder<PositionState>(
stream: audioBloc.playPosition,
builder: (context, snapshot) {
var position = snapshot.hasData ? snapshot.data!.position.inSeconds : 0;
episodeLength = snapshot.hasData ? snapshot.data!.length.inSeconds : 0;
var divisions = episodeLength == 0 ? 1 : episodeLength;
// If a screen reader is enabled, will make divisions ten seconds each.
if (screenReader) {
divisions = episodeLength ~/ 10;
}
if (!dragging) {
currentPosition = position;
if (currentPosition < 0) {
currentPosition = 0;
}
if (currentPosition > episodeLength) {
currentPosition = episodeLength;
}
timeRemaining = episodeLength - position;
if (timeRemaining < 0) {
timeRemaining = 0;
}
}
return Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 0.0,
bottom: 4.0,
),
child: Row(
children: <Widget>[
FittedBox(
child: Text(
_formatDuration(Duration(seconds: currentPosition)),
semanticsLabel:
'${L.of(context)!.now_playing_episode_position} ${_formatDuration(Duration(seconds: currentPosition))}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
Expanded(
child: snapshot.hasData
? Slider(
label: _formatDuration(Duration(seconds: currentPosition)),
onChanged: (value) {
setState(() {
_calculatePositions(value.toInt());
// Normally, we only want to trigger a position change when the user has finished
// sliding; however, with a screen reader enabled that will never trigger. Instead,
// we'll use the 'normal' change event.
if (screenReader) {
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
}
});
},
onChangeStart: (value) {
if (!snapshot.data!.buffering) {
setState(() {
dragging = true;
_calculatePositions(currentPosition);
});
}
},
onChangeEnd: (value) {
setState(() {
dragging = false;
});
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
},
value: currentPosition.toDouble(),
min: 0.0,
max: episodeLength.toDouble(),
divisions: divisions,
activeColor: Theme.of(context).primaryColor,
semanticFormatterCallback: (double newValue) {
return _formatDuration(Duration(seconds: currentPosition));
})
: Slider(
onChanged: null,
value: 0,
min: 0.0,
max: 1.0,
activeColor: Theme.of(context).primaryColor,
),
),
FittedBox(
child: Text(
_formatDuration(Duration(seconds: timeRemaining)),
textAlign: TextAlign.right,
semanticsLabel:
'${L.of(context)!.now_playing_episode_time_remaining} ${_formatDuration(Duration(seconds: timeRemaining))}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
),
);
});
}
void _calculatePositions(int p) {
currentPosition = p;
timeRemaining = episodeLength - p;
}
String _formatDuration(Duration duration) {
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt());
var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt());
return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
}

View File

@@ -0,0 +1,202 @@
// 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/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/sleep_selector.dart';
import 'package:pinepods_mobile/ui/widgets/speed_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:provider/provider.dart';
/// Builds a transport control bar for rewind, play and fast-forward.
/// See [NowPlaying].
class PlayerTransportControls extends StatefulWidget {
const PlayerTransportControls({
super.key,
});
@override
State<PlayerTransportControls> createState() => _PlayerTransportControlsState();
}
class _PlayerTransportControlsState extends State<PlayerTransportControls> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: StreamBuilder<AudioState>(
stream: audioBloc.playingState,
initialData: AudioState.none,
builder: (context, snapshot) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
const SleepSelectorWidget(),
IconButton(
onPressed: () {
return snapshot.data == AudioState.buffering ? null : _rewind(audioBloc);
},
tooltip: L.of(context)!.rewind_button_label,
padding: const EdgeInsets.all(0.0),
icon: const Icon(
Icons.replay_10,
size: 48.0,
),
),
AnimatedPlayButton(audioState: snapshot.data!),
IconButton(
onPressed: () {
return snapshot.data == AudioState.buffering ? null : _fastforward(audioBloc);
},
tooltip: L.of(context)!.fast_forward_button_label,
padding: const EdgeInsets.all(0.0),
icon: const Icon(
Icons.forward_30,
size: 48.0,
),
),
const SpeedSelectorWidget(),
],
);
}),
);
}
void _rewind(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.rewind);
}
void _fastforward(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.fastforward);
}
}
typedef PlayHandler = Function(AudioBloc audioBloc);
class AnimatedPlayButton extends StatefulWidget {
final AudioState audioState;
final PlayHandler onPlay;
final PlayHandler onPause;
const AnimatedPlayButton({
super.key,
required this.audioState,
this.onPlay = _onPlay,
this.onPause = _onPause,
});
@override
State<AnimatedPlayButton> createState() => _AnimatedPlayButtonState();
}
void _onPlay(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _onPause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
class _AnimatedPlayButtonState extends State<AnimatedPlayButton> with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
bool init = true;
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
/// Seems a little hacky, but when we load the form we want the play/pause
/// button to be in the correct state. If we are building the first frame,
/// just set the animation controller to the correct state; for all other
/// frames we want to animate. Doing it this way prevents the play/pause
/// button from animating when the form is first loaded.
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (init) {
_playPauseController.value = 1;
init = false;
} else {
_playPauseController.forward();
}
} else {
if (init) {
_playPauseController.value = 0;
init = false;
} else {
_playPauseController.reverse();
}
}
});
}
@override
void dispose() {
_playPauseController.dispose();
_audioStateSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final playing = widget.audioState == AudioState.playing;
final buffering = widget.audioState == AudioState.buffering;
return Stack(
alignment: AlignmentDirectional.center,
children: [
if (buffering)
SpinKitRing(
lineWidth: 4.0,
color: Theme.of(context).primaryColor,
size: 84,
),
if (!buffering)
const SizedBox(
height: 84,
width: 84,
),
Tooltip(
message: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
child: TextButton(
style: TextButton.styleFrom(
shape: CircleBorder(side: BorderSide(color: Theme.of(context).highlightColor, width: 0.0)),
backgroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
padding: const EdgeInsets.all(6.0),
),
onPressed: () {
if (playing) {
widget.onPause(audioBloc);
} else {
widget.onPlay(audioBloc);
}
},
child: AnimatedIcon(
size: 60.0,
semanticLabel: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
icon: AnimatedIcons.play_pause,
color: Colors.white,
progress: _playPauseController,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,206 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/feed.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is responsible for rendering the context menu on the podcast details
/// page.
///
/// It returns either a [_MaterialPodcastMenu] or a [_CupertinoContextMenu}
/// instance depending upon which platform we are running on.
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
class PodcastContextMenu extends StatelessWidget {
final Podcast podcast;
const PodcastContextMenu(
this.podcast, {
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _MaterialPodcastMenu(podcast);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _CupertinoContextMenu(podcast);
}
}
}
/// This is the material design version of the context menu. This will be rendered
/// for all platforms that are not iOS.
class _MaterialPodcastMenu extends StatelessWidget {
final Podcast podcast;
const _MaterialPodcastMenu(this.podcast);
@override
Widget build(BuildContext context) {
final bloc = Provider.of<PodcastBloc>(context);
return StreamBuilder<BlocState<Podcast>>(
stream: bloc.details,
builder: (context, snapshot) {
return PopupMenuButton<String>(
onSelected: (event) {
togglePlayed(value: event, bloc: bloc);
},
icon: const Icon(
Icons.more_vert,
),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'ma',
enabled: podcast.subscribed,
child: Text(L.of(context)!.mark_episodes_played_label),
),
PopupMenuItem<String>(
value: 'ua',
enabled: podcast.subscribed,
child: Text(L.of(context)!.mark_episodes_not_played_label),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'refresh',
enabled: podcast.link?.isNotEmpty ?? false,
child: Text(L.of(context)!.refresh_feed_label),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'web',
enabled: podcast.link?.isNotEmpty ?? false,
child: Text(L.of(context)!.open_show_website_label),
),
];
},
);
});
}
void togglePlayed({
required String value,
required PodcastBloc bloc,
}) async {
if (value == 'ma') {
bloc.podcastEvent(PodcastEvent.markAllPlayed);
} else if (value == 'ua') {
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
} else if (value == 'refresh') {
bloc.load(Feed(
podcast: podcast,
refresh: true,
));
} else if (value == 'web') {
final uri = Uri.parse(podcast.link!);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
}
}
}
/// This is the Cupertino context menu and is rendered only when running on
/// an iOS device.
class _CupertinoContextMenu extends StatelessWidget {
final Podcast podcast;
const _CupertinoContextMenu(this.podcast);
@override
Widget build(BuildContext context) {
final bloc = Provider.of<PodcastBloc>(context);
return StreamBuilder<BlocState<Podcast>>(
stream: bloc.details,
builder: (context, snapshot) {
return IconButton(
tooltip: L.of(context)!.podcast_options_overflow_menu_semantic_label,
icon: const Icon(CupertinoIcons.ellipsis),
onPressed: () => showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.podcastEvent(PodcastEvent.markAllPlayed);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.mark_episodes_played_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.mark_episodes_not_played_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.load(Feed(
podcast: podcast,
refresh: true,
));
if (context.mounted) {
Navigator.pop(context, 'Cancel');
}
},
child: Text(L.of(context)!.refresh_feed_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () async {
final uri = Uri.parse(podcast.link!);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
if (context.mounted) {
Navigator.pop(context, 'Cancel');
}
},
child: Text(L.of(context)!.open_show_website_label),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_option_label),
),
);
},
),
);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastEpisodeList extends StatelessWidget {
final List<Episode?>? episodes;
final IconData icon;
final String emptyMessage;
final bool play;
final bool download;
static const _defaultIcon = Icons.add_alert;
const PodcastEpisodeList({
super.key,
required this.episodes,
required this.play,
required this.download,
this.icon = _defaultIcon,
this.emptyMessage = '',
});
@override
Widget build(BuildContext context) {
if (episodes != null && episodes!.isNotEmpty) {
var queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<QueueState>(
stream: queueBloc.queue,
builder: (context, snapshot) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var queued = false;
var playing = false;
var episode = episodes![index]!;
if (snapshot.hasData) {
var playingGuid = snapshot.data!.playing?.guid;
queued = snapshot.data!.queue.any((element) => element.guid == episode.guid);
playing = playingGuid == episode.guid;
}
return EpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
},
childCount: episodes!.length,
addAutomaticKeepAlives: false,
));
});
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
emptyMessage,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,54 @@
// 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 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
/// This class displays the show notes for the selected podcast.
///
/// We make use of [Html] to render the notes and, if in HTML format, display the
/// correct formatting, links etc.
class ShowNotes extends StatelessWidget {
final ScrollController _sliverScrollController = ScrollController();
final Episode episode;
ShowNotes({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: CustomScrollView(controller: _sliverScrollController, slivers: <Widget>[
SliverAppBar(
title: Text(episode.podcast!),
floating: false,
pinned: true,
snap: false,
),
SliverToBoxAdapter(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0),
child: Text(episode.title ?? '', style: textTheme.titleLarge),
),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0),
child: PodcastHtml(content: episode.content ?? episode.description!),
),
],
),
),
]));
}
}

View File

@@ -0,0 +1,532 @@
// 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/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/state/transcript_state_event.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class handles the rendering of the podcast transcript (where available).
// ignore: must_be_immutable
class TranscriptView extends StatefulWidget {
const TranscriptView({
super.key,
});
@override
State<TranscriptView> createState() => _TranscriptViewState();
}
class _TranscriptViewState extends State<TranscriptView> {
final log = Logger('TranscriptView');
final ItemScrollController _itemScrollController = ItemScrollController();
final ScrollOffsetListener _scrollOffsetListener = ScrollOffsetListener.create(recordProgrammaticScrolls: false);
final _transcriptSearchController = TextEditingController();
late StreamSubscription<PositionState> _positionSubscription;
int position = 0;
bool autoScroll = true;
bool autoScrollEnabled = true;
bool forceTranscriptUpdate = false;
bool first = true;
bool scrolling = false;
bool isHtmlTranscript = false;
String speaker = '';
RegExp exp = RegExp(r'(^)(\[?)(?<speaker>[A-Za-z0-9\s]+)(\]?)(\s?)(:)');
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
Subtitle? subtitle;
int index = 0;
// If the user initiates scrolling, disable auto scroll.
_scrollOffsetListener.changes.listen((event) {
if (!scrolling) {
setState(() {
autoScroll = false;
});
}
});
// Listen to playback position updates and scroll to the correct items in the transcript
// if we have auto scroll enabled.
_positionSubscription = audioBloc.playPosition!.listen((event) {
if (_itemScrollController.isAttached && !isHtmlTranscript) {
var transcript = event.episode?.transcript;
if (transcript != null && transcript.subtitles.isNotEmpty) {
subtitle ??= transcript.subtitles[index];
if (index == 0) {
var match = exp.firstMatch(subtitle?.data ?? '');
if (match != null) {
setState(() {
speaker = match.namedGroup('speaker') ?? '';
});
}
}
// Our we outside the range of our current transcript.
if (event.position.inMilliseconds < subtitle!.start.inMilliseconds ||
event.position.inMilliseconds > subtitle!.end!.inMilliseconds ||
forceTranscriptUpdate) {
forceTranscriptUpdate = false;
// Will the next in the list do?
if (transcript.subtitles.length > (index + 1) &&
event.position.inMilliseconds >= transcript.subtitles[index + 1].start.inMilliseconds &&
event.position.inMilliseconds < transcript.subtitles[index + 1].end!.inMilliseconds) {
index++;
subtitle = transcript.subtitles[index];
if (subtitle != null && subtitle!.speaker.isNotEmpty) {
speaker = subtitle!.speaker;
} else {
var match = exp.firstMatch(transcript.subtitles[index].data ?? '');
if (match != null) {
speaker = match.namedGroup('speaker') ?? '';
}
}
} else {
try {
subtitle = transcript.subtitles
.where((a) => (event.position.inMilliseconds >= a.start.inMilliseconds &&
event.position.inMilliseconds < a.end!.inMilliseconds))
.first;
index = transcript.subtitles.indexOf(subtitle!);
/// If we have had to jump more than one position within the transcript, we may
/// need to back scan the conversation to find the current speaker.
if (subtitle!.speaker.isNotEmpty) {
speaker = subtitle!.speaker;
} else {
/// Scan backwards a maximum of 50 lines to see if we can find a speaker
var speakFound = false;
var count = 50;
var countIndex = index;
while (!speakFound && count-- > 0 && countIndex >= 0) {
var match = exp.firstMatch(transcript.subtitles[countIndex].data!);
countIndex--;
if (match != null) {
speaker = match.namedGroup('speaker') ?? '';
if (speaker.isNotEmpty) {
setState(() {
speakFound = true;
});
}
}
}
}
} catch (e) {
// We don't have a transcript entry for this position.
}
}
if (subtitle != null) {
setState(() {
position = subtitle!.start.inMilliseconds;
});
}
if (autoScroll) {
if (first) {
_itemScrollController.jumpTo(index: index);
first = false;
} else {
scrolling = true;
_itemScrollController.scrollTo(index: index, duration: const Duration(milliseconds: 50)).then((value) {
scrolling = false;
});
}
}
}
}
}
});
}
@override
void dispose() {
super.dispose();
_positionSubscription.cancel();
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, queueSnapshot) {
return StreamBuilder<TranscriptState>(
stream: audioBloc.nowPlayingTranscript,
builder: (context, transcriptSnapshot) {
if (transcriptSnapshot.hasData) {
if (transcriptSnapshot.data is TranscriptLoadingState) {
return const Align(
alignment: Alignment.center,
child: PlatformProgressIndicator(),
);
} else if (transcriptSnapshot.data is TranscriptUnavailableState ||
!transcriptSnapshot.data!.transcript!.transcriptAvailable) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Transcript Error',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Failed to load transcript. The episode has transcript support but there was an error retrieving or parsing the transcript data.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
} else {
final items = transcriptSnapshot.data!.transcript?.subtitles ?? <Subtitle>[];
// Detect if this is an HTML transcript (single item with HTMLFULL marker)
final isLikelyHtmlTranscript = items.length == 1 &&
items.first.data != null &&
items.first.data!.startsWith('{{HTMLFULL}}');
// Update the state flag for HTML transcript detection
if (isLikelyHtmlTranscript != isHtmlTranscript) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
isHtmlTranscript = isLikelyHtmlTranscript;
if (isHtmlTranscript) {
autoScroll = false;
autoScrollEnabled = false;
}
});
});
}
return Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0, left: 16.0, right: 16.0),
child: TextField(
controller: _transcriptSearchController,
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(0.0),
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_transcriptSearchController.clear();
audioBloc.filterTranscript(TranscriptClearEvent());
setState(() {
autoScrollEnabled = true;
});
},
),
isDense: true,
filled: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderSide: BorderSide.none,
gapPadding: 0.0,
),
hintText: L.of(context)!.search_transcript_label,
),
onSubmitted: ((search) {
if (search.isNotEmpty) {
setState(() {
autoScrollEnabled = false;
autoScroll = false;
});
audioBloc.filterTranscript(TranscriptFilterEvent(search: search));
}
}),
),
),
if (!isHtmlTranscript)
Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(L.of(context)!.auto_scroll_transcript_label),
Switch(
value: autoScroll,
onChanged: autoScrollEnabled
? (bool enableAutoScroll) {
setState(() {
autoScroll = enableAutoScroll;
if (enableAutoScroll) {
forceTranscriptUpdate = true;
}
});
}
: null,
),
],
),
),
if (!isHtmlTranscript &&
queueSnapshot.hasData &&
queueSnapshot.data?.playing != null &&
queueSnapshot.data!.playing!.persons.isNotEmpty)
Container(
padding: const EdgeInsets.only(left: 16.0),
width: double.infinity,
height: 72.0,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: queueSnapshot.data!.playing!.persons.length,
itemBuilder: (BuildContext context, int index) {
var person = queueSnapshot.data!.playing!.persons[index];
var selected = false;
// Some speakers are - delimited so won't match
speaker = speaker.replaceAll('-', ' ');
if (speaker.isNotEmpty &&
person.name.toLowerCase().startsWith(speaker.toLowerCase())) {
selected = true;
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: selected ? Colors.orange : Colors.transparent, shape: BoxShape.circle),
child: CircleAvatar(
radius: 28,
backgroundImage: ExtendedImage.network(
person.image!,
cache: true,
).image,
child: const Text(''),
),
),
);
}),
),
Expanded(
/// A simple way to ensure the builder is visible before attempting to use it.
child: LayoutBuilder(builder: (context, constraints) {
return constraints.minHeight > 60.0
? Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ScrollablePositionedList.builder(
itemScrollController: _itemScrollController,
scrollOffsetListener: _scrollOffsetListener,
itemCount: items.length,
itemBuilder: (BuildContext context, int index) {
var i = items[index];
return Wrap(
children: [
SubtitleWidget(
subtitle: i,
persons: queueSnapshot.data?.playing?.persons ?? <Person>[],
highlight: i.start.inMilliseconds == position,
),
],
);
}),
)
: Container();
}),
),
],
);
}
} else {
return Container();
}
});
});
}
}
/// Each transcript is made up of one or more subtitles. Each [Subtitle] represents one
/// line of the transcript. This widget handles rendering the passed line.
class SubtitleWidget extends StatelessWidget {
final Subtitle subtitle;
final List<Person>? persons;
final bool highlight;
static const margin = Duration(milliseconds: 1000);
const SubtitleWidget({
super.key,
required this.subtitle,
this.persons,
this.highlight = false,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final data = subtitle.data ?? '';
final isFullHtmlTranscript = data.startsWith('{{HTMLFULL}}');
// For full HTML transcripts, render as a simple container without timing or clickability
if (isFullHtmlTranscript) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: _buildSubtitleContent(context),
);
}
// For timed transcripts (JSON, SRT, chunked HTML), render with timing and clickability
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
final p = subtitle.start + margin;
audioBloc.transitionPosition(p.inSeconds.toDouble());
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0),
color: highlight ? Theme.of(context).cardTheme.color : Colors.transparent,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subtitle.speaker.isEmpty
? _formatDuration(subtitle.start)
: '${_formatDuration(subtitle.start)} - ${subtitle.speaker}',
style: Theme.of(context).textTheme.titleSmall,
),
_buildSubtitleContent(context),
const Padding(padding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 16.0))
],
),
),
);
}
Widget _buildSubtitleContent(BuildContext context) {
final data = subtitle.data ?? '';
// Check if this is full HTML content (single document)
if (data.startsWith('{{HTMLFULL}}')) {
final htmlContent = data.substring(12); // Remove '{{HTMLFULL}}' marker
return Html(
data: htmlContent,
style: {
'body': Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(Theme.of(context).textTheme.bodyMedium?.fontSize ?? 14),
color: Theme.of(context).textTheme.bodyMedium?.color,
fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily,
lineHeight: const LineHeight(1.5),
),
'a': Style(
color: Theme.of(context).primaryColor,
textDecoration: TextDecoration.underline,
),
'p': Style(
margin: Margins.only(bottom: 12),
padding: HtmlPaddings.zero,
),
'h1, h2, h3, h4, h5, h6': Style(
margin: Margins.only(top: 16, bottom: 8),
fontWeight: FontWeight.bold,
),
'strong, b': Style(
fontWeight: FontWeight.bold,
),
'em, i': Style(
fontStyle: FontStyle.italic,
),
},
onLinkTap: (url, attributes, element) {
if (url != null) {
final uri = Uri.parse(url);
launchUrl(uri);
}
},
);
}
// Check if this is chunked HTML content (legacy)
else if (data.startsWith('{{HTML}}')) {
final htmlContent = data.substring(8); // Remove '{{HTML}}' marker
return Html(
data: htmlContent,
style: {
'body': Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16),
color: Theme.of(context).textTheme.titleMedium?.color,
fontFamily: Theme.of(context).textTheme.titleMedium?.fontFamily,
),
'a': Style(
color: Theme.of(context).primaryColor,
textDecoration: TextDecoration.underline,
),
'p': Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
),
},
onLinkTap: (url, attributes, element) {
if (url != null) {
final uri = Uri.parse(url);
launchUrl(uri);
}
},
);
} else {
// Render as plain text for non-HTML content
return Text(
data,
style: Theme.of(context).textTheme.titleMedium,
);
}
}
String _formatDuration(Duration duration) {
final hh = (duration.inHours).toString().padLeft(2, '0');
final mm = (duration.inMinutes % 60).toString().padLeft(2, '0');
final ss = (duration.inSeconds % 60).toString().padLeft(2, '0');
return '$hh:$mm:$ss';
}
}

View File

@@ -0,0 +1,277 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/download_button.dart';
import 'package:pinepods_mobile/ui/widgets/play_pause_button.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
/// Handles the state of the episode transport controls.
///
/// This currently consists of the [PlayControl] and [DownloadControl]
/// to handle the play/pause and download control state respectively.
class PlayControl extends StatelessWidget {
final Episode episode;
const PlayControl({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
return SizedBox(
height: 48.0,
width: 48.0,
child: StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (snapshot.hasData) {
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
if (episode.downloadState != DownloadState.downloading && episode.downloadState != DownloadState.queued) {
// If this episode is the one we are playing, allow the user
// to toggle between play and pause.
if (snapshot.hasData && nowPlaying?.guid == episode.guid) {
if (audioState == AudioState.playing) {
return InkWell(
onTap: () {
audioBloc.transitionState(TransitionState.pause);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.pause_button_label,
icon: Icons.pause,
),
);
} else if (audioState == AudioState.buffering) {
return PlayPauseBusyButton(
title: episode.title!,
label: L.of(context)!.pause_button_label,
icon: Icons.pause,
);
} else if (audioState == AudioState.pausing) {
return InkWell(
onTap: () {
audioBloc.transitionState(TransitionState.play);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
}
// If this episode is not the one we are playing, allow the
// user to start playing this episode.
return InkWell(
onTap: () {
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
} else {
// We are currently downloading this episode. Do not allow
// the user to play it until the download is complete.
return Opacity(
opacity: 0.2,
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
} else {
// We have no playing information at the moment. Show a play button
// until the stream wakes up.
if (episode.downloadState != DownloadState.downloading) {
return InkWell(
onTap: () {
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
} else {
return Opacity(
opacity: 0.2,
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
}
}),
);
}
}
class DownloadControl extends StatelessWidget {
final Episode episode;
const DownloadControl({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
return SizedBox(
height: 48.0,
width: 48.0,
child: StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (snapshot.hasData) {
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
if (nowPlaying?.guid == episode.guid &&
(audioState == AudioState.playing || audioState == AudioState.buffering)) {
if (episode.downloadState != DownloadState.downloaded) {
return Opacity(
opacity: 0.2,
child: DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.save_alt,
percent: 0,
label: L.of(context)!.download_episode_button_label,
),
);
} else {
return Opacity(
opacity: 0.2,
child: DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.check,
percent: 0,
label: L.of(context)!.download_episode_button_label,
),
);
}
}
}
if (episode.downloadState == DownloadState.downloaded) {
return DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.check,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
} else if (episode.downloadState == DownloadState.queued) {
return DownloadButton(
onPressed: () => _showCancelDialog(context),
title: episode.title!,
icon: Icons.timer_outlined,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
} else if (episode.downloadState == DownloadState.downloading) {
return DownloadButton(
onPressed: () => _showCancelDialog(context),
title: episode.title!,
icon: Icons.timer_outlined,
percent: episode.downloadPercentage!,
label: L.of(context)!.download_episode_button_label,
);
}
return DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.save_alt,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
}),
);
}
Future<void> _showCancelDialog(BuildContext context) {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
return showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.stop_download_title,
),
content: Text(L.of(context)!.stop_download_confirmation),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.continue_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.stop_download_button_label,
),
iosIsDefaultAction: true,
onPressed: () {
episodeBloc.deleteDownload(episode);
Navigator.pop(context);
},
),
],
),
);
}
}
/// This class acts as a wrapper between the current audio state and
/// downloadables. Saves all that nesting of StreamBuilders.
class _PlayerControlState {
final AudioState audioState;
final Episode? episode;
_PlayerControlState(this.audioState, this.episode);
}

View File

@@ -0,0 +1,185 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_episode_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
/// This class is responsible for rendering the Up Next queue feature.
///
/// The user can see the currently playing item and the current queue. The user can
/// re-arrange items in the queue, remove individual items or completely clear the queue.
class UpNextView extends StatelessWidget {
const UpNextView({
super.key,
});
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, snapshot) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
child: Text(
L.of(context)!.now_playing_queue_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0),
child: DraggableEpisodeTile(
key: const Key('detileplaying'),
episode: snapshot.data!.playing!,
draggable: false,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: Text(
L.of(context)!.up_next_queue_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: TextButton(
onPressed: snapshot.hasData && snapshot.data!.queue.isEmpty
? null
: () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.queue_clear_label_title,
),
content: Text(L.of(context)!.queue_clear_label),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.cancel_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
Theme.of(context).platform == TargetPlatform.iOS
? L.of(context)!.queue_clear_button_label.toUpperCase()
: L.of(context)!.queue_clear_button_label,
),
iosIsDefaultAction: true,
iosIsDestructiveAction: true,
onPressed: () {
queueBloc.queueEvent(QueueClearEvent());
Navigator.pop(context);
},
),
],
),
);
},
child: snapshot.hasData && snapshot.data!.queue.isEmpty
? Text(
L.of(context)!.clear_queue_button_label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: Theme.of(context).disabledColor,
),
)
: Text(
L.of(context)!.clear_queue_button_label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: Theme.of(context).primaryColor,
),
),
),
),
],
),
snapshot.hasData && snapshot.data!.queue.isEmpty
? Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: const BorderRadius.all(Radius.circular(10))),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
L.of(context)!.empty_queue_message,
style: Theme.of(context).textTheme.titleMedium,
),
),
),
)
: Expanded(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: snapshot.hasData ? snapshot.data!.queue.length : 0,
itemBuilder: (BuildContext context, int index) {
return Dismissible(
key: ValueKey('disqueue${snapshot.data!.queue[index].guid}'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
queueBloc.queueEvent(QueueRemoveEvent(episode: snapshot.data!.queue[index]));
},
child: DraggableEpisodeTile(
key: ValueKey('tilequeue${snapshot.data!.queue[index].guid}'),
index: index,
episode: snapshot.data!.queue[index],
playable: true,
),
);
},
onReorder: (int oldIndex, int newIndex) {
/// Seems odd to have to do this, but this -1 was taken from
/// the Flutter docs.
if (oldIndex < newIndex) {
newIndex -= 1;
}
queueBloc.queueEvent(QueueMoveEvent(
episode: snapshot.data!.queue[oldIndex],
oldIndex: oldIndex,
newIndex: newIndex,
));
},
),
),
],
);
});
}
}