first commit
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/music_player_background_task.dart';
|
||||
import '../../screens/add_to_playlist_screen.dart';
|
||||
|
||||
class AddToPlaylistButton extends StatelessWidget {
|
||||
const AddToPlaylistButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return IconButton(
|
||||
onPressed: () => Navigator.of(context).pushReplacementNamed(
|
||||
AddToPlaylistScreen.routeName,
|
||||
arguments:
|
||||
BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"])
|
||||
.id),
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip,
|
||||
);
|
||||
} else {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
onPressed: null,
|
||||
tooltip: AppLocalizations.of(context)!.addToPlaylistTooltip,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
|
||||
class PlaybackMode extends StatelessWidget {
|
||||
const PlaybackMode({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
late String onlineOrOffline;
|
||||
late String transcodeOrDirect;
|
||||
if (snapshot.data!.extras!["downloadedSongJson"] == null) {
|
||||
onlineOrOffline = AppLocalizations.of(context)!.streaming;
|
||||
} else {
|
||||
onlineOrOffline = AppLocalizations.of(context)!.downloaded;
|
||||
}
|
||||
|
||||
if (snapshot.data!.extras!["shouldTranscode"] &&
|
||||
snapshot.data!.extras!["downloadedSongJson"] == null) {
|
||||
transcodeOrDirect = AppLocalizations.of(context)!.transcode;
|
||||
} else {
|
||||
transcodeOrDirect = AppLocalizations.of(context)!.direct;
|
||||
}
|
||||
|
||||
return Text(
|
||||
"$onlineOrOffline\n$transcodeOrDirect",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(
|
||||
AppLocalizations.of(context)!.statusError,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
AppLocalizations.of(context)!.noItem.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
|
||||
class PlayerButtons extends StatelessWidget {
|
||||
const PlayerButtons({Key? key}) : super(key: key);
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return StreamBuilder<PlaybackState>(
|
||||
stream: audioHandler.playbackState,
|
||||
builder: (context, snapshot) {
|
||||
final PlaybackState? playbackState = snapshot.data;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
textDirection: TextDirection.ltr,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: playbackState?.shuffleMode == AudioServiceShuffleMode.all
|
||||
? AppLocalizations.of(context)!.playbackOrderShuffledTooltip
|
||||
: AppLocalizations.of(context)!.playbackOrderLinearTooltip,
|
||||
icon: _getShufflingIcon(
|
||||
playbackState == null
|
||||
? AudioServiceShuffleMode.none
|
||||
: playbackState.shuffleMode,
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
onPressed: playbackState != null
|
||||
? () async {
|
||||
if (playbackState.shuffleMode ==
|
||||
AudioServiceShuffleMode.all) {
|
||||
await audioHandler
|
||||
.setShuffleMode(AudioServiceShuffleMode.none);
|
||||
} else {
|
||||
await audioHandler
|
||||
.setShuffleMode(AudioServiceShuffleMode.all);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
iconSize: 20,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: AppLocalizations.of(context)!.skipToPrevious,
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: playbackState != null
|
||||
? () async => await audioHandler.skipToPrevious()
|
||||
: null,
|
||||
iconSize: 36,
|
||||
),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
width: 56,
|
||||
child: FloatingActionButton(
|
||||
tooltip: AppLocalizations.of(context)!.togglePlayback,
|
||||
// We set a heroTag because otherwise the play button on AlbumScreenContent will do hero widget stuff
|
||||
heroTag: "PlayerScreenFAB",
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
||||
splashColor:
|
||||
Theme.of(context).colorScheme.onPrimary.withOpacity(0.24),
|
||||
onPressed: playbackState != null
|
||||
? () async {
|
||||
if (playbackState.playing) {
|
||||
await audioHandler.pause();
|
||||
} else {
|
||||
await audioHandler.play();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Icon(
|
||||
playbackState == null || playbackState.playing
|
||||
? Icons.pause
|
||||
: Icons.play_arrow,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: AppLocalizations.of(context)!.skipToNext,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: playbackState != null
|
||||
? () async => audioHandler.skipToNext()
|
||||
: null,
|
||||
iconSize: 36),
|
||||
IconButton(
|
||||
tooltip: playbackState?.repeatMode == AudioServiceRepeatMode.all
|
||||
? AppLocalizations.of(context)!.loopModeAllTooltip
|
||||
: playbackState?.repeatMode == AudioServiceRepeatMode.one
|
||||
? AppLocalizations.of(context)!.loopModeOneTooltip
|
||||
: AppLocalizations.of(context)!.loopModeNoneTooltip,
|
||||
icon: _getRepeatingIcon(
|
||||
playbackState == null
|
||||
? AudioServiceRepeatMode.none
|
||||
: playbackState.repeatMode,
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
onPressed: playbackState != null
|
||||
? () async {
|
||||
// Cyles from none -> all -> one
|
||||
if (playbackState.repeatMode ==
|
||||
AudioServiceRepeatMode.none) {
|
||||
await audioHandler
|
||||
.setRepeatMode(AudioServiceRepeatMode.all);
|
||||
} else if (playbackState.repeatMode ==
|
||||
AudioServiceRepeatMode.all) {
|
||||
await audioHandler
|
||||
.setRepeatMode(AudioServiceRepeatMode.one);
|
||||
} else {
|
||||
await audioHandler
|
||||
.setRepeatMode(AudioServiceRepeatMode.none);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getRepeatingIcon(
|
||||
AudioServiceRepeatMode repeatMode, Color iconColour) {
|
||||
if (repeatMode == AudioServiceRepeatMode.all) {
|
||||
return Icon(Icons.repeat, color: iconColour);
|
||||
} else if (repeatMode == AudioServiceRepeatMode.one) {
|
||||
return Icon(Icons.repeat_one, color: iconColour);
|
||||
} else {
|
||||
return const Icon(Icons.repeat);
|
||||
}
|
||||
}
|
||||
|
||||
Icon _getShufflingIcon(
|
||||
AudioServiceShuffleMode shuffleMode, Color iconColour) {
|
||||
if (shuffleMode == AudioServiceShuffleMode.all) {
|
||||
return Icon(Icons.shuffle, color: iconColour);
|
||||
} else {
|
||||
return const Icon(Icons.shuffle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
import '../../services/progress_state_stream.dart';
|
||||
import '../print_duration.dart';
|
||||
|
||||
class ProgressSlider extends StatefulWidget {
|
||||
const ProgressSlider({
|
||||
Key? key,
|
||||
this.allowSeeking = true,
|
||||
this.showBuffer = true,
|
||||
this.showDuration = true,
|
||||
this.showPlaceholder = true,
|
||||
}) : super(key: key);
|
||||
|
||||
final bool allowSeeking;
|
||||
final bool showBuffer;
|
||||
final bool showDuration;
|
||||
final bool showPlaceholder;
|
||||
|
||||
@override
|
||||
State<ProgressSlider> createState() => _ProgressSliderState();
|
||||
}
|
||||
|
||||
class _ProgressSliderState extends State<ProgressSlider> {
|
||||
/// Value used to hold the slider's value when dragging.
|
||||
double? _dragValue;
|
||||
|
||||
late SliderThemeData _sliderThemeData;
|
||||
|
||||
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
_sliderThemeData = SliderTheme.of(context).copyWith(
|
||||
trackHeight: 4.0,
|
||||
inactiveTrackColor:
|
||||
Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The slider always needs to be LTR, so we use Directionality to save
|
||||
// putting TextDirection.ltr all over the place
|
||||
return Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
// The slider can refresh up to 60 times per second, so we wrap it in a
|
||||
// RepaintBoundary to avoid more areas being repainted than necessary
|
||||
child: RepaintBoundary(
|
||||
child: StreamBuilder<ProgressState>(
|
||||
stream: progressStateStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data?.mediaItem == null) {
|
||||
// If nothing is playing or the AudioService isn't connected, return a
|
||||
// greyed out slider with some fake numbers. We also do this if
|
||||
// currentPosition is null, which sometimes happens when the app is
|
||||
// closed and reopened.
|
||||
return widget.showPlaceholder
|
||||
? Column(
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: _sliderThemeData.copyWith(
|
||||
trackShape: CustomTrackShape(),
|
||||
),
|
||||
child: const Slider(
|
||||
value: 0,
|
||||
max: 1,
|
||||
onChanged: null,
|
||||
),
|
||||
),
|
||||
if (widget.showDuration)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"00:00",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color),
|
||||
),
|
||||
Text(
|
||||
"00:00",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
} else if (snapshot.hasData) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Slider displaying playback and buffering progress.
|
||||
SliderTheme(
|
||||
data: widget.allowSeeking
|
||||
? _sliderThemeData.copyWith(
|
||||
trackShape: CustomTrackShape(),
|
||||
)
|
||||
: _sliderThemeData.copyWith(
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 0),
|
||||
// gets rid of both horizontal and vertical padding
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 0),
|
||||
trackShape: const RectangularSliderTrackShape(),
|
||||
),
|
||||
child: Slider(
|
||||
min: 0.0,
|
||||
max: snapshot.data!.mediaItem?.duration == null
|
||||
? snapshot.data!.playbackState.bufferedPosition
|
||||
.inMicroseconds
|
||||
.toDouble()
|
||||
: snapshot.data!.mediaItem!.duration!.inMicroseconds
|
||||
.toDouble(),
|
||||
value: (_dragValue ??
|
||||
snapshot.data!.position.inMicroseconds)
|
||||
.clamp(
|
||||
0,
|
||||
snapshot.data!.mediaItem!.duration!.inMicroseconds
|
||||
.toDouble())
|
||||
.toDouble(),
|
||||
secondaryTrackValue: widget.showBuffer &&
|
||||
snapshot.data!.mediaItem
|
||||
?.extras?["downloadedSongJson"] ==
|
||||
null
|
||||
? snapshot.data!.playbackState.bufferedPosition
|
||||
.inMicroseconds
|
||||
.clamp(
|
||||
0.0,
|
||||
snapshot.data!.mediaItem!.duration == null
|
||||
? snapshot.data!.playbackState
|
||||
.bufferedPosition.inMicroseconds
|
||||
: snapshot.data!.mediaItem!.duration!
|
||||
.inMicroseconds,
|
||||
)
|
||||
.toDouble()
|
||||
: 0,
|
||||
onChanged: widget.allowSeeking
|
||||
? (newValue) async {
|
||||
// We don't actually tell audio_service to seek here
|
||||
// because it would get flooded with seek requests
|
||||
setState(() {
|
||||
_dragValue = newValue;
|
||||
});
|
||||
}
|
||||
: (_) {},
|
||||
onChangeStart: widget.allowSeeking
|
||||
? (value) {
|
||||
setState(() {
|
||||
_dragValue = value;
|
||||
});
|
||||
}
|
||||
: (_) {},
|
||||
onChangeEnd: widget.allowSeeking
|
||||
? (newValue) async {
|
||||
// Seek to the new position
|
||||
await _audioHandler.seek(
|
||||
Duration(microseconds: newValue.toInt()));
|
||||
|
||||
// Clear drag value so that the slider uses the play
|
||||
// duration again.
|
||||
setState(() {
|
||||
_dragValue = null;
|
||||
});
|
||||
}
|
||||
: (_) {},
|
||||
),
|
||||
),
|
||||
if (widget.showDuration)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
printDuration(
|
||||
Duration(
|
||||
microseconds: _dragValue?.toInt() ??
|
||||
snapshot.data!.position.inMicroseconds),
|
||||
),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color),
|
||||
),
|
||||
Text(
|
||||
printDuration(snapshot.data!.mediaItem?.duration),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const Text(
|
||||
"Snapshot doesn't have data and MediaItem isn't null and AudioService is connected?");
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PositionData {
|
||||
final Duration position;
|
||||
final Duration bufferedPosition;
|
||||
|
||||
PositionData(this.position, this.bufferedPosition);
|
||||
}
|
||||
|
||||
/// Track shape used to remove horizontal padding.
|
||||
/// https://github.com/flutter/flutter/issues/37057
|
||||
class CustomTrackShape extends RoundedRectSliderTrackShape {
|
||||
@override
|
||||
Rect getPreferredRect({
|
||||
required RenderBox parentBox,
|
||||
Offset offset = Offset.zero,
|
||||
required SliderThemeData sliderTheme,
|
||||
bool isEnabled = false,
|
||||
bool isDiscrete = false,
|
||||
}) {
|
||||
final double trackHeight = sliderTheme.trackHeight!;
|
||||
final double trackLeft = offset.dx;
|
||||
final double trackTop =
|
||||
offset.dy + (parentBox.size.height - trackHeight) / 2;
|
||||
final double trackWidth = parentBox.size.width;
|
||||
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
|
||||
}
|
||||
|
||||
/// Disable additionalActiveTrackHeight
|
||||
@override
|
||||
void paint(
|
||||
PaintingContext context,
|
||||
Offset offset, {
|
||||
required RenderBox parentBox,
|
||||
required SliderThemeData sliderTheme,
|
||||
required Animation<double> enableAnimation,
|
||||
required TextDirection textDirection,
|
||||
required Offset thumbCenter,
|
||||
Offset? secondaryOffset,
|
||||
bool isDiscrete = false,
|
||||
bool isEnabled = false,
|
||||
double additionalActiveTrackHeight = 0,
|
||||
}) {
|
||||
super.paint(
|
||||
context,
|
||||
offset,
|
||||
parentBox: parentBox,
|
||||
sliderTheme: sliderTheme,
|
||||
enableAnimation: enableAnimation,
|
||||
textDirection: textDirection,
|
||||
thumbCenter: thumbCenter,
|
||||
secondaryOffset: secondaryOffset,
|
||||
isDiscrete: isDiscrete,
|
||||
isEnabled: isEnabled,
|
||||
additionalActiveTrackHeight: additionalActiveTrackHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'queue_list.dart';
|
||||
|
||||
class QueueButton extends StatelessWidget {
|
||||
const QueueButton({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
tooltip: AppLocalizations.of(context)!.queue,
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24.0)),
|
||||
),
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return QueueList(
|
||||
scrollController: scrollController,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../album_image.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/process_artist.dart';
|
||||
import '../../services/media_state_stream.dart';
|
||||
import '../../services/music_player_background_task.dart';
|
||||
|
||||
class _QueueListStreamState {
|
||||
_QueueListStreamState(
|
||||
this.queue,
|
||||
this.mediaState,
|
||||
);
|
||||
|
||||
final List<MediaItem>? queue;
|
||||
final MediaState mediaState;
|
||||
}
|
||||
|
||||
class QueueList extends StatefulWidget {
|
||||
const QueueList({Key? key, required this.scrollController}) : super(key: key);
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<QueueList> createState() => _QueueListState();
|
||||
}
|
||||
|
||||
class _QueueListState extends State<QueueList> {
|
||||
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
List<MediaItem>? _queue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<_QueueListStreamState>(
|
||||
// stream: AudioService.queueStream,
|
||||
stream: Rx.combineLatest2<List<MediaItem>?, MediaState,
|
||||
_QueueListStreamState>(_audioHandler.queue, mediaStateStream,
|
||||
(a, b) => _QueueListStreamState(a, b)),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
_queue ??= snapshot.data!.queue;
|
||||
return PrimaryScrollController(
|
||||
controller: widget.scrollController,
|
||||
child: ReorderableListView.builder(
|
||||
itemCount: snapshot.data!.queue?.length ?? 0,
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
setState(() {
|
||||
// _queue?.insert(newIndex, _queue![oldIndex]);
|
||||
// _queue?.removeAt(oldIndex);
|
||||
int? smallerThanNewIndex;
|
||||
if (oldIndex < newIndex) {
|
||||
// When we're moving an item backwards, we need to reduce
|
||||
// newIndex by 1 to account for there being a new item added
|
||||
// before newIndex.
|
||||
smallerThanNewIndex = newIndex - 1;
|
||||
}
|
||||
final item = _queue?.removeAt(oldIndex);
|
||||
_queue?.insert(smallerThanNewIndex ?? newIndex, item!);
|
||||
});
|
||||
await _audioHandler.reorderQueue(oldIndex, newIndex);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final actualIndex =
|
||||
_audioHandler.playbackState.valueOrNull?.shuffleMode ==
|
||||
AudioServiceShuffleMode.all
|
||||
? _audioHandler.shuffleIndices![index]
|
||||
: index;
|
||||
return Dismissible(
|
||||
key: ValueKey(snapshot.data!.queue![actualIndex].id),
|
||||
direction: FinampSettingsHelper.finampSettings.disableGesture
|
||||
? DismissDirection.none
|
||||
: DismissDirection.horizontal,
|
||||
onDismissed: (direction) async {
|
||||
await _audioHandler.removeQueueItemAt(actualIndex);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: AlbumImage(
|
||||
item: snapshot.data!.queue?[actualIndex]
|
||||
.extras?["itemJson"] ==
|
||||
null
|
||||
? null
|
||||
: BaseItemDto.fromJson(snapshot
|
||||
.data!.queue?[actualIndex].extras?["itemJson"]),
|
||||
),
|
||||
title: Text(
|
||||
snapshot.data!.queue?[actualIndex].title ??
|
||||
AppLocalizations.of(context)!.unknownName,
|
||||
style: snapshot.data!.mediaState.mediaItem ==
|
||||
snapshot.data!.queue?[actualIndex]
|
||||
? TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary)
|
||||
: null),
|
||||
subtitle: Text(processArtist(
|
||||
snapshot.data!.queue?[actualIndex].artist, context)),
|
||||
onTap: () async =>
|
||||
await _audioHandler.skipToIndex(actualIndex),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
import 'sleep_timer_dialog.dart';
|
||||
import 'sleep_timer_cancel_dialog.dart';
|
||||
|
||||
class SleepTimerButton extends StatelessWidget {
|
||||
const SleepTimerButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return ValueListenableBuilder<Timer?>(
|
||||
valueListenable: audioHandler.sleepTimer,
|
||||
builder: (context, value, child) {
|
||||
return IconButton(
|
||||
icon: value == null
|
||||
? const Icon(Icons.mode_night_outlined)
|
||||
: const Icon(Icons.mode_night),
|
||||
tooltip: AppLocalizations.of(context)!.sleepTimerTooltip,
|
||||
onPressed: () async {
|
||||
if (value != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SleepTimerCancelDialog(),
|
||||
);
|
||||
} else {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => const SleepTimerDialog(),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
|
||||
class SleepTimerCancelDialog extends StatelessWidget {
|
||||
const SleepTimerCancelDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.cancelSleepTimer),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.noButtonLabel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.yesButtonLabel),
|
||||
onPressed: () {
|
||||
audioHandler.clearSleepTimer();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/music_player_background_task.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class SleepTimerDialog extends StatefulWidget {
|
||||
const SleepTimerDialog({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SleepTimerDialog> createState() => _SleepTimerDialogState();
|
||||
}
|
||||
|
||||
class _SleepTimerDialogState extends State<SleepTimerDialog> {
|
||||
final _audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
final _textController = TextEditingController(
|
||||
text: (FinampSettingsHelper.finampSettings.sleepTimerSeconds ~/ 60)
|
||||
.toString());
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.setSleepTimer),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _textController,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.minutes),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return AppLocalizations.of(context)!.required;
|
||||
}
|
||||
|
||||
if (int.tryParse(value) == null) {
|
||||
return AppLocalizations.of(context)!.invalidNumber;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (value) {
|
||||
final valueInt = int.parse(value!);
|
||||
|
||||
_audioHandler.setSleepTimer(Duration(minutes: valueInt));
|
||||
FinampSettingsHelper.setSleepTimerSeconds(valueInt * 60);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
onPressed: () {
|
||||
if (_formKey.currentState?.validate() == true) {
|
||||
_formKey.currentState!.save();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:finamp/screens/artist_screen.dart';
|
||||
import 'package:finamp/services/finamp_settings_helper.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../screens/album_screen.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/music_player_background_task.dart';
|
||||
import '../artists_text_spans.dart';
|
||||
|
||||
/// Creates some text that shows the song's name, album and the artist.
|
||||
class SongName extends StatelessWidget {
|
||||
const SongName({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
final JellyfinApiHelper jellyfinApiHelper =
|
||||
GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
final textColour =
|
||||
Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6);
|
||||
|
||||
return StreamBuilder<MediaItem?>(
|
||||
stream: audioHandler.mediaItem,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final MediaItem mediaItem = snapshot.data!;
|
||||
BaseItemDto songBaseItemDto =
|
||||
BaseItemDto.fromJson(mediaItem.extras!["itemJson"]);
|
||||
|
||||
List<TextSpan> separatedArtistTextSpans = [];
|
||||
|
||||
if (songBaseItemDto.artistItems?.isEmpty ?? true) {
|
||||
separatedArtistTextSpans = [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)!.unknownArtist,
|
||||
style: TextStyle(color: textColour),
|
||||
)
|
||||
];
|
||||
} else {
|
||||
songBaseItemDto.artistItems
|
||||
?.map((e) => TextSpan(
|
||||
text: e.name,
|
||||
style: TextStyle(color: textColour),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
// Offline artists aren't implemented yet so we return if
|
||||
// offline
|
||||
if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
return;
|
||||
}
|
||||
|
||||
jellyfinApiHelper.getItemById(e.id).then((artist) =>
|
||||
Navigator.of(context).popAndPushNamed(
|
||||
ArtistScreen.routeName,
|
||||
arguments: artist));
|
||||
}))
|
||||
.forEach((artistTextSpan) {
|
||||
separatedArtistTextSpans.add(artistTextSpan);
|
||||
separatedArtistTextSpans.add(TextSpan(
|
||||
text: ", ",
|
||||
style: TextStyle(color: textColour),
|
||||
));
|
||||
});
|
||||
separatedArtistTextSpans.removeLast();
|
||||
}
|
||||
|
||||
return SongNameContent(
|
||||
songBaseItemDto: songBaseItemDto,
|
||||
mediaItem: mediaItem,
|
||||
separatedArtistTextSpans: ArtistsTextSpans(
|
||||
songBaseItemDto,
|
||||
textColour,
|
||||
context,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
return const SongNameContent(
|
||||
songBaseItemDto: null,
|
||||
mediaItem: null,
|
||||
separatedArtistTextSpans: [],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SongNameContent extends StatelessWidget {
|
||||
const SongNameContent({
|
||||
Key? key,
|
||||
required this.songBaseItemDto,
|
||||
required this.mediaItem,
|
||||
required this.separatedArtistTextSpans,
|
||||
}) : super(key: key);
|
||||
final BaseItemDto? songBaseItemDto;
|
||||
final MediaItem? mediaItem;
|
||||
final List<TextSpan> separatedArtistTextSpans;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
final textColour =
|
||||
Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6);
|
||||
|
||||
return Padding(
|
||||
// I don't know why but 12 is the magic number that lines up with the
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: songBaseItemDto == null
|
||||
? null
|
||||
: () => jellyfinApiHelper
|
||||
.getItemById(songBaseItemDto!.albumId as String)
|
||||
.then((album) => Navigator.of(context).popAndPushNamed(
|
||||
AlbumScreen.routeName,
|
||||
arguments: album)),
|
||||
child: Text(
|
||||
mediaItem == null
|
||||
? AppLocalizations.of(context)!.noAlbum
|
||||
: mediaItem!.album ?? AppLocalizations.of(context)!.noAlbum,
|
||||
style: TextStyle(color: textColour),
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(vertical: 2)),
|
||||
Text(
|
||||
mediaItem == null
|
||||
? AppLocalizations.of(context)!.noItem
|
||||
: mediaItem!.title,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.fade,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(vertical: 2)),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
children: mediaItem == null || mediaItem!.artist == null
|
||||
? [
|
||||
TextSpan(
|
||||
text: AppLocalizations.of(context)!.noArtist,
|
||||
style: TextStyle(color: textColour),
|
||||
)
|
||||
]
|
||||
: separatedArtistTextSpans,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user