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

318 lines
13 KiB
Dart

// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '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(),
],
),
),
],
),
),
),
);
}),
);
});
}
}