first commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const jellyfinBlueColor = Color(0xFF00A4DC);
|
||||
const jellyfinPurpleColor = Color(0xFFAA5CC3);
|
||||
|
||||
const lightColorScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
// Primary
|
||||
primary: Color(0xFF00668A),
|
||||
onPrimary: Color(0xFFFFFFFF),
|
||||
primaryContainer: Color(0xFFC4E8FF),
|
||||
onPrimaryContainer: Color(0xFF001E2C),
|
||||
// Secondary
|
||||
secondary: Color(0xFF406374),
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondaryContainer: Color(0xFFCCE8F8),
|
||||
onSecondaryContainer: Color(0xFF1B333F),
|
||||
// Tertiary
|
||||
tertiary: Color(0xFF893DA2),
|
||||
onTertiary: Color(0xFFFFFFFF),
|
||||
tertiaryContainer: Color(0xFFFAD7FF),
|
||||
onTertiaryContainer: Color(0xFF330044),
|
||||
// Error
|
||||
error: Color(0xFFBA1A1A),
|
||||
errorContainer: Color(0xFFFFDAD6),
|
||||
onError: Color(0xFFFFFFFF),
|
||||
onErrorContainer: Color(0xFF410002),
|
||||
// Background & Surface
|
||||
background: Color(0xFFFCFDFE),
|
||||
onBackground: Color(0xFF191C1E),
|
||||
surface: Color(0xFFFCFDFE),
|
||||
onSurface: Color(0xFF191C1E),
|
||||
surfaceVariant: Color(0xFFDDE4E8),
|
||||
onSurfaceVariant: Color(0xFF41484D),
|
||||
// Other colors
|
||||
outline: Color(0xFF727A7F),
|
||||
onInverseSurface: Color(0xFFF0F1F3),
|
||||
inverseSurface: Color(0xFF2E3133),
|
||||
inversePrimary: Color(0xFF7BD0FF),
|
||||
shadow: Color(0xFF000000),
|
||||
surfaceTint: Color(0xFF00668A),
|
||||
outlineVariant: Color(0xFFC0C7CD),
|
||||
scrim: Color(0xFF000000),
|
||||
);
|
||||
|
||||
const darkColorScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
// Primary
|
||||
primary: jellyfinBlueColor,
|
||||
onPrimary: Color(0xFF001E2C),
|
||||
primaryContainer: Color(0xFF004C68),
|
||||
onPrimaryContainer: Color(0xFFC3E7FF),
|
||||
// Secondary
|
||||
secondary: Color(0xFF60B4DD),
|
||||
onSecondary: Color(0xFF112732),
|
||||
secondaryContainer: Color(0xFF206B8C),
|
||||
onSecondaryContainer: Color(0xFFCEEEFF),
|
||||
// Tertiary
|
||||
tertiary: Color(0xFFC979E2),
|
||||
onTertiary: Color(0xFF3D0050),
|
||||
tertiaryContainer: Color(0xFF762A90),
|
||||
onTertiaryContainer: Color(0xFFFAD7FF),
|
||||
// Error
|
||||
error: Color(0xFFFFB4AB),
|
||||
errorContainer: Color(0xFF93000A),
|
||||
onError: Color(0xFF690005),
|
||||
onErrorContainer: Color(0xFFFFDAD6),
|
||||
// Background & Surface
|
||||
background: Color(0xFF101315),
|
||||
onBackground: Color(0xFFE1E2E5),
|
||||
surface: Color(0xFF101315),
|
||||
onSurface: Color(0xFFE1E2E5),
|
||||
surfaceVariant: Color(0xFF333A3E),
|
||||
onSurfaceVariant: Color(0xFFC0C7CD),
|
||||
// Other colors
|
||||
outline: Color(0xFF80878C),
|
||||
onInverseSurface: Color(0xFF191C1E),
|
||||
inverseSurface: Color(0xFFE1E2E5),
|
||||
inversePrimary: Color(0xFF00668A),
|
||||
shadow: Color(0xFF000000),
|
||||
surfaceTint: Color(0xFF7BD0FF),
|
||||
outlineVariant: Color(0xFF41484D),
|
||||
scrim: Color(0xFF000000),
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class AppDirectoryLocationForm extends StatefulWidget {
|
||||
const AppDirectoryLocationForm({Key? key, required this.formKey})
|
||||
: super(key: key);
|
||||
|
||||
final Key formKey;
|
||||
|
||||
@override
|
||||
State<AppDirectoryLocationForm> createState() =>
|
||||
_AppDirectoryLocationFormState();
|
||||
}
|
||||
|
||||
class _AppDirectoryLocationFormState extends State<AppDirectoryLocationForm> {
|
||||
Directory? selectedDirectory;
|
||||
late Future<List<Directory>?> externalStorageListFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
externalStorageListFuture = getExternalStorageDirectories();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: widget.formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FutureBuilder<List<Directory>?>(
|
||||
future: externalStorageListFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return const Text("No external directories.");
|
||||
}
|
||||
List<DropdownMenuItem<Directory>> dropdownButtonItems =
|
||||
snapshot.data!
|
||||
.map((e) => DropdownMenuItem(
|
||||
value: e,
|
||||
child: Text(
|
||||
e.path,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
))
|
||||
.toList();
|
||||
return DropdownButtonFormField<Directory>(
|
||||
items: dropdownButtonItems,
|
||||
hint: const Text("Location"),
|
||||
isExpanded: true,
|
||||
value: selectedDirectory,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedDirectory = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return "Required";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (newValue) {
|
||||
if (newValue != null) {
|
||||
context.read<NewDownloadLocation>().path = newValue.path;
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Text(snapshot.error.toString());
|
||||
} else {
|
||||
return const CircularProgressIndicator.adaptive();
|
||||
}
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(labelText: "Name (required)"),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return "Required";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (newValue) {
|
||||
if (newValue != null) {
|
||||
context.read<NewDownloadLocation>().name = newValue;
|
||||
}
|
||||
},
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8.0)),
|
||||
Text(
|
||||
"If the path doesn't contain \"emulated\", it is proably external storage.",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class CustomDownloadLocationForm extends StatefulWidget {
|
||||
const CustomDownloadLocationForm({Key? key, required this.formKey})
|
||||
: super(key: key);
|
||||
|
||||
final Key formKey;
|
||||
|
||||
@override
|
||||
State<CustomDownloadLocationForm> createState() =>
|
||||
_CustomDownloadLocationFormState();
|
||||
}
|
||||
|
||||
class _CustomDownloadLocationFormState
|
||||
extends State<CustomDownloadLocationForm> {
|
||||
Directory? selectedDirectory;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Form(
|
||||
key: widget.formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FormField<Directory>(
|
||||
builder: (field) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedDirectory == null
|
||||
? AppLocalizations.of(context)!
|
||||
.selectDirectory
|
||||
: selectedDirectory!.path.replaceFirst(
|
||||
"${selectedDirectory!.parent.path}/",
|
||||
""),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: selectedDirectory == null
|
||||
? Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
)
|
||||
: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder),
|
||||
onPressed: () async {
|
||||
String? newPath = await FilePicker.platform
|
||||
.getDirectoryPath();
|
||||
|
||||
if (newPath != null) {
|
||||
setState(() {
|
||||
selectedDirectory = Directory(newPath);
|
||||
});
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (field.hasError)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 4, 0, 0),
|
||||
child: Text(
|
||||
field.errorText ??
|
||||
AppLocalizations.of(context)!.unknownError,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
validator: (_) {
|
||||
if (selectedDirectory == null) {
|
||||
return AppLocalizations.of(context)!.required;
|
||||
}
|
||||
|
||||
// There are a load of null checks here, but since we've already
|
||||
// checked if selectedDirectory is null we should be fine.
|
||||
|
||||
if (selectedDirectory!.path == "/") {
|
||||
return AppLocalizations.of(context)!
|
||||
.pathReturnSlashErrorMessage;
|
||||
}
|
||||
|
||||
// This checks if the chosen directory is empty
|
||||
if (selectedDirectory!
|
||||
.listSync()
|
||||
.where((event) => !event.path
|
||||
.replaceFirst(selectedDirectory!.path, "")
|
||||
.contains("."))
|
||||
.isNotEmpty) {
|
||||
return AppLocalizations.of(context)!.directoryMustBeEmpty;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (_) {
|
||||
if (selectedDirectory != null) {
|
||||
context.read<NewDownloadLocation>().path =
|
||||
selectedDirectory!.path;
|
||||
}
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
decoration:
|
||||
InputDecoration(labelText: AppLocalizations.of(context)!.name),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return AppLocalizations.of(context)!.required;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (newValue) {
|
||||
if (newValue != null) {
|
||||
context.read<NewDownloadLocation>().name = newValue;
|
||||
}
|
||||
},
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8.0)),
|
||||
Text(AppLocalizations.of(context)!.customLocationsBuggy,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../MusicScreen/album_item.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class AddToPlaylistList extends StatefulWidget {
|
||||
const AddToPlaylistList({
|
||||
Key? key,
|
||||
required this.itemToAddId,
|
||||
}) : super(key: key);
|
||||
|
||||
final String itemToAddId;
|
||||
|
||||
@override
|
||||
State<AddToPlaylistList> createState() => _AddToPlaylistListState();
|
||||
}
|
||||
|
||||
class _AddToPlaylistListState extends State<AddToPlaylistList> {
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
late Future<List<BaseItemDto>?> addToPlaylistListFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
addToPlaylistListFuture = jellyfinApiHelper.getItems(
|
||||
includeItemTypes: "Playlist",
|
||||
sortBy: "SortName",
|
||||
isGenres: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<BaseItemDto>?>(
|
||||
future: addToPlaylistListFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Scrollbar(
|
||||
child: ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return AlbumItem(
|
||||
album: snapshot.data![index],
|
||||
parentType: snapshot.data![index].type,
|
||||
onTap: () async {
|
||||
try {
|
||||
await jellyfinApiHelper.addItemstoPlaylist(
|
||||
playlistId: snapshot.data![index].id,
|
||||
ids: [widget.itemToAddId],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Added to playlist."),
|
||||
// action: SnackBarAction(
|
||||
// label: "OPEN",
|
||||
// onPressed: () {
|
||||
// Navigator.of(context).pushNamed(
|
||||
// "/music/albumscreen",
|
||||
// arguments: snapshot.data![index]);
|
||||
// },
|
||||
// ),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return const Center(
|
||||
child: Icon(Icons.error, size: 64),
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/finamp_user_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class NewPlaylistDialog extends StatefulWidget {
|
||||
const NewPlaylistDialog({
|
||||
Key? key,
|
||||
required this.itemToAdd,
|
||||
}) : super(key: key);
|
||||
|
||||
final String itemToAdd;
|
||||
|
||||
@override
|
||||
State<NewPlaylistDialog> createState() => _NewPlaylistDialogState();
|
||||
}
|
||||
|
||||
class _NewPlaylistDialogState extends State<NewPlaylistDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
String? _name;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.newPlaylist),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: TextFormField(
|
||||
decoration:
|
||||
InputDecoration(labelText: AppLocalizations.of(context)!.name),
|
||||
textInputAction: TextInputAction.done,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return AppLocalizations.of(context)!.required;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) async => await _submit(),
|
||||
onSaved: (newValue) => _name = newValue,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop<bool>(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : () async => await _submit(),
|
||||
child: Text(AppLocalizations.of(context)!.createButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
_formKey.currentState!.save();
|
||||
|
||||
try {
|
||||
await _jellyfinApiHelper.createNewPlaylist(NewPlaylist(
|
||||
name: _name,
|
||||
ids: [widget.itemToAdd],
|
||||
userId: _finampUserHelper.currentUser!.id,
|
||||
));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.playlistCreated),
|
||||
));
|
||||
Navigator.of(context).pop<bool>(true);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:finamp/components/AlbumScreen/sync_album_or_playlist_button.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../components/favourite_button.dart';
|
||||
import 'album_screen_content_flexible_space_bar.dart';
|
||||
import 'delete_button.dart';
|
||||
import 'song_list_tile.dart';
|
||||
import 'playlist_name_edit_button.dart';
|
||||
|
||||
typedef BaseItemDtoCallback = void Function(BaseItemDto item);
|
||||
|
||||
class AlbumScreenContent extends StatefulWidget {
|
||||
const AlbumScreenContent({
|
||||
Key? key,
|
||||
required this.parent,
|
||||
required this.children,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto parent;
|
||||
final List<BaseItemDto> children;
|
||||
|
||||
@override
|
||||
State<AlbumScreenContent> createState() => _AlbumScreenContentState();
|
||||
}
|
||||
|
||||
class _AlbumScreenContentState extends State<AlbumScreenContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onDelete(BaseItemDto item) {
|
||||
// This is pretty inefficient (has to search through whole list) but
|
||||
// SongsSliverList gets passed some weird split version of children to
|
||||
// handle multi-disc albums and it's 00:35 so I can't be bothered to get
|
||||
// it to return an index
|
||||
setState(() {
|
||||
widget.children.remove(item);
|
||||
});
|
||||
}
|
||||
|
||||
List<List<BaseItemDto>> childrenPerDisc = [];
|
||||
// if not in playlist, try splitting up tracks by disc numbers
|
||||
// if first track has a disc number, let's assume the rest has it too
|
||||
if (widget.parent.type != "Playlist" &&
|
||||
widget.children[0].parentIndexNumber != null) {
|
||||
int? lastDiscNumber;
|
||||
for (var child in widget.children) {
|
||||
if (child.parentIndexNumber != null &&
|
||||
child.parentIndexNumber != lastDiscNumber) {
|
||||
lastDiscNumber = child.parentIndexNumber;
|
||||
childrenPerDisc.add([]);
|
||||
}
|
||||
childrenPerDisc.last.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(widget.parent.name ??
|
||||
AppLocalizations.of(context)!.unknownName),
|
||||
// 125 + 64 is the total height of the widget we use as a
|
||||
// FlexibleSpaceBar. We add the toolbar height since the widget
|
||||
// should appear below the appbar.
|
||||
// TODO: This height is affected by platform density.
|
||||
expandedHeight: kToolbarHeight + 125 + 64,
|
||||
pinned: true,
|
||||
flexibleSpace: AlbumScreenContentFlexibleSpaceBar(
|
||||
album: widget.parent,
|
||||
items: widget.children,
|
||||
),
|
||||
actions: [
|
||||
if (widget.parent.type == "Playlist" &&
|
||||
!FinampSettingsHelper.finampSettings.isOffline)
|
||||
PlaylistNameEditButton(playlist: widget.parent),
|
||||
FavoriteButton(item: widget.parent),
|
||||
if (GetIt.instance<DownloadsHelper>().isAlbumDownloaded(widget.parent.id))
|
||||
DeleteButton(parent: widget.parent, items: widget.children),
|
||||
if (!FinampSettingsHelper.finampSettings.isOffline)
|
||||
SyncAlbumOrPlaylistButton(parent: widget.parent, items: widget.children)
|
||||
],
|
||||
),
|
||||
if (widget.children.length > 1 &&
|
||||
childrenPerDisc.length >
|
||||
1) // show headers only for multi disc albums
|
||||
for (var childrenOfThisDisc in childrenPerDisc)
|
||||
SliverStickyHeader(
|
||||
header: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 16.0,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!
|
||||
.discNumber(childrenOfThisDisc[0].parentIndexNumber!),
|
||||
style: const TextStyle(fontSize: 20.0),
|
||||
),
|
||||
),
|
||||
sliver: SongsSliverList(
|
||||
childrenForList: childrenOfThisDisc,
|
||||
childrenForQueue: widget.children,
|
||||
parent: widget.parent,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
)
|
||||
else if (widget.children.isNotEmpty)
|
||||
SongsSliverList(
|
||||
childrenForList: widget.children,
|
||||
childrenForQueue: widget.children,
|
||||
parent: widget.parent,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SongsSliverList extends StatefulWidget {
|
||||
const SongsSliverList({
|
||||
Key? key,
|
||||
required this.childrenForList,
|
||||
required this.childrenForQueue,
|
||||
required this.parent,
|
||||
this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<BaseItemDto> childrenForList;
|
||||
final List<BaseItemDto> childrenForQueue;
|
||||
final BaseItemDto parent;
|
||||
final BaseItemDtoCallback? onDelete;
|
||||
|
||||
@override
|
||||
State<SongsSliverList> createState() => _SongsSliverListState();
|
||||
}
|
||||
|
||||
class _SongsSliverListState extends State<SongsSliverList> {
|
||||
final GlobalKey<SliverAnimatedListState> sliverListKey =
|
||||
GlobalKey<SliverAnimatedListState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// When user selects song from disc other than first, index number is
|
||||
// incorrect and song with the same index on first disc is played instead.
|
||||
// Adding this offset ensures playback starts for nth song on correct disc.
|
||||
final int indexOffset =
|
||||
widget.childrenForQueue.indexOf(widget.childrenForList[0]);
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
final BaseItemDto item = widget.childrenForList[index];
|
||||
|
||||
BaseItemDto removeItem() {
|
||||
late BaseItemDto item;
|
||||
|
||||
setState(() {
|
||||
item = widget.childrenForList.removeAt(index);
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
return SongListTile(
|
||||
item: item,
|
||||
children: widget.childrenForQueue,
|
||||
index: index + indexOffset,
|
||||
parentId: widget.parent.id,
|
||||
onDelete: () {
|
||||
final item = removeItem();
|
||||
if (widget.onDelete != null) {
|
||||
widget.onDelete!(item);
|
||||
}
|
||||
},
|
||||
isInPlaylist: widget.parent.type == "Playlist",
|
||||
// show artists except for this one scenario
|
||||
showArtists: !(
|
||||
// we're on album screen
|
||||
widget.parent.type == "MusicAlbum"
|
||||
// "hide song artists if they're the same as album artists" == true
|
||||
&&
|
||||
FinampSettingsHelper
|
||||
.finampSettings.hideSongArtistsIfSameAsAlbumArtists
|
||||
// song artists == album artists
|
||||
&&
|
||||
setEquals(
|
||||
widget.parent.albumArtists?.map((e) => e.name).toSet(),
|
||||
item.artists?.toSet())),
|
||||
);
|
||||
},
|
||||
childCount: widget.childrenForList.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
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/audio_service_helper.dart';
|
||||
import '../album_image.dart';
|
||||
import 'item_info.dart';
|
||||
|
||||
class AlbumScreenContentFlexibleSpaceBar extends StatelessWidget {
|
||||
const AlbumScreenContentFlexibleSpaceBar({
|
||||
Key? key,
|
||||
required this.album,
|
||||
required this.items,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto album;
|
||||
final List<BaseItemDto> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AudioServiceHelper audioServiceHelper =
|
||||
GetIt.instance<AudioServiceHelper>();
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
background: SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 125,
|
||||
child: AlbumImage(item: album),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ItemInfo(
|
||||
item: album,
|
||||
itemSongs: items.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () =>
|
||||
audioServiceHelper.replaceQueueWithItem(
|
||||
itemList: items,
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label:
|
||||
Text(AppLocalizations.of(context)!.playButtonLabel),
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 8)),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () =>
|
||||
audioServiceHelper.replaceQueueWithItem(
|
||||
itemList: items,
|
||||
shuffle: true,
|
||||
initialIndex: Random().nextInt(items.length),
|
||||
),
|
||||
icon: const Icon(Icons.shuffle),
|
||||
label: Text(
|
||||
AppLocalizations.of(context)!.shuffleButtonLabel),
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../confirmation_prompt_dialog.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class DeleteButton extends StatefulWidget {
|
||||
const DeleteButton({
|
||||
Key? key,
|
||||
required this.parent,
|
||||
required this.items,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto parent;
|
||||
final List<BaseItemDto> items;
|
||||
|
||||
@override
|
||||
State<DeleteButton> createState() => _DeleteButtonState();
|
||||
}
|
||||
|
||||
class _DeleteButtonState extends State<DeleteButton> {
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void checkIfDownloaded() {
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, child) {
|
||||
bool? isOffline = box.get("FinampSettings")?.isOffline;
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!.deleteFromDevice,
|
||||
icon: const Icon(Icons.delete),
|
||||
// If offline, we don't allow the user to delete items.
|
||||
// If we did, we'd have to implement listeners for MusicScreenTabView so that the user can't delete a parent, go back, and select the same parent.
|
||||
// If they did, AlbumScreen would show an error since the item no longer exists.
|
||||
// Also, the user could delete the parent and immediately re-download it, which will either cause unwanted network usage or cause more errors because the user is offline.
|
||||
onPressed: isOffline ?? false
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmationPromptDialog(
|
||||
promptText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsPrompt(
|
||||
widget.parent.name ?? "",
|
||||
widget.parent.type == "Playlist"
|
||||
? "playlist"
|
||||
: "album"),
|
||||
confirmButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsConfirmButtonText,
|
||||
abortButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsAbortButtonText,
|
||||
onConfirmed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await _downloadsHelper
|
||||
.deleteParentAndChildDownloads(
|
||||
jellyfinItemIds:
|
||||
widget.items.map((e) => e.id).toList(),
|
||||
deletedFor: widget.parent.id,
|
||||
);
|
||||
checkIfDownloaded();
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!
|
||||
.downloadsDeleted)));
|
||||
} catch (error) {
|
||||
errorSnackbar(error, context);
|
||||
}
|
||||
},
|
||||
onAborted: () {},
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class DownloadDialog extends StatefulWidget {
|
||||
const DownloadDialog({
|
||||
Key? key,
|
||||
required this.parents,
|
||||
required this.items,
|
||||
required this.viewId,
|
||||
}) : assert(parents.length == items.length),
|
||||
super(key: key);
|
||||
|
||||
final List<BaseItemDto> parents;
|
||||
final List<List<BaseItemDto>> items;
|
||||
final String viewId;
|
||||
|
||||
@override
|
||||
State<DownloadDialog> createState() => _DownloadDialogState();
|
||||
}
|
||||
|
||||
class _DownloadDialogState extends State<DownloadDialog> {
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
DownloadLocation? selectedDownloadLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.addDownloads),
|
||||
content: DropdownButton<DownloadLocation>(
|
||||
hint: Text(AppLocalizations.of(context)!.location),
|
||||
isExpanded: true,
|
||||
onChanged: (value) => setState(() {
|
||||
selectedDownloadLocation = value;
|
||||
}),
|
||||
value: selectedDownloadLocation,
|
||||
items: FinampSettingsHelper.finampSettings.downloadLocationsMap.values
|
||||
.map((e) => DropdownMenuItem<DownloadLocation>(
|
||||
value: e,
|
||||
child: Text(e.name),
|
||||
))
|
||||
.toList()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: selectedDownloadLocation == null
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop(selectedDownloadLocation);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.addButtonLabel),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This function is used by DownloadDialog to check/add downloads.
|
||||
Future<void> checkedAddDownloads(
|
||||
BuildContext context, {
|
||||
required DownloadLocation downloadLocation,
|
||||
required List<BaseItemDto> parents,
|
||||
required List<List<BaseItemDto>> items,
|
||||
required String viewId,
|
||||
}) async {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final checkedAddDownloadsLogger = Logger("CheckedAddDownloads");
|
||||
|
||||
// If the default "internal storage" path is set and doesn't
|
||||
// exist, it may have been moved by an iOS update.
|
||||
if (downloadLocation.useHumanReadableNames == false &&
|
||||
!await Directory(downloadLocation.path).exists()) {
|
||||
checkedAddDownloadsLogger
|
||||
.warning("Internal storage path doesn't exist! Resetting.");
|
||||
await FinampSettingsHelper.resetDefaultDownloadLocation();
|
||||
}
|
||||
|
||||
for (int i = 0; i < parents.length; i++) {
|
||||
downloadsHelper
|
||||
.addDownloads(
|
||||
parent: parents[i],
|
||||
items: items[i],
|
||||
useHumanReadableNames: downloadLocation.useHumanReadableNames,
|
||||
viewId: viewId,
|
||||
downloadLocation: downloadLocation,
|
||||
)
|
||||
.onError((error, stackTrace) => errorSnackbar(error, context));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/download_update_stream.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class DownloadedIndicator extends StatefulWidget {
|
||||
const DownloadedIndicator({
|
||||
Key? key,
|
||||
required this.item,
|
||||
this.size,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final double? size;
|
||||
|
||||
@override
|
||||
State<DownloadedIndicator> createState() => _DownloadedIndicatorState();
|
||||
}
|
||||
|
||||
class _DownloadedIndicatorState extends State<DownloadedIndicator> {
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final _downloadUpdateStream = GetIt.instance<DownloadUpdateStream>();
|
||||
|
||||
late Future<List<DownloadTask>?> _downloadedIndicatorFuture;
|
||||
String? _downloadTaskId;
|
||||
|
||||
DownloadTaskStatus? _currentStatus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_downloadedIndicatorFuture =
|
||||
_downloadsHelper.getDownloadStatus([widget.item.id]);
|
||||
|
||||
// We do this instead of using a StreamBuilder because the StreamBuilder
|
||||
// kept dropping events. With this, we can also make it so that the widget
|
||||
// only rebuilds when it actually has to.
|
||||
_downloadUpdateStream.stream.listen((event) {
|
||||
if (event.id == _downloadTaskId && event.status != _currentStatus) {
|
||||
setState(() {
|
||||
_currentStatus = event.status;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<DownloadTask>?>(
|
||||
future: _downloadedIndicatorFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
if (snapshot.data != null && snapshot.data!.isNotEmpty) {
|
||||
_downloadTaskId = snapshot.data?[0].taskId;
|
||||
_currentStatus = snapshot.data?[0].status;
|
||||
}
|
||||
// This ValueListenable is used to get the download task ID of new
|
||||
// downloads. It also clears the task ID and status when the download
|
||||
// is deleted. This only rebuilds when the item with key
|
||||
// widget.item.id is changed, so this should only rebuild when the
|
||||
// item is first downloaded and when it is deleted.
|
||||
return ValueListenableBuilder<Box<DownloadedSong>>(
|
||||
valueListenable: _downloadsHelper
|
||||
.getDownloadedItemsListenable(keys: [widget.item.id]),
|
||||
builder: (context, box, _) {
|
||||
if (_downloadTaskId == null && box.get(widget.item.id) != null) {
|
||||
_downloadTaskId = box.get(widget.item.id)!.downloadId;
|
||||
} else if (box.get(widget.item.id) == null) {
|
||||
_downloadTaskId = null;
|
||||
_currentStatus = null;
|
||||
}
|
||||
// return StreamBuilder<DownloadUpdate>(
|
||||
// stream: _downloadUpdateStream.stream,
|
||||
// builder: (context, snapshot) {
|
||||
// print("Streambuilder rebuild ${snapshot.data}");
|
||||
// if (snapshot.hasData &&
|
||||
// snapshot.data!.id == _downloadTask?.taskId) {
|
||||
// print("Change $_currentStatus to ${snapshot.data?.status}");
|
||||
// if (snapshot.data?.status == DownloadTaskStatus.complete) {
|
||||
// print(
|
||||
// "COMPLETE IN STREAMBUILDER ${widget.item.name}!!!!");
|
||||
// }
|
||||
// _currentStatus = snapshot.data?.status;
|
||||
// }
|
||||
if (_currentStatus == null) {
|
||||
return const SizedBox.shrink();
|
||||
} else if (_currentStatus == DownloadTaskStatus.complete) {
|
||||
return Icon(
|
||||
Icons.download,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
size: widget.size,
|
||||
);
|
||||
} else if (_currentStatus == DownloadTaskStatus.failed ||
|
||||
_currentStatus == DownloadTaskStatus.undefined) {
|
||||
return Icon(
|
||||
Icons.error,
|
||||
color: Colors.red,
|
||||
size: widget.size,
|
||||
);
|
||||
} else if (_currentStatus == DownloadTaskStatus.paused) {
|
||||
return Icon(
|
||||
Icons.pause,
|
||||
color: Colors.yellow,
|
||||
size: widget.size,
|
||||
);
|
||||
} else if (_currentStatus == DownloadTaskStatus.enqueued ||
|
||||
_currentStatus == DownloadTaskStatus.running) {
|
||||
return Icon(
|
||||
Icons.download_outlined,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
size: widget.size,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
// },
|
||||
// );
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:finamp/components/artists_text_spans.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../print_duration.dart';
|
||||
|
||||
class ItemInfo extends StatelessWidget {
|
||||
const ItemInfo({
|
||||
Key? key,
|
||||
required this.item,
|
||||
required this.itemSongs,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final int itemSongs;
|
||||
|
||||
// TODO: see if there's a way to expand this column to the row that it's in
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (item.type != "Playlist")
|
||||
_IconAndText(
|
||||
iconData: Icons.person,
|
||||
textSpan: TextSpan(
|
||||
children: ArtistsTextSpans(
|
||||
item,
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
context,
|
||||
false,
|
||||
),
|
||||
)),
|
||||
_IconAndText(
|
||||
iconData: Icons.music_note,
|
||||
textSpan: _buildStyledText(
|
||||
context: context,
|
||||
text: AppLocalizations.of(context)!.songCount(itemSongs),
|
||||
),
|
||||
),
|
||||
_IconAndText(
|
||||
iconData: Icons.timer,
|
||||
textSpan: _buildStyledText(
|
||||
context: context,
|
||||
text: printDuration(Duration(
|
||||
microseconds:
|
||||
item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10,
|
||||
)),
|
||||
),
|
||||
),
|
||||
if (item.type != "Playlist")
|
||||
_IconAndText(
|
||||
iconData: Icons.event,
|
||||
textSpan: _buildStyledText(
|
||||
context: context,
|
||||
text: item.productionYearString,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _buildStyledText({required BuildContext context, String? text}) {
|
||||
return TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconAndText extends StatelessWidget {
|
||||
const _IconAndText({
|
||||
Key? key,
|
||||
required this.iconData,
|
||||
required this.textSpan,
|
||||
}) : super(key: key);
|
||||
|
||||
final IconData iconData;
|
||||
final TextSpan textSpan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
iconData,
|
||||
// Inactive icons have an opacity of 50% with dark theme and 38%
|
||||
// with bright theme
|
||||
// https://material.io/design/iconography/system-icons.html#color
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: textSpan,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import 'playlist_name_edit_dialog.dart';
|
||||
|
||||
class PlaylistNameEditButton extends StatelessWidget {
|
||||
const PlaylistNameEditButton({
|
||||
Key? key,
|
||||
required this.playlist,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto playlist;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
tooltip: AppLocalizations.of(context)!.editPlaylistNameTooltip,
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => PlaylistNameEditDialog(playlist: playlist),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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/jellyfin_api_helper.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class PlaylistNameEditDialog extends StatefulWidget {
|
||||
const PlaylistNameEditDialog({
|
||||
Key? key,
|
||||
required this.playlist,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto playlist;
|
||||
|
||||
@override
|
||||
State<PlaylistNameEditDialog> createState() => _PlaylistNameEditDialogState();
|
||||
}
|
||||
|
||||
class _PlaylistNameEditDialogState extends State<PlaylistNameEditDialog> {
|
||||
String? _name;
|
||||
bool _isUpdating = false;
|
||||
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_name = widget.playlist.name;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.editPlaylistNameTitle),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: TextFormField(
|
||||
initialValue: _name,
|
||||
decoration:
|
||||
InputDecoration(labelText: AppLocalizations.of(context)!.name),
|
||||
textInputAction: TextInputAction.done,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return AppLocalizations.of(context)!.required;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) async => await _submit(),
|
||||
onSaved: (newValue) => _name = newValue,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isUpdating ? null : () async => await _submit(),
|
||||
child: Text(AppLocalizations.of(context)!.updateButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_formKey.currentState != null && _formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
_isUpdating = true;
|
||||
});
|
||||
|
||||
_formKey.currentState!.save();
|
||||
|
||||
try {
|
||||
BaseItemDto playlistTemp = widget.playlist;
|
||||
playlistTemp.name = _name;
|
||||
await _jellyfinApiHelper.updateItem(
|
||||
itemId: widget.playlist.id,
|
||||
newItem: playlistTemp,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.playlistNameUpdated),
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
setState(() {
|
||||
_isUpdating = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:mini_music_visualizer/mini_music_visualizer.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../screens/add_to_playlist_screen.dart';
|
||||
import '../../screens/album_screen.dart';
|
||||
import '../../services/audio_service_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/media_state_stream.dart';
|
||||
import '../../services/process_artist.dart';
|
||||
import '../album_image.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import '../favourite_button.dart';
|
||||
import '../print_duration.dart';
|
||||
import 'downloaded_indicator.dart';
|
||||
|
||||
enum SongListTileMenuItems {
|
||||
addToQueue,
|
||||
playNext,
|
||||
replaceQueueWithItem,
|
||||
addToPlaylist,
|
||||
removeFromPlaylist,
|
||||
instantMix,
|
||||
goToAlbum,
|
||||
addFavourite,
|
||||
removeFavourite,
|
||||
}
|
||||
|
||||
class SongListTile extends StatefulWidget {
|
||||
const SongListTile({
|
||||
Key? key,
|
||||
required this.item,
|
||||
|
||||
/// Children that are related to this list tile, such as the other songs in
|
||||
/// the album. This is used to give the audio service all the songs for the
|
||||
/// item. If null, only this song will be given to the audio service.
|
||||
this.children,
|
||||
|
||||
/// Index of the song in whatever parent this widget is in. Used to start
|
||||
/// the audio service at a certain index, such as when selecting the middle
|
||||
/// song in an album.
|
||||
this.index,
|
||||
this.parentId,
|
||||
this.isSong = false,
|
||||
this.showArtists = true,
|
||||
this.onDelete,
|
||||
|
||||
/// Whether this widget is being displayed in a playlist. If true, will show
|
||||
/// the remove from playlist button.
|
||||
this.isInPlaylist = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final List<BaseItemDto>? children;
|
||||
final int? index;
|
||||
final bool isSong;
|
||||
final String? parentId;
|
||||
final bool showArtists;
|
||||
final VoidCallback? onDelete;
|
||||
final bool isInPlaylist;
|
||||
|
||||
@override
|
||||
State<SongListTile> createState() => _SongListTileState();
|
||||
}
|
||||
|
||||
class _SongListTileState extends State<SongListTile> {
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
/// Sets the item's favourite on the Jellyfin server.
|
||||
Future<void> setFavourite() async {
|
||||
try {
|
||||
// We switch the widget state before actually doing the request to
|
||||
// make the app feel faster (without, there is a delay from the
|
||||
// user adding the favourite and the icon showing)
|
||||
setState(() {
|
||||
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
|
||||
});
|
||||
|
||||
// Since we flipped the favourite state already, we can use the flipped
|
||||
// state to decide which API call to make
|
||||
final newUserData = widget.item.userData!.isFavorite
|
||||
? await _jellyfinApiHelper.addFavourite(widget.item.id)
|
||||
: await _jellyfinApiHelper.removeFavourite(widget.item.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
widget.item.userData = newUserData;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
widget.item.userData!.isFavorite = !widget.item.userData!.isFavorite;
|
||||
});
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
}
|
||||
|
||||
final listTile = StreamBuilder<MediaState>(
|
||||
stream: mediaStateStream,
|
||||
builder: (context, snapshot) {
|
||||
// I think past me did this check directly from the JSON for
|
||||
// performance. It works for now, apologies if you're debugging it
|
||||
// years in the future.
|
||||
final isCurrentlyPlaying =
|
||||
snapshot.data?.mediaItem?.extras?["itemJson"]["Id"] ==
|
||||
widget.item.id &&
|
||||
snapshot.data?.mediaItem?.extras?["itemJson"]["AlbumId"] ==
|
||||
widget.parentId;
|
||||
|
||||
return ListTile(
|
||||
leading: AlbumImage(item: widget.item),
|
||||
title: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
// third condition checks if the item is viewed from its album (instead of e.g. a playlist)
|
||||
// same horrible check as in canGoToAlbum in GestureDetector below
|
||||
if (widget.item.indexNumber != null &&
|
||||
!widget.isSong &&
|
||||
widget.item.albumId == widget.parentId)
|
||||
TextSpan(
|
||||
text: "${widget.item.indexNumber}. ",
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).disabledColor)),
|
||||
TextSpan(
|
||||
text: widget.item.name ??
|
||||
AppLocalizations.of(context)!.unknownName,
|
||||
style: TextStyle(
|
||||
color: isCurrentlyPlaying
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
subtitle: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
WidgetSpan(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(-3, 0),
|
||||
child: DownloadedIndicator(
|
||||
item: widget.item,
|
||||
size:
|
||||
Theme.of(context).textTheme.bodyMedium!.fontSize! +
|
||||
3,
|
||||
),
|
||||
),
|
||||
alignment: PlaceholderAlignment.top,
|
||||
),
|
||||
TextSpan(
|
||||
text: printDuration(Duration(
|
||||
microseconds: (widget.item.runTimeTicks == null
|
||||
? 0
|
||||
: widget.item.runTimeTicks! ~/ 10))),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.color
|
||||
?.withOpacity(0.7)),
|
||||
),
|
||||
if (widget.showArtists)
|
||||
TextSpan(
|
||||
text:
|
||||
" · ${processArtist(widget.item.artists?.join(", ") ?? widget.item.albumArtist, context)}",
|
||||
style: TextStyle(color: Theme.of(context).disabledColor),
|
||||
)
|
||||
],
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isCurrentlyPlaying &&
|
||||
(snapshot.data?.playbackState.playing ?? false))
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: MiniMusicVisualizer(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
width: 4,
|
||||
height: 15,
|
||||
),
|
||||
),
|
||||
FavoriteButton(
|
||||
item: widget.item,
|
||||
onlyIfFav: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_audioServiceHelper.replaceQueueWithItem(
|
||||
itemList: widget.children ?? [widget.item],
|
||||
initialIndex: widget.index ?? 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
Feedback.forLongPress(context);
|
||||
|
||||
// This horrible check does 2 things:
|
||||
// - Checks if the item's album is not the same as the parent item
|
||||
// that created the widget. The ids will be different if the
|
||||
// SongListTile is in a playlist, but they will be the same if viewed
|
||||
// in the item's album. We don't want to show this menu item if we're
|
||||
// already in the item's album.
|
||||
//
|
||||
// - Checks if the album is downloaded if in offline mode. If we're in
|
||||
// offline mode, we need the album to actually be downloaded to show
|
||||
// its metadata. This function also checks if widget.item.parentId is
|
||||
// null.
|
||||
final canGoToAlbum = widget.item.albumId != widget.parentId &&
|
||||
_isAlbumDownloadedIfOffline(widget.item.parentId);
|
||||
|
||||
// Some options are disabled in offline mode
|
||||
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
final selection = await showMenu<SongListTileMenuItems>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
screenSize.width - details.globalPosition.dx,
|
||||
screenSize.height - details.globalPosition.dy,
|
||||
),
|
||||
items: [
|
||||
if (_audioServiceHelper.hasQueueItems()) ...[
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.addToQueue,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.addToQueue),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.playNext,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.playNext),
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.replaceQueueWithItem,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.play_circle),
|
||||
title: Text(AppLocalizations.of(context)!.replaceQueue),
|
||||
),
|
||||
),
|
||||
if (widget.isInPlaylist)
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.addToPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(AppLocalizations.of(context)!.addToPlaylistTitle),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
widget.isInPlaylist
|
||||
? PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.removeFromPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_remove),
|
||||
title: Text(AppLocalizations.of(context)!
|
||||
.removeFromPlaylistTitle),
|
||||
enabled: !isOffline && widget.parentId != null,
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.addToPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.addToPlaylistTitle),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: SongListTileMenuItems.instantMix,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.explore),
|
||||
title: Text(AppLocalizations.of(context)!.instantMix),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
PopupMenuItem<SongListTileMenuItems>(
|
||||
enabled: canGoToAlbum,
|
||||
value: SongListTileMenuItems.goToAlbum,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.album),
|
||||
title: Text(AppLocalizations.of(context)!.goToAlbum),
|
||||
enabled: canGoToAlbum,
|
||||
),
|
||||
),
|
||||
widget.item.userData!.isFavorite
|
||||
? PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.removeFavourite,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.favorite_border),
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.removeFavourite),
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<SongListTileMenuItems>(
|
||||
value: SongListTileMenuItems.addFavourite,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.favorite),
|
||||
title: Text(AppLocalizations.of(context)!.addFavourite),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (selection) {
|
||||
case SongListTileMenuItems.addToQueue:
|
||||
await _audioServiceHelper.addQueueItems([widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.addedToQueue),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.playNext:
|
||||
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.insertedIntoQueue),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.replaceQueueWithItem:
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: [widget.item]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.queueReplaced),
|
||||
));
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.addToPlaylist:
|
||||
Navigator.of(context).pushNamed(AddToPlaylistScreen.routeName,
|
||||
arguments: widget.item.id);
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.removeFromPlaylist:
|
||||
try {
|
||||
await _jellyfinApiHelper.removeItemsFromPlaylist(
|
||||
playlistId: widget.parentId!,
|
||||
entryIds: [widget.item.playlistItemId!]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await _jellyfinApiHelper.getItems(
|
||||
parentItem:
|
||||
await _jellyfinApiHelper.getItemById(widget.item.parentId!),
|
||||
sortBy: "ParentIndexNumber,IndexNumber,SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (widget.onDelete != null) widget.onDelete!();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(AppLocalizations.of(context)!.removedFromPlaylist),
|
||||
));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
|
||||
case SongListTileMenuItems.instantMix:
|
||||
await _audioServiceHelper.startInstantMixForItem(widget.item);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.startingInstantMix),
|
||||
));
|
||||
break;
|
||||
case SongListTileMenuItems.goToAlbum:
|
||||
late BaseItemDto album;
|
||||
if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
// If offline, load the album's BaseItemDto from DownloadHelper.
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// downloadedParent won't be null here since the menu item already
|
||||
// checks if the DownloadedParent exists.
|
||||
album = downloadsHelper
|
||||
.getDownloadedParent(widget.item.parentId!)!
|
||||
.item;
|
||||
} else {
|
||||
// If online, get the album's BaseItemDto from the server.
|
||||
try {
|
||||
album =
|
||||
await _jellyfinApiHelper.getItemById(widget.item.parentId!);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context)
|
||||
.pushNamed(AlbumScreen.routeName, arguments: album);
|
||||
break;
|
||||
case SongListTileMenuItems.addFavourite:
|
||||
case SongListTileMenuItems.removeFavourite:
|
||||
await setFavourite();
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: widget.isSong
|
||||
? listTile
|
||||
: Dismissible(
|
||||
key: Key(widget.index.toString()),
|
||||
direction: FinampSettingsHelper.finampSettings.disableGesture
|
||||
? DismissDirection.none
|
||||
: DismissDirection.horizontal,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.fitHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Icon(
|
||||
Icons.queue_music,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (!FinampSettingsHelper.finampSettings.swipeInsertQueueNext) {
|
||||
await _audioServiceHelper.addQueueItems([widget.item]);
|
||||
} else {
|
||||
await _audioServiceHelper.insertQueueItemsNext([widget.item]);
|
||||
}
|
||||
|
||||
if (!mounted) return false;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
FinampSettingsHelper.finampSettings.swipeInsertQueueNext
|
||||
? AppLocalizations.of(context)!.insertedIntoQueue
|
||||
: AppLocalizations.of(context)!.addedToQueue),
|
||||
));
|
||||
|
||||
return false;
|
||||
},
|
||||
child: listTile,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If offline, check if an album is downloaded. Always returns true if online.
|
||||
/// Returns false if albumId is null.
|
||||
bool _isAlbumDownloadedIfOffline(String? albumId) {
|
||||
if (albumId == null) {
|
||||
return false;
|
||||
} else if (FinampSettingsHelper.finampSettings.isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
return downloadsHelper.isAlbumDownloaded(albumId);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:finamp/services/sync_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class SyncAlbumOrPlaylistButton extends StatefulWidget {
|
||||
const SyncAlbumOrPlaylistButton({
|
||||
Key? key,
|
||||
required this.parent,
|
||||
required this.items,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto parent;
|
||||
final List<BaseItemDto> items;
|
||||
@override
|
||||
State<SyncAlbumOrPlaylistButton> createState() =>
|
||||
_SyncAlbumOrPlaylistButtonState();
|
||||
}
|
||||
class _SyncAlbumOrPlaylistButtonState
|
||||
extends State<SyncAlbumOrPlaylistButton> {
|
||||
final _syncLogger = Logger("SyncPlaylistButton");
|
||||
final _downloadHelper = GetIt.instance<DownloadsHelper>();
|
||||
bool isAlbumDownloaded = false;
|
||||
|
||||
|
||||
void syncAlbumOrPlaylist(BuildContext context) async {
|
||||
_syncLogger.info("Syncing playlist");
|
||||
|
||||
var syncHelper = DownloadsSyncHelper(_syncLogger);
|
||||
syncHelper.sync(context, widget.parent, widget.items);
|
||||
setState(() {
|
||||
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
isAlbumDownloaded = _downloadHelper.isAlbumDownloaded(widget.parent.id);
|
||||
return IconButton(
|
||||
tooltip: isAlbumDownloaded
|
||||
? AppLocalizations.of(context)!.sync
|
||||
: AppLocalizations.of(context)!.download,
|
||||
onPressed: () => syncAlbumOrPlaylist(context),
|
||||
icon:
|
||||
isAlbumDownloaded ?
|
||||
const Icon(Icons.sync) :
|
||||
const Icon(Icons.download));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/finamp_user_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../AlbumScreen/download_dialog.dart';
|
||||
import '../confirmation_prompt_dialog.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class ArtistDownloadButton extends StatefulWidget {
|
||||
const ArtistDownloadButton({
|
||||
Key? key,
|
||||
required this.artist,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto artist;
|
||||
|
||||
@override
|
||||
State<ArtistDownloadButton> createState() => _ArtistDownloadButtonState();
|
||||
}
|
||||
|
||||
class _ArtistDownloadButtonState extends State<ArtistDownloadButton> {
|
||||
static const _disabledButton = IconButton(
|
||||
icon: Icon(Icons.download),
|
||||
onPressed: null,
|
||||
);
|
||||
Future<List<BaseItemDto>?>? _artistDownloadButtonFuture;
|
||||
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
List<BaseItemDto> _getUndownloadedAlbums(List<BaseItemDto> albums) {
|
||||
return albums
|
||||
.where((element) => !_downloadsHelper.isAlbumDownloaded(element.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, _) {
|
||||
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
||||
bool deleteAlbums = false;
|
||||
|
||||
if (isOffline) {
|
||||
return _disabledButton;
|
||||
} else {
|
||||
// We only want to get album data if we're online
|
||||
_artistDownloadButtonFuture ??= _jellyfinApiHelper.getItems(
|
||||
parentItem: widget.artist,
|
||||
includeItemTypes: "MusicAlbum",
|
||||
isGenres: false,
|
||||
);
|
||||
return FutureBuilder<List<BaseItemDto>?>(
|
||||
future: _artistDownloadButtonFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final undownloadedAlbums =
|
||||
_getUndownloadedAlbums(snapshot.data!);
|
||||
deleteAlbums = undownloadedAlbums.isEmpty;
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.downloadArtist(widget.artist.name ?? "Unknown Artist"),
|
||||
icon: deleteAlbums
|
||||
? const Icon(Icons.delete)
|
||||
: const Icon(Icons.download),
|
||||
onPressed:() async {
|
||||
if (deleteAlbums) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmationPromptDialog(
|
||||
promptText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsPrompt(
|
||||
widget.artist.name ?? "",
|
||||
widget.artist.type == "MusicArtist" ? "artist" : "genre"),
|
||||
confirmButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsConfirmButtonText,
|
||||
abortButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsAbortButtonText,
|
||||
onConfirmed: () async {
|
||||
try {
|
||||
final deleteFutures = snapshot.data!.map((e) =>
|
||||
_downloadsHelper.deleteParentAndChildDownloads(
|
||||
jellyfinItemIds: _downloadsHelper
|
||||
.getDownloadedParent(e.id)!
|
||||
.downloadedChildren
|
||||
.keys
|
||||
.toList(),
|
||||
deletedFor: e.id));
|
||||
await Future.wait(deleteFutures);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Downloads deleted.")));
|
||||
final undownloadedAlbums =
|
||||
_getUndownloadedAlbums(snapshot.data!);
|
||||
setState(() {
|
||||
deleteAlbums = undownloadedAlbums.isEmpty;
|
||||
});
|
||||
} catch (error) {
|
||||
errorSnackbar(error, context);
|
||||
}
|
||||
},
|
||||
onAborted: () {},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
List<Future<List<BaseItemDto>?>> albumInfoFutures = [];
|
||||
for (var element in undownloadedAlbums) {
|
||||
albumInfoFutures.add(_jellyfinApiHelper.getItems(
|
||||
parentItem: element,
|
||||
sortBy: "SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
));
|
||||
}
|
||||
|
||||
List<List<BaseItemDto>?> albumInfo;
|
||||
|
||||
try {
|
||||
albumInfo = await Future.wait(albumInfoFutures);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
return;
|
||||
}
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => DownloadDialog(
|
||||
parents: undownloadedAlbums,
|
||||
// getItems returns null so we have to null check
|
||||
// each element
|
||||
items: albumInfo.map((e) => e!).toList(),
|
||||
viewId: _finampUserHelper.currentUser!.currentViewId!,
|
||||
),
|
||||
);
|
||||
}
|
||||
// We call a setState so that the downloaded albums are
|
||||
// checked again (so that the download icon turns into a
|
||||
// delete icon and vice-versa)
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return _disabledButton;
|
||||
} else {
|
||||
return _disabledButton;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/audio_service_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
|
||||
class ArtistPlayButton extends StatefulWidget {
|
||||
const ArtistPlayButton({
|
||||
Key? key,
|
||||
required this.artist,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto artist;
|
||||
|
||||
|
||||
@override
|
||||
State<ArtistPlayButton> createState() => _ArtistPlayButtonState();
|
||||
}
|
||||
|
||||
class _ArtistPlayButtonState extends State<ArtistPlayButton> {
|
||||
static const _disabledButton = IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.play_arrow)
|
||||
);
|
||||
Future<List<BaseItemDto>?>? artistPlayButtonFuture;
|
||||
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, _) {
|
||||
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
||||
|
||||
if (isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
final List<BaseItemDto>artistsSongs = [];
|
||||
|
||||
for (DownloadedSong item in downloadsHelper.downloadedItems) {
|
||||
if (item.song.albumArtist == widget.artist.name) {
|
||||
artistsSongs.add(item.song);
|
||||
}
|
||||
}
|
||||
|
||||
// We have to sort by hand in offline mode because a downloadedParent for artists hasn't been implemented
|
||||
Map<String, List<BaseItemDto>> groupedSongs = {};
|
||||
for (BaseItemDto song in artistsSongs) {
|
||||
groupedSongs.putIfAbsent((song.albumId ?? 'unknown'), () => []);
|
||||
groupedSongs[song.albumId]!.add(song);
|
||||
}
|
||||
|
||||
final List<BaseItemDto> sortedSongs = [];
|
||||
groupedSongs.forEach((album, albumSongs) {
|
||||
albumSongs.sort((a, b) => (a.indexNumber ?? 0).compareTo(b.indexNumber ?? 0));
|
||||
sortedSongs.addAll(albumSongs);
|
||||
});
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.playArtist(widget.artist.name ?? "Unknown Artist"),
|
||||
onPressed: () async {
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: sortedSongs);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
);
|
||||
} else {
|
||||
artistPlayButtonFuture ??= _jellyfinApiHelper.getItems(
|
||||
parentItem: widget.artist,
|
||||
includeItemTypes: "Audio",
|
||||
sortBy: 'PremiereDate,Album,SortName',
|
||||
isGenres: false,
|
||||
);
|
||||
|
||||
return FutureBuilder<List<BaseItemDto>?>(
|
||||
future: artistPlayButtonFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData){
|
||||
final List<BaseItemDto> items = snapshot.data!;
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.playArtist(widget.artist.name ?? "Unknown Artist"),
|
||||
onPressed: () async {
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: items);
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
);
|
||||
} else {
|
||||
return _disabledButton;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/audio_service_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
|
||||
class ArtistShuffleButton extends StatefulWidget {
|
||||
const ArtistShuffleButton({
|
||||
Key? key,
|
||||
required this.artist,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto artist;
|
||||
|
||||
|
||||
@override
|
||||
State<ArtistShuffleButton> createState() => _ArtistShuffleButtonState();
|
||||
}
|
||||
|
||||
class _ArtistShuffleButtonState extends State<ArtistShuffleButton> {
|
||||
static const _disabledButton = IconButton(
|
||||
onPressed: null,
|
||||
icon: Icon(Icons.play_arrow)
|
||||
);
|
||||
Future<List<BaseItemDto>?>? artistShuffleButtonFuture;
|
||||
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, _) {
|
||||
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
||||
|
||||
if (isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
final List<BaseItemDto>artistsSongs = [];
|
||||
|
||||
for (DownloadedSong item in downloadsHelper.downloadedItems) {
|
||||
if (item.song.albumArtist == widget.artist.name) {
|
||||
artistsSongs.add(item.song);
|
||||
}
|
||||
}
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.shuffleArtist(widget.artist.name ?? "Unknown Artist"),
|
||||
onPressed: () async {
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: artistsSongs, shuffle: true);
|
||||
},
|
||||
icon: const Icon(Icons.shuffle),
|
||||
);
|
||||
} else {
|
||||
artistShuffleButtonFuture ??= _jellyfinApiHelper.getItems(
|
||||
parentItem: widget.artist,
|
||||
includeItemTypes: "Audio",
|
||||
sortBy: 'PremiereDate,Album,SortName',
|
||||
isGenres: false,
|
||||
);
|
||||
|
||||
return FutureBuilder<List<BaseItemDto>?>(
|
||||
future: artistShuffleButtonFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData){
|
||||
final List<BaseItemDto> items = snapshot.data!;
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!
|
||||
.shuffleArtist(widget.artist.name ?? "Unknown Artist"),
|
||||
onPressed: () async {
|
||||
await _audioServiceHelper
|
||||
.replaceQueueWithItem(itemList: items, shuffle: true, initialIndex: Random().nextInt(items.length));
|
||||
},
|
||||
icon: const Icon(Icons.shuffle),
|
||||
);
|
||||
} else {
|
||||
return _disabledButton;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class BufferDurationListTile extends StatefulWidget {
|
||||
const BufferDurationListTile({super.key});
|
||||
|
||||
@override
|
||||
State<BufferDurationListTile> createState() => _BufferDurationListTileState();
|
||||
}
|
||||
|
||||
class _BufferDurationListTileState extends State<BufferDurationListTile> {
|
||||
final _controller = TextEditingController(
|
||||
text:
|
||||
FinampSettingsHelper.finampSettings.bufferDurationSeconds.toString());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(AppLocalizations.of(context)!.bufferDuration),
|
||||
subtitle: Text(AppLocalizations.of(context)!.bufferDurationSubtitle),
|
||||
trailing: SizedBox(
|
||||
width: 50 * MediaQuery.of(context).textScaleFactor,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
final valueInt = int.tryParse(value);
|
||||
|
||||
if (valueInt != null && !valueInt.isNegative) {
|
||||
FinampSettingsHelper.setBufferDuration(
|
||||
Duration(seconds: valueInt));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class SongShuffleItemCountEditor extends StatefulWidget {
|
||||
const SongShuffleItemCountEditor({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SongShuffleItemCountEditor> createState() =>
|
||||
_SongShuffleItemCountEditorState();
|
||||
}
|
||||
|
||||
class _SongShuffleItemCountEditorState
|
||||
extends State<SongShuffleItemCountEditor> {
|
||||
final _controller = TextEditingController(
|
||||
text:
|
||||
FinampSettingsHelper.finampSettings.songShuffleItemCount.toString());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(AppLocalizations.of(context)!.shuffleAllSongCount),
|
||||
subtitle: Text(AppLocalizations.of(context)!.shuffleAllSongCountSubtitle),
|
||||
trailing: SizedBox(
|
||||
width: 50 * MediaQuery.of(context).textScaleFactor,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
final valueInt = int.tryParse(value);
|
||||
|
||||
if (valueInt != null) {
|
||||
FinampSettingsHelper.setSongShuffleItemCount(valueInt);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class StopForegroundSelector extends StatelessWidget {
|
||||
const StopForegroundSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.enterLowPriorityStateOnPause),
|
||||
subtitle: Text(AppLocalizations.of(context)!
|
||||
.enterLowPriorityStateOnPauseSubtitle),
|
||||
value:
|
||||
FinampSettingsHelper.finampSettings.androidStopForegroundOnPause,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setAndroidStopForegroundOnPause(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class DownloadLocationDeleteDialog extends StatelessWidget {
|
||||
const DownloadLocationDeleteDialog({
|
||||
Key? key,
|
||||
required this.id,
|
||||
}) : super(key: key);
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text("Are you sure?"),
|
||||
content: const Text(
|
||||
"Deleting a download location doesn't actually delete any downloads. It just removes the menu entry."),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text("CANCEL"),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
TextButton(
|
||||
child: const Text("DELETE"),
|
||||
onPressed: () {
|
||||
FinampSettingsHelper.deleteDownloadLocation(id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import 'download_location_list_tile.dart';
|
||||
|
||||
class DownloadLocationList extends StatefulWidget {
|
||||
const DownloadLocationList({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadLocationList> createState() => _DownloadLocationListState();
|
||||
}
|
||||
|
||||
class _DownloadLocationListState extends State<DownloadLocationList> {
|
||||
late Iterable<DownloadLocation> downloadLocationsIterable;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
downloadLocationsIterable =
|
||||
FinampSettingsHelper.finampSettings.downloadLocationsMap.values;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, child) {
|
||||
return ListView.builder(
|
||||
itemCount: downloadLocationsIterable.length,
|
||||
itemBuilder: (context, index) {
|
||||
return DownloadLocationListTile(
|
||||
downloadLocation: downloadLocationsIterable.elementAt(index),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import 'download_location_delete_dialog.dart';
|
||||
|
||||
class DownloadLocationListTile extends StatelessWidget {
|
||||
const DownloadLocationListTile({
|
||||
Key? key,
|
||||
required this.downloadLocation,
|
||||
}) : super(key: key);
|
||||
|
||||
final DownloadLocation downloadLocation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(downloadLocation.name),
|
||||
subtitle: Text(
|
||||
downloadLocation.path,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: downloadLocation.deletable
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => DownloadLocationDeleteDialog(
|
||||
id: downloadLocation.id,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import 'download_error_list_tile.dart';
|
||||
|
||||
class DownloadErrorList extends StatefulWidget {
|
||||
const DownloadErrorList({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadErrorList> createState() => _DownloadErrorListState();
|
||||
}
|
||||
|
||||
class _DownloadErrorListState extends State<DownloadErrorList> {
|
||||
List<DownloadTask>? loadedDownloadTasks;
|
||||
|
||||
late Future<List<DownloadTask>?> downloadErrorListFuture;
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
downloadErrorListFuture =
|
||||
downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<DownloadTask>?>(
|
||||
future: downloadErrorListFuture,
|
||||
builder: (context, snapshot) {
|
||||
loadedDownloadTasks = snapshot.data;
|
||||
if (snapshot.hasData) {
|
||||
if (snapshot.data!.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check,
|
||||
size: 64,
|
||||
// Inactive icons have an opacity of 50% with dark theme and 38%
|
||||
// with bright theme
|
||||
// https://material.io/design/iconography/system-icons.html#color
|
||||
color: Theme.of(context).iconTheme.color?.withOpacity(
|
||||
Theme.of(context).brightness == Brightness.light
|
||||
? 0.38
|
||||
: 0.5)),
|
||||
const Padding(padding: EdgeInsets.all(8.0)),
|
||||
Text(AppLocalizations.of(context)!.noErrors),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
return DownloadErrorListTile(
|
||||
downloadTask: snapshot.data![index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(AppLocalizations.of(context)!.errorScreenError),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/process_artist.dart';
|
||||
import '../album_image.dart';
|
||||
|
||||
class DownloadErrorListTile extends StatelessWidget {
|
||||
const DownloadErrorListTile({Key? key, required this.downloadTask})
|
||||
: super(key: key);
|
||||
|
||||
final DownloadTask downloadTask;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
DownloadedSong? downloadedSong =
|
||||
downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId);
|
||||
|
||||
if (downloadedSong == null) {
|
||||
return ListTile(
|
||||
title: Text(downloadTask.taskId),
|
||||
subtitle:
|
||||
Text(AppLocalizations.of(context)!.failedToGetSongFromDownloadId),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: AlbumImage(item: downloadedSong.song),
|
||||
title: Text(downloadedSong.song.name == null
|
||||
? AppLocalizations.of(context)!.unknownName
|
||||
: downloadedSong.song.name!),
|
||||
subtitle: Text(processArtist(downloadedSong.song.albumArtist, context)),
|
||||
// trailing: IconButton(
|
||||
// icon: Icon(Icons.refresh),
|
||||
// onPressed: () {},
|
||||
// tooltip: "Retry",
|
||||
// ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:file_sizes/file_sizes.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
|
||||
class AlbumFileSize extends StatelessWidget {
|
||||
const AlbumFileSize({Key? key, required this.downloadedParent})
|
||||
: super(key: key);
|
||||
|
||||
final DownloadedParent downloadedParent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
int totalSize = 0;
|
||||
|
||||
for (final item in downloadedParent.downloadedChildren.values) {
|
||||
DownloadedSong? downloadedSong =
|
||||
downloadsHelper.getDownloadedSong(item.id);
|
||||
|
||||
if (downloadedSong?.mediaSourceInfo.size != null) {
|
||||
totalSize += downloadedSong!.mediaSourceInfo.size!;
|
||||
}
|
||||
}
|
||||
|
||||
return Text(FileSize.getSize(totalSize));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import '../album_image.dart';
|
||||
|
||||
class CurrentDownloadsList extends StatefulWidget {
|
||||
const CurrentDownloadsList({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CurrentDownloadsList> createState() => _CurrentDownloadsListState();
|
||||
}
|
||||
|
||||
class _CurrentDownloadsListState extends State<CurrentDownloadsList> {
|
||||
final ReceivePort _port = ReceivePort();
|
||||
final DownloadsHelper _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
IsolateNameServer.registerPortWithName(
|
||||
_port.sendPort, 'downloader_send_port');
|
||||
_port.listen((dynamic data) {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
FlutterDownloader.registerCallback(downloadCallback);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
IsolateNameServer.removePortNameMapping('downloader_send_port');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// https://github.com/fluttercommunity/flutter_downloader/issues/629
|
||||
@pragma('vm:entry-point')
|
||||
static void downloadCallback(String id, int status, int progress) {
|
||||
final SendPort? send =
|
||||
IsolateNameServer.lookupPortByName('downloader_send_port');
|
||||
send?.send([id, status, progress]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<DownloadTask>?>(
|
||||
future: _downloadsHelper.getIncompleteDownloads(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return SliverList(
|
||||
delegate:
|
||||
SliverChildBuilderDelegate((BuildContext context, int index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CurrentDownloadListTile(
|
||||
downloadTask: snapshot.data![index],
|
||||
));
|
||||
}, childCount: snapshot.data!.length),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const Text("An error occured while getting current downloads")
|
||||
]));
|
||||
} else {
|
||||
return SliverList(delegate: SliverChildListDelegate([]));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CurrentDownloadListTile extends StatelessWidget {
|
||||
const CurrentDownloadListTile({Key? key, required this.downloadTask})
|
||||
: super(key: key);
|
||||
|
||||
final DownloadTask downloadTask;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
DownloadedSong? item =
|
||||
downloadsHelper.getJellyfinItemFromDownloadId(downloadTask.taskId);
|
||||
|
||||
return Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: downloadTask.progress / 100,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
ListTile(
|
||||
leading: AlbumImage(item: item?.song),
|
||||
title: Text(item?.song.name ?? "???"),
|
||||
subtitle: Text(item?.song.albumArtist ?? "???"),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../screens/downloads_error_screen.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
|
||||
class DownloadErrorScreenButton extends StatefulWidget {
|
||||
const DownloadErrorScreenButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadErrorScreenButton> createState() =>
|
||||
_DownloadErrorScreenButtonState();
|
||||
}
|
||||
|
||||
class _DownloadErrorScreenButtonState extends State<DownloadErrorScreenButton> {
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
late Future<List<DownloadTask>?> downloadErrorScreenButtonFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
downloadErrorScreenButtonFuture =
|
||||
_downloadsHelper.getDownloadsWithStatus(DownloadTaskStatus.failed);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<DownloadTask>?>(
|
||||
future: downloadErrorScreenButtonFuture,
|
||||
builder: (context, snapshot) {
|
||||
return IconButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pushNamed(DownloadsErrorScreen.routeName),
|
||||
icon: Icon(
|
||||
Icons.error,
|
||||
color: snapshot.data?.isNotEmpty ?? false
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
tooltip: AppLocalizations.of(context)!.downloadErrors,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class DownloadMissingImagesButton extends StatefulWidget {
|
||||
const DownloadMissingImagesButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadMissingImagesButton> createState() =>
|
||||
_DownloadMissingImagesButtonState();
|
||||
}
|
||||
|
||||
class _DownloadMissingImagesButtonState
|
||||
extends State<DownloadMissingImagesButton> {
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// The user shouldn't be able to check for missing downloads while offline
|
||||
bool _enabled = !FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
void downloadImages() async {
|
||||
// We don't want two checks to happen at once, so we disable the
|
||||
// button while a check is running (it shouldn't take long enough
|
||||
// for the user to be able to press twice, but you never know)
|
||||
setState(() {
|
||||
_enabled = false;
|
||||
});
|
||||
|
||||
final imagesDownloaded = await _downloadsHelper.downloadMissingImages();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!
|
||||
.downloadedMissingImages(imagesDownloaded)),
|
||||
));
|
||||
|
||||
setState(() {
|
||||
_enabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: _enabled ? () => downloadImages() : null,
|
||||
icon: const Icon(Icons.image),
|
||||
tooltip: AppLocalizations.of(context)!.downloadMissingImages,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:finamp/services/jellyfin_api_helper.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../album_image.dart';
|
||||
import '../confirmation_prompt_dialog.dart';
|
||||
import 'item_media_source_info.dart';
|
||||
import 'album_file_size.dart';
|
||||
|
||||
class DownloadedAlbumsList extends StatefulWidget {
|
||||
const DownloadedAlbumsList({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadedAlbumsList> createState() => _DownloadedAlbumsListState();
|
||||
}
|
||||
|
||||
class _DownloadedAlbumsListState extends State<DownloadedAlbumsList> {
|
||||
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
final JellyfinApiHelper jellyfinApiHelper = JellyfinApiHelper();
|
||||
|
||||
Future<void> deleteAlbum(
|
||||
BuildContext context, DownloadedParent downloadedParent) async {
|
||||
List<String> itemIds = [];
|
||||
for (BaseItemDto item in downloadedParent.downloadedChildren.values) {
|
||||
itemIds.add(item.id);
|
||||
}
|
||||
await downloadsHelper.deleteParentAndChildDownloads(
|
||||
jellyfinItemIds: itemIds, deletedFor: downloadedParent.item.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Iterable<DownloadedParent> downloadedParents =
|
||||
downloadsHelper.downloadedParents;
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
DownloadedParent album = downloadedParents.elementAt(index);
|
||||
return ExpansionTile(
|
||||
key: PageStorageKey(album.item.id),
|
||||
leading: AlbumImage(item: album.item),
|
||||
title: Text(album.item.name ?? "Unknown Name"),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmationPromptDialog(
|
||||
promptText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsPrompt(
|
||||
album.item.name ?? "",
|
||||
album.item.type == "Playlist"
|
||||
? "playlist"
|
||||
: "album"),
|
||||
confirmButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsConfirmButtonText,
|
||||
abortButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsAbortButtonText,
|
||||
onConfirmed: () async {
|
||||
await deleteAlbum(context, album);
|
||||
setState(() {});
|
||||
},
|
||||
onAborted: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: AlbumFileSize(
|
||||
downloadedParent: album,
|
||||
),
|
||||
children: [
|
||||
DownloadedSongsInAlbumList(
|
||||
children: album.downloadedChildren.values,
|
||||
parent: album,
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
childCount: downloadedParents.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadedSongsInAlbumList extends StatefulWidget {
|
||||
const DownloadedSongsInAlbumList(
|
||||
{Key? key, required this.children, required this.parent})
|
||||
: super(key: key);
|
||||
|
||||
final Iterable<BaseItemDto> children;
|
||||
final DownloadedParent parent;
|
||||
|
||||
@override
|
||||
State<DownloadedSongsInAlbumList> createState() =>
|
||||
_DownloadedSongsInAlbumListState();
|
||||
}
|
||||
|
||||
class _DownloadedSongsInAlbumListState
|
||||
extends State<DownloadedSongsInAlbumList> {
|
||||
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(children: [
|
||||
//TODO use a list builder here
|
||||
for (final song in widget.children)
|
||||
ListTile(
|
||||
title: Text(song.name ?? "Unknown Name"),
|
||||
leading: AlbumImage(item: song),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmationPromptDialog(
|
||||
promptText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsPrompt(
|
||||
song.name ?? "",
|
||||
"track"),
|
||||
confirmButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsConfirmButtonText,
|
||||
abortButtonText: AppLocalizations.of(context)!
|
||||
.deleteDownloadsAbortButtonText,
|
||||
onConfirmed: () async {
|
||||
await deleteSong(context, song);
|
||||
setState(() {});
|
||||
},
|
||||
onAborted: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: ItemMediaSourceInfo(
|
||||
songId: song.id,
|
||||
),
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> deleteSong(BuildContext context, BaseItemDto itemDto) async {
|
||||
widget.parent.downloadedChildren
|
||||
.removeWhere((key, value) => value == itemDto);
|
||||
await downloadsHelper.deleteSong(jellyfinItemId: itemDto.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:file_sizes/file_sizes.dart';
|
||||
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class DownloadsFileSize extends StatefulWidget {
|
||||
const DownloadsFileSize({Key? key, required this.directory})
|
||||
: super(key: key);
|
||||
|
||||
final Directory directory;
|
||||
|
||||
@override
|
||||
State<DownloadsFileSize> createState() => _DownloadsFileSizeState();
|
||||
}
|
||||
|
||||
class _DownloadsFileSizeState extends State<DownloadsFileSize> {
|
||||
late Future<int> _downloadsFileSizeFuture;
|
||||
final DownloadsHelper _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_downloadsFileSizeFuture = _downloadsHelper.getDirSize(widget.directory);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: _downloadsFileSizeFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
FileSize.getSize(snapshot.data),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return const Text(
|
||||
"??? MB",
|
||||
style: TextStyle(color: Colors.red),
|
||||
);
|
||||
} else {
|
||||
return const Text(
|
||||
"...",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/download_update_stream.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
const double downloadsOverviewCardLoadingHeight = 120;
|
||||
|
||||
class DownloadsOverview extends StatefulWidget {
|
||||
const DownloadsOverview({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DownloadsOverview> createState() => _DownloadsOverviewState();
|
||||
}
|
||||
|
||||
class _DownloadsOverviewState extends State<DownloadsOverview> {
|
||||
late Future<List<DownloadTask>?> _downloadsOverviewFuture;
|
||||
final _downloadUpdateStream = GetIt.instance<DownloadUpdateStream>();
|
||||
final _downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
Map<String, DownloadTaskStatus>? _downloadTaskStatuses;
|
||||
|
||||
final Map<DownloadTaskStatus, int> _downloadCount = {
|
||||
DownloadTaskStatus.undefined: 0,
|
||||
DownloadTaskStatus.enqueued: 0,
|
||||
DownloadTaskStatus.running: 0,
|
||||
DownloadTaskStatus.complete: 0,
|
||||
DownloadTaskStatus.failed: 0,
|
||||
DownloadTaskStatus.canceled: 0,
|
||||
DownloadTaskStatus.paused: 0,
|
||||
};
|
||||
|
||||
bool _initialCountDone = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_downloadsOverviewFuture = FlutterDownloader.loadTasks();
|
||||
|
||||
// Like in DownloadedIndicator, we use our own listener instead of a
|
||||
// StreamBuilder to ensure that we capture all events.
|
||||
_downloadUpdateStream.stream.listen((event) {
|
||||
if (_downloadTaskStatuses != null &&
|
||||
_downloadTaskStatuses!.containsKey(event.id) &&
|
||||
_downloadTaskStatuses![event.id] != event.status) {
|
||||
setState(() {
|
||||
_downloadCount[_downloadTaskStatuses![event.id]!] =
|
||||
_downloadCount[_downloadTaskStatuses![event.id]]! - 1;
|
||||
_downloadCount[event.status] = _downloadCount[event.status]! + 1;
|
||||
_downloadTaskStatuses![event.id] = event.status;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<DownloadTask>?>(
|
||||
future: _downloadsOverviewFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
_downloadTaskStatuses ??= Map.fromEntries(
|
||||
snapshot.data!.map((e) => MapEntry(e.taskId, e.status)));
|
||||
|
||||
if (!_initialCountDone) {
|
||||
// Switch cases don't work for some reason
|
||||
for (var element in snapshot.data!) {
|
||||
if (element.status == DownloadTaskStatus.undefined) {
|
||||
_downloadCount[DownloadTaskStatus.undefined] =
|
||||
_downloadCount[DownloadTaskStatus.undefined]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.enqueued) {
|
||||
_downloadCount[DownloadTaskStatus.enqueued] =
|
||||
_downloadCount[DownloadTaskStatus.enqueued]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.running) {
|
||||
_downloadCount[DownloadTaskStatus.running] =
|
||||
_downloadCount[DownloadTaskStatus.running]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.complete) {
|
||||
_downloadCount[DownloadTaskStatus.complete] =
|
||||
_downloadCount[DownloadTaskStatus.complete]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.failed) {
|
||||
_downloadCount[DownloadTaskStatus.failed] =
|
||||
_downloadCount[DownloadTaskStatus.failed]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.canceled) {
|
||||
_downloadCount[DownloadTaskStatus.canceled] =
|
||||
_downloadCount[DownloadTaskStatus.canceled]! + 1;
|
||||
} else if (element.status == DownloadTaskStatus.paused) {
|
||||
_downloadCount[DownloadTaskStatus.paused] =
|
||||
_downloadCount[DownloadTaskStatus.paused]! + 1;
|
||||
}
|
||||
}
|
||||
_initialCountDone = true;
|
||||
}
|
||||
|
||||
// We have to awkwardly get two strings like this because Flutter's
|
||||
// internationalisation stuff doesn't support multiple plurals.
|
||||
// https://github.com/flutter/flutter/issues/86906
|
||||
final downloadedItemsString = AppLocalizations.of(context)!
|
||||
.downloadedItemsCount(_downloadsHelper.downloadedItems.length);
|
||||
final downloadedImagesString = AppLocalizations.of(context)!
|
||||
.downloadedImagesCount(_downloadsHelper.downloadedImages.length);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AutoSizeText(
|
||||
AppLocalizations.of(context)!
|
||||
.downloadCount(snapshot.data!.length),
|
||||
style: const TextStyle(fontSize: 28),
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!
|
||||
.downloadedItemsImagesCount(
|
||||
downloadedItemsString,
|
||||
downloadedImagesString),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.dlComplete(
|
||||
_downloadCount[DownloadTaskStatus.complete]!),
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.dlFailed(
|
||||
_downloadCount[DownloadTaskStatus.failed]!),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.dlEnqueued(
|
||||
_downloadCount[DownloadTaskStatus.enqueued]!),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.dlRunning(
|
||||
_downloadCount[DownloadTaskStatus.running]!),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
errorSnackbar(snapshot.error, context);
|
||||
return const SizedBox(
|
||||
height: downloadsOverviewCardLoadingHeight,
|
||||
child: Card(
|
||||
child: Icon(Icons.error),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox(
|
||||
height: downloadsOverviewCardLoadingHeight,
|
||||
child: Card(
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_sizes/file_sizes.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
|
||||
class ItemMediaSourceInfo extends StatelessWidget {
|
||||
const ItemMediaSourceInfo({Key? key, required this.songId}) : super(key: key);
|
||||
|
||||
final String songId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
MediaSourceInfo? mediaSourceInfo =
|
||||
downloadsHelper.getDownloadedSong(songId)?.mediaSourceInfo;
|
||||
|
||||
if (mediaSourceInfo == null) {
|
||||
return const Text("??? MB Unknown");
|
||||
} else {
|
||||
return Text(
|
||||
"${FileSize.getSize(mediaSourceInfo.size)} ${mediaSourceInfo.container?.toUpperCase()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:finamp/models/finamp_models.dart';
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:finamp/services/jellyfin_api_helper.dart';
|
||||
import 'package:finamp/services/sync_helper.dart';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class SyncDownloadedAlbumsOrPlaylistsButton extends StatelessWidget {
|
||||
SyncDownloadedAlbumsOrPlaylistsButton({super.key});
|
||||
|
||||
final _syncLogger = Logger("SyncDownloadedPlaylistsButton");
|
||||
final DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
final _jellyfinApiData = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
void syncPlaylists(BuildContext context) async {
|
||||
_syncLogger.info("Syncing downloaded playlists");
|
||||
|
||||
var syncHelper = DownloadsSyncHelper(_syncLogger);
|
||||
List<DownloadedParent> parents = downloadsHelper.downloadedParents.toList();
|
||||
|
||||
for (DownloadedParent parent in parents) {
|
||||
List<BaseItemDto>? items = await _jellyfinApiData.getItems(
|
||||
isGenres: false, parentItem: parent.item);
|
||||
|
||||
if (items == null) {
|
||||
_syncLogger.warning("Could not find any items for album or playlist id ${parent.item.id}");
|
||||
continue;
|
||||
}
|
||||
syncHelper.sync(context, parent.item, items);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
onPressed: () => syncPlaylists(context),
|
||||
icon: const Icon(Icons.sync),
|
||||
tooltip: AppLocalizations.of(context)!.syncDownloadedPlaylists,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class FastScrollSelector extends StatelessWidget {
|
||||
const FastScrollSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.showFastScroller),
|
||||
value: FinampSettingsHelper.finampSettings.showFastScroller,
|
||||
onChanged: (value) => FinampSettingsHelper.setShowFastScroller(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class DisableGestureSelector extends StatelessWidget {
|
||||
const DisableGestureSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.disableGesture),
|
||||
subtitle: Text(AppLocalizations.of(context)!.disableGestureSubtitle),
|
||||
value: FinampSettingsHelper.finampSettings.disableGesture,
|
||||
onChanged: (value) => FinampSettingsHelper.setDisableGesture(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class SwipeInsertQueueNextSelector extends StatelessWidget {
|
||||
const SwipeInsertQueueNextSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.swipeInsertQueueNext),
|
||||
subtitle:
|
||||
Text(AppLocalizations.of(context)!.swipeInsertQueueNextSubtitle),
|
||||
value: FinampSettingsHelper.finampSettings.swipeInsertQueueNext,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setSwipeInsertQueueNext(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:locale_names/locale_names.dart';
|
||||
|
||||
import '../../services/locale_helper.dart';
|
||||
|
||||
class LanguageList extends StatefulWidget {
|
||||
const LanguageList({super.key});
|
||||
|
||||
@override
|
||||
State<LanguageList> createState() => _LanguageListState();
|
||||
}
|
||||
|
||||
class _LanguageListState extends State<LanguageList> {
|
||||
// yeah I'm a computer science student how could you tell
|
||||
// (sorts locales without having to copy them into a list first)
|
||||
final locales = SplayTreeMap<String?, Locale>.fromIterable(
|
||||
AppLocalizations.supportedLocales,
|
||||
key: (element) => (element as Locale).defaultDisplayLanguage,
|
||||
value: (element) => element,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scrollbar(
|
||||
// We have a ValueListenableBuilder here to rebuild all the ListTiles when
|
||||
// the language changes
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: LocaleHelper.localeListener,
|
||||
builder: (_, __, ___) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// For some reason, setting the null (system) LanguageListTile to
|
||||
// const stops it from switching when going to/from the same
|
||||
// language as the system language (e.g., system to English on a
|
||||
// device set to English)
|
||||
// ignore: prefer_const_constructors
|
||||
SliverList(
|
||||
// ignore: prefer_const_constructors
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
// ignore: prefer_const_constructors
|
||||
LanguageListTile(),
|
||||
const Divider(),
|
||||
]),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final locale = locales.values.elementAt(index);
|
||||
|
||||
return LanguageListTile(locale: locale);
|
||||
},
|
||||
childCount: locales.length,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LanguageListTile extends StatelessWidget {
|
||||
const LanguageListTile({
|
||||
super.key,
|
||||
this.locale,
|
||||
});
|
||||
|
||||
final Locale? locale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RadioListTile<Locale?>(
|
||||
title: Text(locale?.nativeDisplayLanguage ??
|
||||
AppLocalizations.of(context)!.system),
|
||||
subtitle: locale == null
|
||||
? null
|
||||
: Text((LocaleHelper.locale == null
|
||||
? locale!.defaultDisplayLanguage
|
||||
: locale!.displayLanguageIn(LocaleHelper.locale!)) ??
|
||||
"???"),
|
||||
value: locale,
|
||||
groupValue: LocaleHelper.locale,
|
||||
onChanged: (_) {
|
||||
LocaleHelper.setLocale(locale);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
enum ContentGridViewCrossAxisCountType {
|
||||
portrait,
|
||||
landscape;
|
||||
|
||||
/// Human-readable version of the [ContentGridViewCrossAxisCountType]. For
|
||||
/// example, toString() on [ContentGridViewCrossAxisCountType.portrait],
|
||||
/// toString() would return "ContentGridViewCrossAxisCountType.portrait". With
|
||||
/// this function, the same input would return "Portrait".
|
||||
@override
|
||||
@Deprecated("Use toLocalisedString when possible")
|
||||
String toString() => _humanReadableName(this);
|
||||
|
||||
String toLocalisedString(BuildContext context) =>
|
||||
_humanReadableLocalisedName(this, context);
|
||||
|
||||
String _humanReadableName(
|
||||
ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType) {
|
||||
switch (contentGridViewCrossAxisCountType) {
|
||||
case ContentGridViewCrossAxisCountType.portrait:
|
||||
return "Portrait";
|
||||
case ContentGridViewCrossAxisCountType.landscape:
|
||||
return "Landscape";
|
||||
}
|
||||
}
|
||||
|
||||
String _humanReadableLocalisedName(
|
||||
ContentGridViewCrossAxisCountType contentGridViewCrossAxisCountType,
|
||||
BuildContext context) {
|
||||
switch (contentGridViewCrossAxisCountType) {
|
||||
case ContentGridViewCrossAxisCountType.portrait:
|
||||
return AppLocalizations.of(context)!.portrait;
|
||||
case ContentGridViewCrossAxisCountType.landscape:
|
||||
return AppLocalizations.of(context)!.landscape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContentGridViewCrossAxisCountListTile extends StatefulWidget {
|
||||
const ContentGridViewCrossAxisCountListTile({
|
||||
Key? key,
|
||||
required this.type,
|
||||
}) : super(key: key);
|
||||
|
||||
final ContentGridViewCrossAxisCountType type;
|
||||
|
||||
@override
|
||||
State<ContentGridViewCrossAxisCountListTile> createState() =>
|
||||
_ContentGridViewCrossAxisCountListTileState();
|
||||
}
|
||||
|
||||
class _ContentGridViewCrossAxisCountListTileState
|
||||
extends State<ContentGridViewCrossAxisCountListTile> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
switch (widget.type) {
|
||||
case ContentGridViewCrossAxisCountType.portrait:
|
||||
_controller.text = FinampSettingsHelper
|
||||
.finampSettings.contentGridViewCrossAxisCountPortrait
|
||||
.toString();
|
||||
break;
|
||||
case ContentGridViewCrossAxisCountType.landscape:
|
||||
_controller.text = FinampSettingsHelper
|
||||
.finampSettings.contentGridViewCrossAxisCountLandscape
|
||||
.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(AppLocalizations.of(context)!
|
||||
.gridCrossAxisCount(widget.type.toLocalisedString(context))),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(context)!.gridCrossAxisCountSubtitle(
|
||||
widget.type.toLocalisedString(context).toLowerCase()),
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 50 * MediaQuery.of(context).textScaleFactor,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) {
|
||||
final valueInt = int.tryParse(value);
|
||||
|
||||
if (valueInt != null && valueInt > 0) {
|
||||
switch (widget.type) {
|
||||
case ContentGridViewCrossAxisCountType.portrait:
|
||||
FinampSettingsHelper.setContentGridViewCrossAxisCountPortrait(
|
||||
valueInt);
|
||||
break;
|
||||
case ContentGridViewCrossAxisCountType.landscape:
|
||||
FinampSettingsHelper
|
||||
.setContentGridViewCrossAxisCountLandscape(valueInt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class ContentViewTypeDropdownListTile extends StatelessWidget {
|
||||
const ContentViewTypeDropdownListTile({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return ListTile(
|
||||
title: Text(AppLocalizations.of(context)!.viewType),
|
||||
subtitle: Text(AppLocalizations.of(context)!.viewTypeSubtitle),
|
||||
trailing: DropdownButton<ContentViewType>(
|
||||
value: box.get("FinampSettings")?.contentViewType,
|
||||
items: ContentViewType.values
|
||||
.map((e) => DropdownMenuItem<ContentViewType>(
|
||||
value: e,
|
||||
child: Text(e.toLocalisedString(context)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
FinampSettingsHelper.setContentViewType(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class HideSongArtistsIfSameAsAlbumArtistsSelector extends StatelessWidget {
|
||||
const HideSongArtistsIfSameAsAlbumArtistsSelector({Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!
|
||||
.hideSongArtistsIfSameAsAlbumArtists),
|
||||
subtitle: Text(AppLocalizations.of(context)!
|
||||
.hideSongArtistsIfSameAsAlbumArtistsSubtitle),
|
||||
value: FinampSettingsHelper
|
||||
.finampSettings.hideSongArtistsIfSameAsAlbumArtists,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setHideSongArtistsIfSameAsAlbumArtists(
|
||||
value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class ShowCoverAsPlayerBackgroundSelector extends StatelessWidget {
|
||||
const ShowCoverAsPlayerBackgroundSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.showCoverAsPlayerBackground),
|
||||
subtitle: Text(AppLocalizations.of(context)!
|
||||
.showCoverAsPlayerBackgroundSubtitle),
|
||||
value:
|
||||
FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setShowCoverAsPlayerBackground(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class ShowTextOnGridViewSelector extends StatelessWidget {
|
||||
const ShowTextOnGridViewSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.showTextOnGridView),
|
||||
subtitle:
|
||||
Text(AppLocalizations.of(context)!.showTextOnGridViewSubtitle),
|
||||
value: FinampSettingsHelper.finampSettings.showTextOnGridView,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setShowTextOnGridView(value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/theme_mode_helper.dart';
|
||||
|
||||
extension LocalisedName on ThemeMode {
|
||||
String toLocalisedString(BuildContext context) =>
|
||||
_humanReadableLocalisedName(this, context);
|
||||
|
||||
String _humanReadableLocalisedName(
|
||||
ThemeMode themeMode, BuildContext context) {
|
||||
switch (themeMode) {
|
||||
case ThemeMode.system:
|
||||
return AppLocalizations.of(context)!.system;
|
||||
case ThemeMode.light:
|
||||
return AppLocalizations.of(context)!.light;
|
||||
case ThemeMode.dark:
|
||||
return AppLocalizations.of(context)!.dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeSelector extends StatelessWidget {
|
||||
const ThemeSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<ThemeMode>>(
|
||||
valueListenable: ThemeModeHelper.themeModeListener,
|
||||
builder: (_, box, __) {
|
||||
return ListTile(
|
||||
title: const Text("Theme"),
|
||||
trailing: DropdownButton<ThemeMode>(
|
||||
value: box.get("ThemeMode"),
|
||||
items: ThemeMode.values
|
||||
.map((e) => DropdownMenuItem<ThemeMode>(
|
||||
value: e,
|
||||
child: Text(e.toLocalisedString(context)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
ThemeModeHelper.setThemeMode(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/finamp_logs_helper.dart';
|
||||
|
||||
class CopyLogsButton extends StatefulWidget {
|
||||
const CopyLogsButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CopyLogsButton> createState() => _CopyLogsButtonState();
|
||||
}
|
||||
|
||||
class _CopyLogsButtonState extends State<CopyLogsButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () async {
|
||||
final finampLogsHelper = GetIt.instance<FinampLogsHelper>();
|
||||
|
||||
await finampLogsHelper.copyLogs();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.logsCopied),
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:clipboard/clipboard.dart';
|
||||
import 'package:finamp/services/censored_log.dart';
|
||||
import 'package:finamp/services/contains_login.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class LogTile extends StatefulWidget {
|
||||
const LogTile({Key? key, required this.logRecord}) : super(key: key);
|
||||
|
||||
final LogRecord logRecord;
|
||||
|
||||
@override
|
||||
State<LogTile> createState() => _LogTileState();
|
||||
}
|
||||
|
||||
class _LogTileState extends State<LogTile> {
|
||||
final _controller = ExpansionTileController();
|
||||
|
||||
/// Whether the user has confirmed. This is used to stop onExpansionChanged
|
||||
/// from infinitely asking the user to confirm.
|
||||
bool hasConfirmed = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onLongPress: () async {
|
||||
try {
|
||||
await FlutterClipboard.copy(widget.logRecord.censoredMessage);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
child: Card(
|
||||
color: _logColor(widget.logRecord.level, context),
|
||||
child: ExpansionTile(
|
||||
controller: _controller,
|
||||
key: PageStorageKey(widget.logRecord.time),
|
||||
leading: _LogIcon(level: widget.logRecord.level),
|
||||
title: RichText(
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
text:
|
||||
"[${widget.logRecord.loggerName}]\n${widget.logRecord.time}",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: RichText(
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
text: widget.logRecord.loginCensoredMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
expandedAlignment: Alignment.centerLeft,
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
textColor: _logTextColor(widget.logRecord.level, context),
|
||||
collapsedTextColor: _logTextColor(widget.logRecord.level, context),
|
||||
iconColor: _logTextColor(widget.logRecord.level, context),
|
||||
collapsedIconColor: _logTextColor(widget.logRecord.level, context),
|
||||
// Remove the border when expanded
|
||||
shape: const Border(),
|
||||
childrenPadding: const EdgeInsets.all(8.0),
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.message,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
_LogMessageContent(widget.logRecord.message),
|
||||
const SizedBox(height: 16.0),
|
||||
if (widget.logRecord.stackTrace != null)
|
||||
Text(
|
||||
AppLocalizations.of(context)!.stackTrace,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (widget.logRecord.stackTrace != null)
|
||||
_LogMessageContent(widget.logRecord.stackTrace.toString()),
|
||||
],
|
||||
onExpansionChanged: (value) async {
|
||||
if (value && !hasConfirmed && widget.logRecord.containsLogin) {
|
||||
_controller.collapse();
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.confirm),
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.showUncensoredLogMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(MaterialLocalizations.of(context)
|
||||
.cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(AppLocalizations.of(context)!.confirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
hasConfirmed = confirmed!;
|
||||
|
||||
if (confirmed) {
|
||||
_controller.expand();
|
||||
}
|
||||
} else if (hasConfirmed) {
|
||||
hasConfirmed = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _logColor(Level level, BuildContext context) {
|
||||
if (level == Level.WARNING) {
|
||||
return Theme.of(context).colorScheme.tertiaryContainer;
|
||||
} else if (level == Level.SEVERE) {
|
||||
return Theme.of(context).colorScheme.errorContainer;
|
||||
} else {
|
||||
return Theme.of(context).colorScheme.primaryContainer;
|
||||
}
|
||||
}
|
||||
|
||||
Color _logTextColor(Level level, BuildContext context) {
|
||||
if (level == Level.WARNING) {
|
||||
return Theme.of(context).colorScheme.onTertiaryContainer;
|
||||
} else if (level == Level.SEVERE) {
|
||||
return Theme.of(context).colorScheme.onErrorContainer;
|
||||
} else {
|
||||
return Theme.of(context).colorScheme.onPrimaryContainer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LogIcon extends StatelessWidget {
|
||||
const _LogIcon({Key? key, required this.level}) : super(key: key);
|
||||
|
||||
final Level level;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (level == Level.INFO) {
|
||||
return const Icon(Icons.info);
|
||||
} else if (level == Level.WARNING) {
|
||||
return const Icon(Icons.warning);
|
||||
} else if (level == Level.SEVERE) {
|
||||
return const Icon(Icons.error);
|
||||
} else {
|
||||
return const Icon(Icons.info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LogMessageContent extends StatelessWidget {
|
||||
const _LogMessageContent(this.content, {Key? key}) : super(key: key);
|
||||
|
||||
final String content;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
content,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
fontFamily: "monospace",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import 'log_tile.dart';
|
||||
import '../../services/finamp_logs_helper.dart';
|
||||
|
||||
class LogsView extends StatelessWidget {
|
||||
const LogsView({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
FinampLogsHelper finampLogsHelper = GetIt.instance<FinampLogsHelper>();
|
||||
|
||||
return Scrollbar(
|
||||
child: ListView.builder(
|
||||
itemCount: finampLogsHelper.logs.length,
|
||||
reverse: true,
|
||||
itemBuilder: (context, index) {
|
||||
return LogTile(
|
||||
logRecord: finampLogsHelper.logs.reversed.elementAt(index));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../services/finamp_logs_helper.dart';
|
||||
|
||||
class ShareLogsButton extends StatelessWidget {
|
||||
const ShareLogsButton({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.adaptive.share),
|
||||
tooltip: AppLocalizations.of(context)!.shareLogs,
|
||||
onPressed: () async {
|
||||
final finampLogsHelper = GetIt.instance<FinampLogsHelper>();
|
||||
|
||||
await finampLogsHelper.shareLogs();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'package:finamp/components/MusicScreen/album_item_list_tile.dart';
|
||||
import 'package:finamp/services/downloads_helper.dart';
|
||||
import 'package:finamp/services/finamp_settings_helper.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/audio_service_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../screens/artist_screen.dart';
|
||||
import '../../screens/album_screen.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import 'album_item_card.dart';
|
||||
|
||||
enum _AlbumListTileMenuItems {
|
||||
addToQueue,
|
||||
playNext,
|
||||
addFavourite,
|
||||
removeFavourite,
|
||||
addToMixList,
|
||||
removeFromMixList,
|
||||
}
|
||||
|
||||
/// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile.
|
||||
/// Depending on the values given, a list tile or a card will be returned. This
|
||||
/// widget exists to handle the dropdown stuff and other stuff shared between
|
||||
/// the two widgets.
|
||||
class AlbumItem extends StatefulWidget {
|
||||
const AlbumItem({
|
||||
Key? key,
|
||||
required this.album,
|
||||
this.parentType,
|
||||
this.onTap,
|
||||
this.isGrid = false,
|
||||
this.gridAddSettingsListener = false,
|
||||
}) : super(key: key);
|
||||
|
||||
/// The album (or item, I just used to call items albums before Finamp
|
||||
/// supported other types) to show in the widget.
|
||||
final BaseItemDto album;
|
||||
|
||||
/// The parent type of the item. Used to change onTap functionality for stuff
|
||||
/// like artists.
|
||||
final String? parentType;
|
||||
|
||||
/// A custom onTap can be provided to override the default value, which is to
|
||||
/// open the item's album/artist screen.
|
||||
final void Function()? onTap;
|
||||
|
||||
/// If specified, use cards instead of list tiles. Use this if you want to use
|
||||
/// this widget in a grid view.
|
||||
final bool isGrid;
|
||||
|
||||
/// If true, the grid item will use a ValueListenableBuilder to check whether
|
||||
/// or not to show the text. You'll want to set this to false if the
|
||||
/// [AlbumItem] would be rebuilt by FinampSettings anyway.
|
||||
final bool gridAddSettingsListener;
|
||||
|
||||
@override
|
||||
State<AlbumItem> createState() => _AlbumItemState();
|
||||
}
|
||||
|
||||
class _AlbumItemState extends State<AlbumItem> {
|
||||
final _audioServiceHelper = GetIt.instance<AudioServiceHelper>();
|
||||
|
||||
late BaseItemDto mutableAlbum;
|
||||
|
||||
late Function() onTap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mutableAlbum = widget.album;
|
||||
|
||||
// this is jank lol
|
||||
onTap = widget.onTap ??
|
||||
() {
|
||||
if (mutableAlbum.type == "MusicArtist" ||
|
||||
mutableAlbum.type == "MusicGenre") {
|
||||
Navigator.of(context)
|
||||
.pushNamed(ArtistScreen.routeName, arguments: mutableAlbum);
|
||||
} else {
|
||||
Navigator.of(context)
|
||||
.pushNamed(AlbumScreen.routeName, arguments: mutableAlbum);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
|
||||
return Padding(
|
||||
padding: widget.isGrid
|
||||
? Theme.of(context).cardTheme.margin ?? const EdgeInsets.all(4.0)
|
||||
: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
Feedback.forLongPress(context);
|
||||
|
||||
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
final selection = await showMenu<_AlbumListTileMenuItems>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
screenSize.width - details.globalPosition.dx,
|
||||
screenSize.height - details.globalPosition.dy,
|
||||
),
|
||||
items: [
|
||||
if (_audioServiceHelper.hasQueueItems()) ...[
|
||||
PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
value: _AlbumListTileMenuItems.addToQueue,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.addToQueue),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
value: _AlbumListTileMenuItems.playNext,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.queue_music),
|
||||
title: Text(AppLocalizations.of(context)!.playNext),
|
||||
),
|
||||
),
|
||||
],
|
||||
mutableAlbum.userData!.isFavorite
|
||||
? PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: _AlbumListTileMenuItems.removeFavourite,
|
||||
child: ListTile(
|
||||
enabled: !isOffline,
|
||||
leading: const Icon(Icons.favorite_border),
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.removeFavourite),
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: _AlbumListTileMenuItems.addFavourite,
|
||||
child: ListTile(
|
||||
enabled: !isOffline,
|
||||
leading: const Icon(Icons.favorite),
|
||||
title: Text(AppLocalizations.of(context)!.addFavourite),
|
||||
),
|
||||
),
|
||||
jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id)
|
||||
? PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: _AlbumListTileMenuItems.removeFromMixList,
|
||||
child: ListTile(
|
||||
enabled: !isOffline,
|
||||
leading: const Icon(Icons.explore_off),
|
||||
title:
|
||||
Text(AppLocalizations.of(context)!.removeFromMix),
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<_AlbumListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: _AlbumListTileMenuItems.addToMixList,
|
||||
child: ListTile(
|
||||
enabled: !isOffline,
|
||||
leading: const Icon(Icons.explore),
|
||||
title: Text(AppLocalizations.of(context)!.addToMix),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (selection) {
|
||||
case _AlbumListTileMenuItems.addToQueue:
|
||||
List<BaseItemDto>? children;
|
||||
|
||||
if (isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// The downloadedParent won't be null here if we've already
|
||||
// navigated to it in offline mode
|
||||
final downloadedParent =
|
||||
downloadsHelper.getDownloadedParent(widget.album.id)!;
|
||||
|
||||
children = downloadedParent.downloadedChildren.values.toList();
|
||||
} else {
|
||||
children = await jellyfinApiHelper.getItems(
|
||||
parentItem: widget.album,
|
||||
sortBy: "ParentIndexNumber,IndexNumber,SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (children != null) {
|
||||
await _audioServiceHelper.addQueueItems(children);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.addedToQueue),
|
||||
));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case _AlbumListTileMenuItems.playNext:
|
||||
List<BaseItemDto>? children;
|
||||
if (isOffline) {
|
||||
final downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
// The downloadedParent won't be null here if we've already
|
||||
// navigated to it in offline mode
|
||||
final downloadedParent =
|
||||
downloadsHelper.getDownloadedParent(widget.album.id)!;
|
||||
|
||||
children = downloadedParent.downloadedChildren.values.toList();
|
||||
} else {
|
||||
children = await jellyfinApiHelper.getItems(
|
||||
parentItem: widget.album,
|
||||
sortBy: "ParentIndexNumber,IndexNumber,SortName",
|
||||
includeItemTypes: "Audio",
|
||||
isGenres: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (children != null) {
|
||||
await _audioServiceHelper.insertQueueItemsNext(children);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content:
|
||||
Text(AppLocalizations.of(context)!.insertedIntoQueue),
|
||||
));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case _AlbumListTileMenuItems.addFavourite:
|
||||
try {
|
||||
final newUserData =
|
||||
await jellyfinApiHelper.addFavourite(mutableAlbum.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
mutableAlbum.userData = newUserData;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Favourite added.")));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case _AlbumListTileMenuItems.removeFavourite:
|
||||
try {
|
||||
final newUserData =
|
||||
await jellyfinApiHelper.removeFavourite(mutableAlbum.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
mutableAlbum.userData = newUserData;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Favourite removed.")));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case _AlbumListTileMenuItems.addToMixList:
|
||||
try {
|
||||
jellyfinApiHelper.addAlbumToMixBuilderList(mutableAlbum);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case _AlbumListTileMenuItems.removeFromMixList:
|
||||
try {
|
||||
jellyfinApiHelper.removeAlbumFromBuilderList(mutableAlbum);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: widget.isGrid
|
||||
? AlbumItemCard(
|
||||
item: mutableAlbum,
|
||||
onTap: onTap,
|
||||
parentType: widget.parentType,
|
||||
addSettingsListener: widget.gridAddSettingsListener,
|
||||
)
|
||||
: AlbumItemListTile(
|
||||
item: mutableAlbum,
|
||||
onTap: onTap,
|
||||
parentType: widget.parentType,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/generate_subtitle.dart';
|
||||
import '../album_image.dart';
|
||||
|
||||
/// Card content for AlbumItem. You probably shouldn't use this widget directly,
|
||||
/// use AlbumItem instead.
|
||||
class AlbumItemCard extends StatelessWidget {
|
||||
const AlbumItemCard({
|
||||
Key? key,
|
||||
required this.item,
|
||||
this.parentType,
|
||||
this.onTap,
|
||||
this.addSettingsListener = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final String? parentType;
|
||||
final void Function()? onTap;
|
||||
final bool addSettingsListener;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
// In AlbumItem, the OpenContainer handles padding.
|
||||
margin: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: AlbumImage.borderRadius,
|
||||
child: Stack(
|
||||
children: [
|
||||
AlbumImage(item: item),
|
||||
addSettingsListener
|
||||
? // We need this ValueListenableBuilder to react to changes to
|
||||
// showTextOnGridView. When shown in a MusicScreen, this widget
|
||||
// would refresh anyway since MusicScreen also listens to
|
||||
// FinampSettings, but there may be cases where this widget is used
|
||||
// elsewhere.
|
||||
ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable:
|
||||
FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
if (box.get("FinampSettings")!.showTextOnGridView) {
|
||||
return _AlbumItemCardText(
|
||||
item: item, parentType: parentType);
|
||||
} else {
|
||||
// ValueListenableBuilder doesn't let us return null, so we
|
||||
// return a 0-sized SizedBox.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
)
|
||||
: FinampSettingsHelper.finampSettings.showTextOnGridView
|
||||
? _AlbumItemCardText(item: item, parentType: parentType)
|
||||
: const SizedBox.shrink(),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumItemCardText extends StatelessWidget {
|
||||
const _AlbumItemCardText({
|
||||
Key? key,
|
||||
required this.item,
|
||||
required this.parentType,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final String? parentType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = generateSubtitle(item, parentType, context);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
// We fade from half transparent black to transparent so that text is visible on bright images
|
||||
Colors.black.withOpacity(0.5),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name ?? "Unknown Name",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 3,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge!
|
||||
.copyWith(color: Colors.white),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Colors.white.withOpacity(0.7)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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/jellyfin_api_helper.dart';
|
||||
import '../../services/generate_subtitle.dart';
|
||||
import '../album_image.dart';
|
||||
|
||||
/// ListTile content for AlbumItem. You probably shouldn't use this widget
|
||||
/// directly, use AlbumItem instead.
|
||||
class AlbumItemListTile extends StatelessWidget {
|
||||
const AlbumItemListTile({
|
||||
Key? key,
|
||||
required this.item,
|
||||
this.parentType,
|
||||
this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final String? parentType;
|
||||
final void Function()? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final subtitle = generateSubtitle(item, parentType, context);
|
||||
|
||||
return ListTile(
|
||||
// This widget is used on the add to playlist screen, so we allow a custom
|
||||
// onTap to be passed as an argument.
|
||||
onTap: onTap,
|
||||
leading: AlbumImage(item: item),
|
||||
title: Text(
|
||||
item.name ?? AppLocalizations.of(context)!.unknownName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: subtitle == null ? null : Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: jellyfinApiHelper.selectedMixAlbumIds.contains(item.id)
|
||||
? const Icon(Icons.explore)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AlphabetList extends StatefulWidget {
|
||||
final Function(String) callback;
|
||||
|
||||
final String sortOrder;
|
||||
|
||||
const AlphabetList({super.key, required this.callback, required this.sortOrder});
|
||||
|
||||
@override
|
||||
State<AlphabetList> createState() => _AlphabetListState();
|
||||
}
|
||||
|
||||
class _AlphabetListState extends State<AlphabetList> {
|
||||
List<String> alphabet = ['#'] +
|
||||
List.generate(26, (int index) {
|
||||
return String.fromCharCode('A'.codeUnitAt(0) + index);
|
||||
});
|
||||
|
||||
|
||||
List<String> get getAlphabet => alphabet;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
orderTheList(alphabet);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AlphabetList oldWidget) {
|
||||
orderTheList(alphabet);
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
alphabet.length,
|
||||
(x) => InkWell(
|
||||
onTap: () {
|
||||
widget.callback(alphabet[x]);
|
||||
},
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
child: Text(
|
||||
alphabet[x].toUpperCase(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void orderTheList(List<String> list) {
|
||||
widget.sortOrder == "Ascending"
|
||||
? list.sort()
|
||||
: list.sort((a, b) => b.compareTo(a));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../screens/artist_screen.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../album_image.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
enum ArtistListTileMenuItems {
|
||||
addToFavourite,
|
||||
removeFromFavourite,
|
||||
addToMixList,
|
||||
removeFromMixList,
|
||||
}
|
||||
|
||||
class ArtistListTile extends StatefulWidget {
|
||||
const ArtistListTile({
|
||||
Key? key,
|
||||
required this.item,
|
||||
this.index,
|
||||
this.parentId,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final int? index;
|
||||
final String? parentId;
|
||||
|
||||
@override
|
||||
State<ArtistListTile> createState() => _ArtistListTileState();
|
||||
}
|
||||
|
||||
class _ArtistListTileState extends State<ArtistListTile> {
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
late BaseItemDto mutableItem = widget.item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final listTile = ListTile(
|
||||
onTap: () {
|
||||
Navigator.of(context)
|
||||
.pushNamed(ArtistScreen.routeName, arguments: mutableItem);
|
||||
},
|
||||
leading: AlbumImage(item: mutableItem),
|
||||
title: Text(
|
||||
mutableItem.name ?? "Unknown Name",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: null,
|
||||
trailing:
|
||||
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
|
||||
? const Icon(Icons.explore)
|
||||
: null,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
Feedback.forLongPress(context);
|
||||
// Some options are disabled in offline mode
|
||||
final isOffline = FinampSettingsHelper.finampSettings.isOffline;
|
||||
|
||||
final selection = await showMenu<ArtistListTileMenuItems>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
details.globalPosition.dx,
|
||||
details.globalPosition.dy,
|
||||
screenSize.width - details.globalPosition.dx,
|
||||
screenSize.height - details.globalPosition.dy,
|
||||
),
|
||||
items: [
|
||||
mutableItem.userData!.isFavorite
|
||||
? const PopupMenuItem<ArtistListTileMenuItems>(
|
||||
value: ArtistListTileMenuItems.removeFromFavourite,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.favorite_border),
|
||||
title: Text("Remove Favourite"),
|
||||
),
|
||||
)
|
||||
: const PopupMenuItem<ArtistListTileMenuItems>(
|
||||
value: ArtistListTileMenuItems.addToFavourite,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.favorite),
|
||||
title: Text("Add Favourite"),
|
||||
),
|
||||
),
|
||||
_jellyfinApiHelper.selectedMixArtistsIds.contains(mutableItem.id)
|
||||
? PopupMenuItem<ArtistListTileMenuItems>(
|
||||
enabled: !isOffline,
|
||||
value: ArtistListTileMenuItems.removeFromMixList,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.explore_off),
|
||||
title: const Text("Remove From Mix"),
|
||||
enabled: isOffline ? false : true,
|
||||
),
|
||||
)
|
||||
: PopupMenuItem<ArtistListTileMenuItems>(
|
||||
value: ArtistListTileMenuItems.addToMixList,
|
||||
enabled: !isOffline,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.explore),
|
||||
title: const Text("Add To Mix"),
|
||||
enabled: !isOffline,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (selection) {
|
||||
case ArtistListTileMenuItems.addToFavourite:
|
||||
try {
|
||||
final newUserData =
|
||||
await _jellyfinApiHelper.addFavourite(mutableItem.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
mutableItem.userData = newUserData;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Favourite added.")));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case ArtistListTileMenuItems.removeFromFavourite:
|
||||
try {
|
||||
final newUserData =
|
||||
await _jellyfinApiHelper.removeFavourite(mutableItem.id);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
mutableItem.userData = newUserData;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Favourite removed.")));
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case ArtistListTileMenuItems.addToMixList:
|
||||
try {
|
||||
_jellyfinApiHelper.addArtistToMixBuilderList(mutableItem);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case ArtistListTileMenuItems.removeFromMixList:
|
||||
try {
|
||||
_jellyfinApiHelper.removeArtistFromBuilderList(mutableItem);
|
||||
setState(() {});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
break;
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: listTile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../services/finamp_user_helper.dart';
|
||||
import '../../screens/downloads_screen.dart';
|
||||
import '../../screens/logs_screen.dart';
|
||||
import '../../screens/settings_screen.dart';
|
||||
import 'offline_mode_switch_list_tile.dart';
|
||||
import 'view_list_tile.dart';
|
||||
|
||||
class MusicScreenDrawer extends StatelessWidget {
|
||||
const MusicScreenDrawer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
return Drawer(
|
||||
child: Scrollbar(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate.fixed(
|
||||
[
|
||||
DrawerHeader(
|
||||
child: Stack(
|
||||
children: [
|
||||
const Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundImage: AssetImage(
|
||||
'images/finamp.png',
|
||||
),
|
||||
radius: 50.0,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment:
|
||||
Alignment.bottomCenter - const Alignment(0, 0.2),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.finamp,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
)),
|
||||
],
|
||||
)),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_download),
|
||||
title: Text(AppLocalizations.of(context)!.downloads),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(DownloadsScreen.routeName),
|
||||
),
|
||||
const OfflineModeSwitchListTile(),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
// This causes an error when logging out if we show this widget
|
||||
if (finampUserHelper.currentUser != null)
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
return ViewListTile(
|
||||
view: finampUserHelper.currentUser!.views.values
|
||||
.elementAt(index));
|
||||
}, childCount: finampUserHelper.currentUser!.views.length),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.science),
|
||||
title: Text(AppLocalizations.of(context)!.redesignBeta),
|
||||
onTap: () async => await launchUrl(Uri.parse(
|
||||
"https://github.com/jmshrv/finamp/releases/tag/0.9.2-beta")),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.warning),
|
||||
title: Text(AppLocalizations.of(context)!.logs),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(LogsScreen.routeName),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(SettingsScreen.routeName),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:finamp/components/MusicScreen/artist_item_list_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/downloads_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/finamp_user_helper.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../AlbumScreen/song_list_tile.dart';
|
||||
import '../error_snackbar.dart';
|
||||
import '../first_page_progress_indicator.dart';
|
||||
import '../new_page_progress_indicator.dart';
|
||||
import 'album_item.dart';
|
||||
import 'alphabet_item_list.dart';
|
||||
|
||||
class MusicScreenTabView extends StatefulWidget {
|
||||
const MusicScreenTabView({
|
||||
Key? key,
|
||||
required this.tabContentType,
|
||||
this.parentItem,
|
||||
this.searchTerm,
|
||||
required this.isFavourite,
|
||||
this.sortBy,
|
||||
this.sortOrder,
|
||||
this.view,
|
||||
this.albumArtist,
|
||||
}) : super(key: key);
|
||||
|
||||
final TabContentType tabContentType;
|
||||
final BaseItemDto? parentItem;
|
||||
final String? searchTerm;
|
||||
final bool isFavourite;
|
||||
final SortBy? sortBy;
|
||||
final SortOrder? sortOrder;
|
||||
final BaseItemDto? view;
|
||||
final String? albumArtist;
|
||||
|
||||
@override
|
||||
State<MusicScreenTabView> createState() => _MusicScreenTabViewState();
|
||||
}
|
||||
|
||||
// We use AutomaticKeepAliveClientMixin so that the view keeps its position after the tab is changed.
|
||||
// https://stackoverflow.com/questions/49439047/how-to-preserve-widget-states-in-flutter-when-navigating-using-bottomnavigation
|
||||
class _MusicScreenTabViewState extends State<MusicScreenTabView>
|
||||
with AutomaticKeepAliveClientMixin<MusicScreenTabView> {
|
||||
// If parentItem is null, we assume that this view is actually in a tab.
|
||||
// If it isn't null, this view is being used as an artist detail screen and shouldn't be kept alive.
|
||||
@override
|
||||
bool get wantKeepAlive => widget.parentItem == null;
|
||||
|
||||
static const _pageSize = 100;
|
||||
|
||||
final PagingController<int, BaseItemDto> _pagingController =
|
||||
PagingController(firstPageKey: 0);
|
||||
|
||||
List<BaseItemDto>? offlineSortedItems;
|
||||
|
||||
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
String? _lastSearch;
|
||||
bool? _oldIsFavourite;
|
||||
SortBy? _oldSortBy;
|
||||
SortOrder? _oldSortOrder;
|
||||
BaseItemDto? _oldView;
|
||||
ScrollController? controller;
|
||||
String? letterToSearch;
|
||||
String lastSortOrder = SortOrder.ascending.toString();
|
||||
Timer? timer;
|
||||
|
||||
// This function just lets us easily set stuff to the getItems call we want.
|
||||
Future<void> _getPage(int pageKey) async {
|
||||
try {
|
||||
final sortOrder =
|
||||
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
|
||||
final newItems = await _jellyfinApiHelper.getItems(
|
||||
// starting with Jellyfin 10.9, only automatically created playlists will have a specific library as parent. user-created playlists will not be returned anymore
|
||||
// this condition fixes this by not providing a parentId when fetching playlists
|
||||
parentItem: widget.tabContentType == TabContentType.playlists
|
||||
? null
|
||||
: widget.parentItem ??
|
||||
widget.view ??
|
||||
_finampUserHelper.currentUser?.currentView,
|
||||
includeItemTypes: _includeItemTypes(widget.tabContentType),
|
||||
|
||||
// If we're on the songs tab, sort by "Album,SortName". This is what the
|
||||
// Jellyfin web client does. If this isn't the case, check if parentItem
|
||||
// is null. parentItem will be null when this widget is not used in an
|
||||
// artist view. If it's null, sort by "SortName". If it isn't null, check
|
||||
// if the parentItem is a MusicArtist. If it is, sort by year. Otherwise,
|
||||
// sort by SortName. If widget.sortBy is set, it is used instead.
|
||||
sortBy: widget.sortBy?.jellyfinName(widget.tabContentType) ??
|
||||
(widget.tabContentType == TabContentType.songs
|
||||
? "Album,SortName"
|
||||
: widget.parentItem == null
|
||||
? "SortName"
|
||||
: widget.parentItem?.type == "MusicArtist"
|
||||
? "ProductionYear,PremiereDate"
|
||||
: "SortName"),
|
||||
sortOrder: sortOrder,
|
||||
searchTerm: widget.searchTerm?.trim(),
|
||||
// If this is the genres tab, tell getItems to get genres.
|
||||
isGenres: widget.tabContentType == TabContentType.genres,
|
||||
filters: widget.isFavourite ? "IsFavorite" : null,
|
||||
startIndex: pageKey,
|
||||
limit: _pageSize,
|
||||
);
|
||||
|
||||
if (newItems!.length < _pageSize) {
|
||||
_pagingController.appendLastPage(newItems);
|
||||
} else {
|
||||
_pagingController.appendPage(newItems, pageKey + newItems.length);
|
||||
}
|
||||
if (letterToSearch != null) {
|
||||
scrollToLetter(letterToSearch);
|
||||
timer?.cancel();
|
||||
timer = Timer(const Duration(seconds: 2, milliseconds: 500), () {
|
||||
scrollToNearbyLetter();
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
lastSortOrder = sortOrder;
|
||||
});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
}
|
||||
|
||||
String _getParentType() =>
|
||||
widget.parentItem?.type! ??
|
||||
_finampUserHelper.currentUser!.currentView!.type!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_pagingController.addPageRequestListener((pageKey) {
|
||||
_getPage(pageKey);
|
||||
});
|
||||
lastSortOrder =
|
||||
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
|
||||
controller = ScrollController();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(oldWidget) {
|
||||
setState(() {
|
||||
lastSortOrder =
|
||||
widget.sortOrder?.toString() ?? SortOrder.ascending.toString();
|
||||
});
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
// Scrolls the list to the first occurrence of the letter in the list
|
||||
// If clicked in the # element, it goes to the first one ( pixels = 0 )
|
||||
void scrollToLetter(String? clickedLetter) async {
|
||||
String? letter = clickedLetter ?? letterToSearch;
|
||||
if (letter == null) return;
|
||||
|
||||
letterToSearch = letter;
|
||||
|
||||
if (letter == '#') {
|
||||
double targetScroll = lastSortOrder == SortOrder.ascending.toString()
|
||||
? -(controller!.position.maxScrollExtent * 10)
|
||||
: controller!.position.maxScrollExtent * 10;
|
||||
|
||||
await controller?.animateTo(targetScroll,
|
||||
duration: const Duration(milliseconds: 200), curve: Curves.ease);
|
||||
} else {
|
||||
final indexWhere = _pagingController.itemList!.indexWhere((element) {
|
||||
final name = element.name!;
|
||||
final firstLetter =
|
||||
name.startsWith(RegExp(r'^the', caseSensitive: false))
|
||||
? name.split(RegExp(r'^the', caseSensitive: false))[1].trim()[0]
|
||||
: name[0].toUpperCase();
|
||||
return firstLetter == letter;
|
||||
});
|
||||
|
||||
if (indexWhere >= 0) {
|
||||
final scrollTo = (indexWhere * 72).toDouble();
|
||||
await controller?.animateTo(scrollTo,
|
||||
duration: const Duration(milliseconds: 200), curve: Curves.ease);
|
||||
letterToSearch = null;
|
||||
} else {
|
||||
await controller?.animateTo(controller!.position.maxScrollExtent * 100,
|
||||
duration: const Duration(milliseconds: 200), curve: Curves.ease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void scrollToNearbyLetter() {
|
||||
if (letterToSearch != null) {
|
||||
const standardAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
final closestLetterIndex = standardAlphabet.indexOf(letterToSearch!);
|
||||
if (closestLetterIndex != -1) {
|
||||
for (int offset = 0; offset <= standardAlphabet.length; offset++) {
|
||||
for (final direction in [1, -1]) {
|
||||
final nextIndex = closestLetterIndex + offset * direction;
|
||||
if (nextIndex >= 0 && nextIndex < standardAlphabet.length) {
|
||||
final nextLetter = standardAlphabet[nextIndex];
|
||||
final nextLetterIndex =
|
||||
_pagingController.itemList!.indexWhere((element) {
|
||||
final firstLetter = element.name![0].toUpperCase();
|
||||
return firstLetter == nextLetter;
|
||||
});
|
||||
|
||||
if (nextLetterIndex >= 0) {
|
||||
final scrollTo = (nextLetterIndex * 72).toDouble();
|
||||
controller?.animateTo(scrollTo,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease);
|
||||
letterToSearch = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pagingController.dispose();
|
||||
timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, _) {
|
||||
final isOffline = box.get("FinampSettings")?.isOffline ?? false;
|
||||
|
||||
if (isOffline) {
|
||||
// We do the same checks we do when online to ensure that the list is
|
||||
// not resorted when it doesn't have to be.
|
||||
if (widget.searchTerm != _lastSearch ||
|
||||
offlineSortedItems == null ||
|
||||
widget.isFavourite != _oldIsFavourite ||
|
||||
widget.sortBy != _oldSortBy ||
|
||||
widget.sortOrder != _oldSortOrder ||
|
||||
widget.view != _oldView) {
|
||||
_lastSearch = widget.searchTerm;
|
||||
_oldIsFavourite = widget.isFavourite;
|
||||
_oldSortBy = widget.sortBy;
|
||||
_oldSortOrder = widget.sortOrder;
|
||||
_oldView = widget.view;
|
||||
|
||||
DownloadsHelper downloadsHelper = GetIt.instance<DownloadsHelper>();
|
||||
|
||||
if (widget.tabContentType == TabContentType.artists) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cloud_off,
|
||||
size: 64,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.all(8.0)),
|
||||
const Text("Offline artists view hasn't been implemented")
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.searchTerm == null) {
|
||||
if (widget.tabContentType == TabContentType.songs) {
|
||||
// If we're on the songs tab, just get all of the downloaded items
|
||||
offlineSortedItems = downloadsHelper.downloadedItems
|
||||
.where((element) =>
|
||||
element.viewId ==
|
||||
_finampUserHelper.currentUser!.currentViewId)
|
||||
.map((e) => e.song)
|
||||
.toList();
|
||||
} else {
|
||||
String? albumArtist = widget.albumArtist;
|
||||
offlineSortedItems = downloadsHelper.downloadedParents
|
||||
.where((element) =>
|
||||
element.item.type ==
|
||||
_includeItemTypes(widget.tabContentType) &&
|
||||
element.viewId ==
|
||||
_finampUserHelper.currentUser!.currentViewId &&
|
||||
(albumArtist == null ||
|
||||
element.item.albumArtist?.toLowerCase() ==
|
||||
albumArtist.toLowerCase()))
|
||||
.map((e) => e.item)
|
||||
.toList();
|
||||
}
|
||||
} else {
|
||||
if (widget.tabContentType == TabContentType.songs) {
|
||||
offlineSortedItems = downloadsHelper.downloadedItems
|
||||
.where(
|
||||
(element) {
|
||||
return _offlineSearch(
|
||||
item: element.song,
|
||||
searchTerm: widget.searchTerm!,
|
||||
tabContentType: widget.tabContentType);
|
||||
},
|
||||
)
|
||||
.map((e) => e.song)
|
||||
.toList();
|
||||
} else {
|
||||
offlineSortedItems = downloadsHelper.downloadedParents
|
||||
.where(
|
||||
(element) {
|
||||
return _offlineSearch(
|
||||
item: element.item,
|
||||
searchTerm: widget.searchTerm!,
|
||||
tabContentType: widget.tabContentType);
|
||||
},
|
||||
)
|
||||
.map((e) => e.item)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
offlineSortedItems!.sort((a, b) {
|
||||
// if (a.name == null || b.name == null) {
|
||||
// // Returning 0 is the same as both being the same
|
||||
// return 0;
|
||||
// } else {
|
||||
// return a.name!.compareTo(b.name!);
|
||||
// }
|
||||
if (a.nameForSorting == null || b.nameForSorting == null) {
|
||||
// Returning 0 is the same as both being the same
|
||||
return 0;
|
||||
} else {
|
||||
switch (widget.sortBy) {
|
||||
case SortBy.sortName:
|
||||
if (a.nameForSorting == null || b.nameForSorting == null) {
|
||||
// Returning 0 is the same as both being the same
|
||||
return 0;
|
||||
} else {
|
||||
return a.nameForSorting!.compareTo(b.nameForSorting!);
|
||||
}
|
||||
case SortBy.albumArtist:
|
||||
if (a.albumArtist == null || b.albumArtist == null) {
|
||||
return 0;
|
||||
} else {
|
||||
return a.albumArtist!.compareTo(b.albumArtist!);
|
||||
}
|
||||
case SortBy.communityRating:
|
||||
if (a.communityRating == null ||
|
||||
b.communityRating == null) {
|
||||
return 0;
|
||||
} else {
|
||||
return a.communityRating!.compareTo(b.communityRating!);
|
||||
}
|
||||
case SortBy.criticRating:
|
||||
if (a.criticRating == null || b.criticRating == null) {
|
||||
return 0;
|
||||
} else {
|
||||
return a.criticRating!.compareTo(b.criticRating!);
|
||||
}
|
||||
case SortBy.dateCreated:
|
||||
if (a.dateCreated == null || b.dateCreated == null) {
|
||||
return 0;
|
||||
} else {
|
||||
return a.dateCreated!.compareTo(b.dateCreated!);
|
||||
}
|
||||
case SortBy.premiereDate:
|
||||
if (a.premiereDate == null || b.premiereDate == null) {
|
||||
return 0;
|
||||
} else {
|
||||
return a.premiereDate!.compareTo(b.premiereDate!);
|
||||
}
|
||||
case SortBy.random:
|
||||
// We subtract the result by one so that we can get -1 values
|
||||
// (see comareTo documentation)
|
||||
return Random().nextInt(2) - 1;
|
||||
default:
|
||||
throw UnimplementedError(
|
||||
"Unimplemented offline sort mode ${widget.sortBy}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (widget.sortOrder == SortOrder.descending) {
|
||||
// The above sort functions sort in ascending order, so we swap them
|
||||
// when sorting in descending order.
|
||||
offlineSortedItems = offlineSortedItems!.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
controller: controller,
|
||||
child: Stack(
|
||||
children: [
|
||||
box.get("FinampSettings")!.contentViewType ==
|
||||
ContentViewType.list
|
||||
? ListView.builder(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
itemCount: offlineSortedItems!.length,
|
||||
key: UniqueKey(),
|
||||
controller: controller,
|
||||
itemBuilder: (context, index) {
|
||||
if (widget.tabContentType == TabContentType.songs) {
|
||||
return SongListTile(
|
||||
item: offlineSortedItems![index],
|
||||
isSong: true,
|
||||
);
|
||||
} else {
|
||||
return AlbumItem(
|
||||
album: offlineSortedItems![index],
|
||||
parentType: _getParentType(),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: GridView.builder(
|
||||
itemCount: offlineSortedItems!.length,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
controller: controller,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: MediaQuery.of(context).size.width >
|
||||
MediaQuery.of(context).size.height
|
||||
? box
|
||||
.get("FinampSettings")!
|
||||
.contentGridViewCrossAxisCountLandscape
|
||||
: box
|
||||
.get("FinampSettings")!
|
||||
.contentGridViewCrossAxisCountPortrait,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (widget.tabContentType == TabContentType.songs) {
|
||||
return SongListTile(
|
||||
item: offlineSortedItems![index],
|
||||
isSong: true,
|
||||
);
|
||||
} else {
|
||||
return AlbumItem(
|
||||
album: offlineSortedItems![index],
|
||||
parentType: _getParentType(),
|
||||
isGrid: true,
|
||||
gridAddSettingsListener: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
box.get("FinampSettings")!.showFastScroller &&
|
||||
widget.sortBy == SortBy.sortName
|
||||
? AlphabetList(
|
||||
callback: scrollToLetter, sortOrder: lastSortOrder)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If the searchTerm argument is different to lastSearch, the user has changed their search input.
|
||||
// This makes albumViewFuture search again so that results with the search are shown.
|
||||
// This also means we don't redo a search unless we actaully need to.
|
||||
if (widget.searchTerm != _lastSearch ||
|
||||
_pagingController.itemList == null ||
|
||||
widget.isFavourite != _oldIsFavourite ||
|
||||
widget.sortBy != _oldSortBy ||
|
||||
widget.sortOrder != _oldSortOrder ||
|
||||
widget.view != _oldView) {
|
||||
_lastSearch = widget.searchTerm;
|
||||
_oldIsFavourite = widget.isFavourite;
|
||||
_oldSortBy = widget.sortBy;
|
||||
_oldSortOrder = widget.sortOrder;
|
||||
_oldView = widget.view;
|
||||
_pagingController.refresh();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
// RefreshIndicator wants an async function, so we use Future.sync()
|
||||
// to run refresh() inside an async function
|
||||
onRefresh: () => Future.sync(() => _pagingController.refresh()),
|
||||
child: Scrollbar(
|
||||
controller: controller,
|
||||
child: Stack(
|
||||
children: [
|
||||
box.get("FinampSettings")!.contentViewType ==
|
||||
ContentViewType.list
|
||||
? PagedListView<int, BaseItemDto>.separated(
|
||||
pagingController: _pagingController,
|
||||
scrollController: controller,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
builderDelegate:
|
||||
PagedChildBuilderDelegate<BaseItemDto>(
|
||||
itemBuilder: (context, item, index) {
|
||||
if (widget.tabContentType ==
|
||||
TabContentType.songs) {
|
||||
return SongListTile(
|
||||
item: item,
|
||||
isSong: true,
|
||||
);
|
||||
} else if (widget.tabContentType ==
|
||||
TabContentType.artists) {
|
||||
return ArtistListTile(item: item);
|
||||
} else {
|
||||
return AlbumItem(
|
||||
album: item,
|
||||
parentType: _getParentType(),
|
||||
);
|
||||
}
|
||||
},
|
||||
firstPageProgressIndicatorBuilder: (_) =>
|
||||
const FirstPageProgressIndicator(),
|
||||
newPageProgressIndicatorBuilder: (_) =>
|
||||
const NewPageProgressIndicator(),
|
||||
),
|
||||
separatorBuilder: (context, index) => SizedBox(
|
||||
height: widget.tabContentType ==
|
||||
TabContentType.artists ||
|
||||
widget.tabContentType ==
|
||||
TabContentType.genres
|
||||
? 16.0
|
||||
: 0.0,
|
||||
),
|
||||
)
|
||||
: PagedGridView(
|
||||
pagingController: _pagingController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
scrollController: controller,
|
||||
builderDelegate:
|
||||
PagedChildBuilderDelegate<BaseItemDto>(
|
||||
itemBuilder: (context, item, index) {
|
||||
if (widget.tabContentType ==
|
||||
TabContentType.songs) {
|
||||
return SongListTile(
|
||||
item: item,
|
||||
isSong: true,
|
||||
);
|
||||
} else {
|
||||
return AlbumItem(
|
||||
album: item,
|
||||
parentType: _getParentType(),
|
||||
isGrid: true,
|
||||
gridAddSettingsListener: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
firstPageProgressIndicatorBuilder: (_) =>
|
||||
const FirstPageProgressIndicator(),
|
||||
newPageProgressIndicatorBuilder: (_) =>
|
||||
const NewPageProgressIndicator(),
|
||||
),
|
||||
gridDelegate:
|
||||
SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: MediaQuery.of(context).size.width >
|
||||
MediaQuery.of(context).size.height
|
||||
? box
|
||||
.get("FinampSettings")!
|
||||
.contentGridViewCrossAxisCountLandscape
|
||||
: box
|
||||
.get("FinampSettings")!
|
||||
.contentGridViewCrossAxisCountPortrait,
|
||||
),
|
||||
),
|
||||
box.get("FinampSettings")!.showFastScroller &&
|
||||
widget.sortBy == SortBy.sortName
|
||||
? AlphabetList(
|
||||
callback: scrollToLetter, sortOrder: lastSortOrder)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _includeItemTypes(TabContentType tabContentType) {
|
||||
switch (tabContentType) {
|
||||
case TabContentType.songs:
|
||||
return "Audio";
|
||||
case TabContentType.albums:
|
||||
return "MusicAlbum";
|
||||
case TabContentType.artists:
|
||||
return "MusicArtist";
|
||||
case TabContentType.genres:
|
||||
return "MusicGenre";
|
||||
case TabContentType.playlists:
|
||||
return "Playlist";
|
||||
default:
|
||||
throw const FormatException("Unsupported TabContentType");
|
||||
}
|
||||
}
|
||||
|
||||
bool _offlineSearch(
|
||||
{required BaseItemDto item,
|
||||
required String searchTerm,
|
||||
required TabContentType tabContentType}) {
|
||||
late bool containsName;
|
||||
|
||||
// This horrible thing is for null safety
|
||||
if (item.name == null) {
|
||||
containsName = false;
|
||||
} else {
|
||||
containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase());
|
||||
}
|
||||
|
||||
return item.type == _includeItemTypes(tabContentType) && containsName;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class OfflineModeSwitchListTile extends StatelessWidget {
|
||||
const OfflineModeSwitchListTile({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, widget) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.offlineMode),
|
||||
secondary: const Icon(Icons.cloud_off),
|
||||
value: box.get("FinampSettings")?.isOffline ?? false,
|
||||
onChanged: (value) {
|
||||
FinampSettingsHelper.setIsOffline(value);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:finamp/models/finamp_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class SortByMenuButton extends StatelessWidget {
|
||||
const SortByMenuButton(this.tabType, {Key? key}) : super(key: key);
|
||||
|
||||
final TabContentType tabType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<SortBy>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: AppLocalizations.of(context)!.sortBy,
|
||||
itemBuilder: (context) => [
|
||||
for (SortBy sortBy in SortBy.defaults)
|
||||
PopupMenuItem(
|
||||
value: sortBy,
|
||||
child: Text(
|
||||
sortBy.toLocalisedString(context),
|
||||
style: TextStyle(
|
||||
color:
|
||||
FinampSettingsHelper.finampSettings.getTabSortBy(tabType) ==
|
||||
sortBy
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
onSelected: (value) => FinampSettingsHelper.setSortBy(tabType, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
|
||||
class SortOrderButton extends StatelessWidget {
|
||||
const SortOrderButton(this.tabType, {Key? key}) : super(key: key);
|
||||
|
||||
final TabContentType tabType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, _) {
|
||||
final finampSettings = box.get("FinampSettings");
|
||||
|
||||
return IconButton(
|
||||
tooltip: AppLocalizations.of(context)!.sortOrder,
|
||||
icon: finampSettings!.getSortOrder(tabType) == SortOrder.ascending
|
||||
? const Icon(Icons.arrow_downward)
|
||||
: const Icon(Icons.arrow_upward),
|
||||
onPressed: () {
|
||||
if (finampSettings.getSortOrder(tabType) == SortOrder.ascending) {
|
||||
FinampSettingsHelper.setSortOrder(tabType,SortOrder.descending);
|
||||
} else {
|
||||
FinampSettingsHelper.setSortOrder(tabType, SortOrder.ascending);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/finamp_user_helper.dart';
|
||||
import '../view_icon.dart';
|
||||
|
||||
class ViewListTile extends StatelessWidget {
|
||||
const ViewListTile({Key? key, required this.view}) : super(key: key);
|
||||
|
||||
final BaseItemDto view;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
return ListTile(
|
||||
leading: ViewIcon(
|
||||
collectionType: view.collectionType,
|
||||
color: finampUserHelper.currentUser!.currentViewId == view.id
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(
|
||||
view.name ?? "Unknown Name",
|
||||
style: TextStyle(
|
||||
color: finampUserHelper.currentUser!.currentViewId == view.id
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onTap: () => finampUserHelper.setCurrentUserCurrentViewId(view.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../screens/splash_screen.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../services/music_player_background_task.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class LogoutListTile extends StatefulWidget {
|
||||
const LogoutListTile({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LogoutListTile> createState() => _LogoutListTileState();
|
||||
}
|
||||
|
||||
class _LogoutListTileState extends State<LogoutListTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.logout,
|
||||
color:
|
||||
FinampSettingsHelper.finampSettings.isOffline ? null : Colors.red,
|
||||
),
|
||||
title: Text(
|
||||
AppLocalizations.of(context)!.logOut,
|
||||
style: FinampSettingsHelper.finampSettings.isOffline
|
||||
? null
|
||||
: const TextStyle(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
subtitle: FinampSettingsHelper.finampSettings.isOffline
|
||||
? Text(AppLocalizations.of(context)!.notAvailableInOfflineMode)
|
||||
: Text(
|
||||
AppLocalizations.of(context)!.downloadedSongsWillNotBeDeleted,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
enabled: !FinampSettingsHelper.finampSettings.isOffline,
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.areYouSure),
|
||||
actions: [
|
||||
TextButton(
|
||||
child:
|
||||
Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
onPressed: () async {
|
||||
try {
|
||||
final audioHandler =
|
||||
GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
// We don't want audio to be playing after we log out.
|
||||
// We check if the audio service is running on iOS because
|
||||
// stop() never completes if the service is not running.
|
||||
if (audioHandler.playbackState.valueOrNull?.playing ==
|
||||
true) {
|
||||
await audioHandler.stop();
|
||||
}
|
||||
|
||||
final jellyfinApiHelper =
|
||||
GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
await jellyfinApiHelper
|
||||
.logoutCurrentUser()
|
||||
.onError((_, __) {});
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
SplashScreen.routeName, (route) => false);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
return;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class HideTabToggle extends StatelessWidget {
|
||||
const HideTabToggle({
|
||||
Key? key,
|
||||
required this.tabContentType,
|
||||
}) : super(key: key);
|
||||
|
||||
final TabContentType tabContentType;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (_, box, __) {
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(tabContentType.toLocalisedString(context)),
|
||||
// secondary: const Icon(Icons.drag_handle),
|
||||
// This should never be null, but it gets set to true if it is.
|
||||
value: FinampSettingsHelper.finampSettings.showTabs[tabContentType] ??
|
||||
true,
|
||||
onChanged: (value) =>
|
||||
FinampSettingsHelper.setShowTab(tabContentType, value),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class BitrateSelector extends StatelessWidget {
|
||||
const BitrateSelector({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(AppLocalizations.of(context)!.bitrate),
|
||||
subtitle: Text(AppLocalizations.of(context)!.bitrateSubtitle),
|
||||
),
|
||||
ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, child) {
|
||||
final finampSettings = box.get("FinampSettings")!;
|
||||
|
||||
// We do all of this division/multiplication because Jellyfin wants us to specify bitrates in bits, not kilobits.
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Slider(
|
||||
min: 64,
|
||||
max: 320,
|
||||
value: finampSettings.transcodeBitrate / 1000,
|
||||
divisions: 8,
|
||||
label: "${finampSettings.transcodeBitrate ~/ 1000}kbps",
|
||||
onChanged: (value) {
|
||||
FinampSettingsHelper.setTranscodeBitrate(
|
||||
(value * 1000).toInt());
|
||||
},
|
||||
),
|
||||
Text(
|
||||
"${finampSettings.transcodeBitrate ~/ 1000}kbps",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import '../../services/finamp_settings_helper.dart';
|
||||
import '../../models/finamp_models.dart';
|
||||
|
||||
class TranscodeSwitch extends StatelessWidget {
|
||||
const TranscodeSwitch({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<Box<FinampSettings>>(
|
||||
valueListenable: FinampSettingsHelper.finampSettingsListener,
|
||||
builder: (context, box, child) {
|
||||
bool? shouldTranscode = box.get("FinampSettings")?.shouldTranscode;
|
||||
|
||||
return SwitchListTile.adaptive(
|
||||
title: Text(AppLocalizations.of(context)!.enableTranscoding),
|
||||
subtitle:
|
||||
Text(AppLocalizations.of(context)!.enableTranscodingSubtitle),
|
||||
value: shouldTranscode ?? false,
|
||||
onChanged: shouldTranscode == null
|
||||
? null
|
||||
: (value) {
|
||||
FinampSettings finampSettingsTemp =
|
||||
box.get("FinampSettings")!;
|
||||
finampSettingsTemp.shouldTranscode = value;
|
||||
box.put("FinampSettings", finampSettingsTemp);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../screens/logs_screen.dart';
|
||||
import '../../screens/view_selector.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../error_snackbar.dart';
|
||||
|
||||
class PrivateUserSignIn extends StatefulWidget {
|
||||
const PrivateUserSignIn({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PrivateUserSignIn> createState() => _PrivateUserSignInState();
|
||||
}
|
||||
|
||||
class _PrivateUserSignInState extends State<PrivateUserSignIn> {
|
||||
bool isAuthenticating = false;
|
||||
|
||||
String? baseUrl;
|
||||
String? username;
|
||||
String? password;
|
||||
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This variable is for handling shifting focus when the user presses submit.
|
||||
// https://stackoverflow.com/questions/52150677/how-to-shift-focus-to-next-textfield-in-flutter
|
||||
final node = FocusScope.of(context);
|
||||
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Form(
|
||||
key: formKey,
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.serverUrl,
|
||||
hintText: "http://0.0.0.0:8096",
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
icon: const Icon(Icons.info),
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: Text(AppLocalizations.of(context)!
|
||||
.internalExternalIpExplanation),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context)
|
||||
.okButtonLabel),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () => node.nextFocus(),
|
||||
validator: (value) {
|
||||
if (value?.isEmpty == true) {
|
||||
return AppLocalizations.of(context)!.emptyServerUrl;
|
||||
}
|
||||
if (!value!.trim().startsWith("http://") &&
|
||||
!value.trim().startsWith("https://")) {
|
||||
return AppLocalizations.of(context)!
|
||||
.urlStartWithHttps;
|
||||
}
|
||||
if (value.trim().endsWith("/")) {
|
||||
return AppLocalizations.of(context)!.urlTrailingSlash;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (newValue) => baseUrl = newValue,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
autocorrect: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.username,
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onEditingComplete: () => node.nextFocus(),
|
||||
onSaved: (newValue) => username = newValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextFormField(
|
||||
autocorrect: false,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.password,
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) async => await sendForm(),
|
||||
onSaved: (newValue) => password = newValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pushNamed(LogsScreen.routeName),
|
||||
child:
|
||||
Text(AppLocalizations.of(context)!.logs.toUpperCase()),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
isAuthenticating ? null : () async => await sendForm(),
|
||||
child:
|
||||
Text(AppLocalizations.of(context)!.next.toUpperCase()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Function to handle logging in for Widgets, including a snackbar for errors.
|
||||
Future<void> loginHelper(
|
||||
{required String username,
|
||||
String? password,
|
||||
required String baseUrl,
|
||||
required BuildContext context}) async {
|
||||
JellyfinApiHelper jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
// We trim the base url in case the user accidentally added some trailing whitespce
|
||||
baseUrl = baseUrl.trim();
|
||||
|
||||
jellyfinApiHelper.baseUrlTemp = Uri.parse(baseUrl);
|
||||
|
||||
try {
|
||||
if (password == null) {
|
||||
await jellyfinApiHelper.authenticateViaName(username: username);
|
||||
} else {
|
||||
await jellyfinApiHelper.authenticateViaName(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushNamed(ViewSelector.routeName);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
|
||||
// We return here to stop the function from continuing.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendForm() async {
|
||||
if (formKey.currentState?.validate() == true) {
|
||||
formKey.currentState!.save();
|
||||
setState(() {
|
||||
isAuthenticating = true;
|
||||
});
|
||||
await loginHelper(
|
||||
username: username!,
|
||||
password: password,
|
||||
baseUrl: baseUrl!,
|
||||
context: context,
|
||||
);
|
||||
setState(() {
|
||||
isAuthenticating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class NoMusicLibrariesMessage extends StatelessWidget {
|
||||
const NoMusicLibrariesMessage({
|
||||
super.key,
|
||||
this.onRefresh,
|
||||
});
|
||||
|
||||
final VoidCallback? onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMusicLibrariesTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.noMusicLibrariesBody,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onRefresh,
|
||||
child: Text(AppLocalizations.of(context)!.refresh))
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../services/album_image_provider.dart';
|
||||
|
||||
typedef ImageProviderCallback = void Function(ImageProvider? imageProvider);
|
||||
|
||||
/// This widget provides the default look for album images throughout Finamp -
|
||||
/// Aspect ratio 1 with a circular border radius of 4. If you don't want these
|
||||
/// customisations, use [BareAlbumImage] or get an [ImageProvider] directly
|
||||
/// through [AlbumImageProvider.init].
|
||||
class AlbumImage extends StatelessWidget {
|
||||
const AlbumImage({
|
||||
Key? key,
|
||||
this.item,
|
||||
this.imageProviderCallback,
|
||||
this.itemsToPrecache,
|
||||
}) : super(key: key);
|
||||
|
||||
/// The item to get an image for.
|
||||
final BaseItemDto? item;
|
||||
|
||||
/// A callback to get the image provider once it has been fetched.
|
||||
final ImageProviderCallback? imageProviderCallback;
|
||||
|
||||
/// A list of items to precache
|
||||
final List<BaseItemDto>? itemsToPrecache;
|
||||
|
||||
static final BorderRadius borderRadius = BorderRadius.circular(4);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (item == null || item!.imageId == null) {
|
||||
if (imageProviderCallback != null) {
|
||||
imageProviderCallback!(null);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: const AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _AlbumImageErrorPlaceholder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
// LayoutBuilder (and other pixel-related stuff in Flutter) returns logical pixels instead of physical pixels.
|
||||
// While this is great for doing layout stuff, we want to get images that are the right size in pixels.
|
||||
// Logical pixels aren't the same as the physical pixels on the device, they're quite a bit bigger.
|
||||
// If we use logical pixels for the image request, we'll get a smaller image than we want.
|
||||
// Because of this, we convert the logical pixels to physical pixels by multiplying by the device's DPI.
|
||||
final MediaQueryData mediaQuery = MediaQuery.of(context);
|
||||
final int physicalWidth =
|
||||
(constraints.maxWidth * mediaQuery.devicePixelRatio).toInt();
|
||||
final int physicalHeight =
|
||||
(constraints.maxHeight * mediaQuery.devicePixelRatio).toInt();
|
||||
|
||||
return BareAlbumImage(
|
||||
item: item!,
|
||||
maxWidth: physicalWidth,
|
||||
maxHeight: physicalHeight,
|
||||
imageProviderCallback: imageProviderCallback,
|
||||
itemsToPrecache: itemsToPrecache,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An [AlbumImage] without any of the padding or media size detection.
|
||||
class BareAlbumImage extends StatefulWidget {
|
||||
const BareAlbumImage({
|
||||
Key? key,
|
||||
required this.item,
|
||||
this.maxWidth,
|
||||
this.maxHeight,
|
||||
this.errorBuilder,
|
||||
this.placeholderBuilder,
|
||||
this.imageProviderCallback,
|
||||
this.itemsToPrecache,
|
||||
}) : super(key: key);
|
||||
|
||||
final BaseItemDto item;
|
||||
final int? maxWidth;
|
||||
final int? maxHeight;
|
||||
final WidgetBuilder? placeholderBuilder;
|
||||
final OctoErrorBuilder? errorBuilder;
|
||||
final ImageProviderCallback? imageProviderCallback;
|
||||
|
||||
/// A list of items to precache
|
||||
final List<BaseItemDto>? itemsToPrecache;
|
||||
|
||||
@override
|
||||
State<BareAlbumImage> createState() => _BareAlbumImageState();
|
||||
}
|
||||
|
||||
class _BareAlbumImageState extends State<BareAlbumImage> {
|
||||
late Future<ImageProvider?> _albumImageContentFuture;
|
||||
late WidgetBuilder _placeholderBuilder;
|
||||
late OctoErrorBuilder _errorBuilder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumImageContentFuture = AlbumImageProvider.init(
|
||||
widget.item,
|
||||
maxWidth: widget.maxWidth,
|
||||
maxHeight: widget.maxHeight,
|
||||
itemsToPrecache: widget.itemsToPrecache,
|
||||
context: context,
|
||||
);
|
||||
_placeholderBuilder = widget.placeholderBuilder ??
|
||||
(context) => Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
);
|
||||
_errorBuilder = widget.errorBuilder ??
|
||||
(context, _, __) => const _AlbumImageErrorPlaceholder();
|
||||
}
|
||||
|
||||
// We need to do this so that the image changes when dependencies change, such
|
||||
// as when used in the player screen.
|
||||
@override
|
||||
void didUpdateWidget(BareAlbumImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.item.imageId != oldWidget.item.imageId ||
|
||||
widget.maxWidth != oldWidget.maxWidth ||
|
||||
widget.maxHeight != oldWidget.maxHeight ||
|
||||
widget.itemsToPrecache != oldWidget.itemsToPrecache) {
|
||||
_albumImageContentFuture = AlbumImageProvider.init(
|
||||
widget.item,
|
||||
maxWidth: widget.maxWidth,
|
||||
maxHeight: widget.maxHeight,
|
||||
itemsToPrecache: widget.itemsToPrecache,
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
_placeholderBuilder = widget.placeholderBuilder ??
|
||||
(context) => Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
);
|
||||
_errorBuilder = widget.errorBuilder ??
|
||||
(context, _, __) => const _AlbumImageErrorPlaceholder();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<ImageProvider?>(
|
||||
future: _albumImageContentFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
if (widget.imageProviderCallback != null) {
|
||||
widget.imageProviderCallback!(snapshot.data!);
|
||||
}
|
||||
|
||||
return OctoImage(
|
||||
image: snapshot.data!,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: _placeholderBuilder,
|
||||
errorBuilder: _errorBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
if (widget.imageProviderCallback != null) {
|
||||
widget.imageProviderCallback!(null);
|
||||
}
|
||||
return const _AlbumImageErrorPlaceholder();
|
||||
}
|
||||
|
||||
if (widget.imageProviderCallback != null) {
|
||||
widget.imageProviderCallback!(null);
|
||||
}
|
||||
|
||||
return Builder(builder: _placeholderBuilder);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumImageErrorPlaceholder extends StatelessWidget {
|
||||
const _AlbumImageErrorPlaceholder({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
child: const Icon(Icons.album),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:finamp/services/finamp_settings_helper.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../models/jellyfin_models.dart';
|
||||
import '../../services/jellyfin_api_helper.dart';
|
||||
import '../screens/artist_screen.dart';
|
||||
|
||||
List<TextSpan> ArtistsTextSpans(
|
||||
BaseItemDto item,
|
||||
Color? textColour,
|
||||
BuildContext context,
|
||||
bool popRoutes
|
||||
) {
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
List<TextSpan> separatedArtistTextSpans = [];
|
||||
|
||||
List<NameIdPair>? artists =
|
||||
item.type == "MusicAlbum"
|
||||
? item.albumArtists
|
||||
: item.artistItems;
|
||||
|
||||
if (artists?.isEmpty ?? true) {
|
||||
separatedArtistTextSpans = [
|
||||
TextSpan(
|
||||
text: "Unknown Artist",
|
||||
style: TextStyle(color: textColour),
|
||||
)
|
||||
];
|
||||
} else {
|
||||
artists
|
||||
?.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) =>
|
||||
popRoutes
|
||||
? Navigator.of(context).popAndPushNamed(
|
||||
ArtistScreen.routeName,
|
||||
arguments: artist)
|
||||
: Navigator.of(context).pushNamed(
|
||||
ArtistScreen.routeName,
|
||||
arguments: artist)
|
||||
);
|
||||
}))
|
||||
.forEach((artistTextSpan) {
|
||||
separatedArtistTextSpans.add(artistTextSpan);
|
||||
separatedArtistTextSpans.add(TextSpan(
|
||||
text: ", ",
|
||||
style: TextStyle(color: textColour),
|
||||
));
|
||||
});
|
||||
separatedArtistTextSpans.removeLast();
|
||||
}
|
||||
return separatedArtistTextSpans;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BlurredImage extends StatelessWidget {
|
||||
const BlurredImage(this.image, {Key? key}) : super(key: key);
|
||||
|
||||
final ImageProvider image;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: image,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../services/downloads_helper.dart';
|
||||
import 'error_snackbar.dart';
|
||||
|
||||
class ConfirmationPromptDialog extends AlertDialog {
|
||||
const ConfirmationPromptDialog({
|
||||
Key? key,
|
||||
required this.promptText,
|
||||
required this.confirmButtonText,
|
||||
required this.abortButtonText,
|
||||
required this.onConfirmed,
|
||||
required this.onAborted,
|
||||
}) : super(key: key);
|
||||
|
||||
final String promptText;
|
||||
final String confirmButtonText;
|
||||
final String abortButtonText;
|
||||
final void Function()? onConfirmed;
|
||||
final void Function()? onAborted;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
buttonPadding: const EdgeInsets.all(0.0),
|
||||
contentPadding: const EdgeInsets.all(0.0),
|
||||
insetPadding: const EdgeInsets.all(32.0),
|
||||
actionsPadding: const EdgeInsets.all(0.0),
|
||||
actionsAlignment: MainAxisAlignment.spaceAround,
|
||||
actionsOverflowAlignment: OverflowBarAlignment.center,
|
||||
actionsOverflowDirection: VerticalDirection.up,
|
||||
title: Text(
|
||||
promptText,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
actions: [
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150.0,
|
||||
),
|
||||
child: TextButton(
|
||||
child: Text(abortButtonText,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onAborted?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150.0,
|
||||
),
|
||||
child: TextButton(
|
||||
child: Text(confirmButtonText,
|
||||
textAlign: TextAlign.center,
|
||||
softWrap: true,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // Close the dialog
|
||||
onConfirmed?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:chopper/chopper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
/// Snackbar with error icon for displaying errors
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> errorSnackbar(
|
||||
dynamic error, BuildContext context) {
|
||||
return ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.anErrorHasOccured),
|
||||
action: SnackBarAction(
|
||||
label: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.error),
|
||||
content: Text(_errorText(error, context)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _errorText(dynamic error, BuildContext context) {
|
||||
if (error.runtimeType == Response) {
|
||||
error = error as Response;
|
||||
|
||||
if (error.statusCode == 401) {
|
||||
return AppLocalizations.of(context)!
|
||||
.responseError401(error.error.toString(), error.statusCode);
|
||||
} else {
|
||||
return AppLocalizations.of(context)!
|
||||
.responseError(error.error.toString(), error.statusCode);
|
||||
}
|
||||
} else {
|
||||
return error.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:finamp/components/error_snackbar.dart';
|
||||
import 'package:finamp/models/jellyfin_models.dart';
|
||||
import 'package:finamp/services/jellyfin_api_helper.dart';
|
||||
import 'package:finamp/services/music_player_background_task.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
class FavoriteButton extends StatefulWidget {
|
||||
const FavoriteButton(
|
||||
{Key? key,
|
||||
required this.item,
|
||||
this.onlyIfFav = false,
|
||||
this.inPlayer = false})
|
||||
: super(key: key);
|
||||
|
||||
final BaseItemDto? item;
|
||||
final bool onlyIfFav;
|
||||
final bool inPlayer;
|
||||
|
||||
@override
|
||||
State<FavoriteButton> createState() => _FavoriteButtonState();
|
||||
}
|
||||
|
||||
class _FavoriteButtonState extends State<FavoriteButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
final jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
if (widget.item == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
bool isFav = widget.item!.userData!.isFavorite;
|
||||
if (widget.onlyIfFav) {
|
||||
if (isFav) {
|
||||
return Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.red,
|
||||
size: 24.0,
|
||||
semanticLabel: AppLocalizations.of(context)!.favourite,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
} else {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
isFav ? Icons.favorite : Icons.favorite_outline,
|
||||
color: isFav ? Colors.red : null,
|
||||
size: 24.0,
|
||||
),
|
||||
tooltip: AppLocalizations.of(context)!.favourite,
|
||||
onPressed: () async {
|
||||
try {
|
||||
UserItemDataDto? newUserData;
|
||||
if (isFav) {
|
||||
newUserData =
|
||||
await jellyfinApiHelper.removeFavourite(widget.item!.id);
|
||||
} else {
|
||||
newUserData =
|
||||
await jellyfinApiHelper.addFavourite(widget.item!.id);
|
||||
}
|
||||
setState(() {
|
||||
widget.item!.userData = newUserData;
|
||||
if (widget.inPlayer) {
|
||||
audioHandler.mediaItem.valueOrNull!.extras!['itemJson'] =
|
||||
widget.item!.toJson();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/983763669fe7247682cd64f0f8f5d6c6f96d75d3/lib/src/ui/default_indicators/first_page_progress_indicator.dart
|
||||
// Made CircularProgressIndicator adaptive
|
||||
|
||||
class FirstPageProgressIndicator extends StatelessWidget {
|
||||
const FirstPageProgressIndicator({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/footer_tile.dart';
|
||||
|
||||
// Taken from https://github.com/EdsonBueno/infinite_scroll_pagination/blob/e1a826d5a06d8cfb6c2146658d59465ced4140cd/lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart
|
||||
// Made CircularProgressIndicator adaptive
|
||||
|
||||
class NewPageProgressIndicator extends StatelessWidget {
|
||||
const NewPageProgressIndicator({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const FooterTile(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:simple_gesture_detector/simple_gesture_detector.dart';
|
||||
|
||||
import '../services/finamp_settings_helper.dart';
|
||||
import '../services/media_state_stream.dart';
|
||||
import 'album_image.dart';
|
||||
import '../models/jellyfin_models.dart';
|
||||
import '../services/process_artist.dart';
|
||||
import '../services/music_player_background_task.dart';
|
||||
import '../screens/player_screen.dart';
|
||||
import 'PlayerScreen/progress_slider.dart';
|
||||
|
||||
class NowPlayingBar extends StatelessWidget {
|
||||
const NowPlayingBar({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html)
|
||||
const elevation = 8.0;
|
||||
final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor;
|
||||
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
|
||||
return SimpleGestureDetector(
|
||||
onVerticalSwipe: (direction) {
|
||||
if (direction == SwipeDirection.up) {
|
||||
Navigator.of(context).pushNamed(PlayerScreen.routeName);
|
||||
}
|
||||
},
|
||||
child: StreamBuilder<MediaState>(
|
||||
stream: mediaStateStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final playing = snapshot.data!.playbackState.playing;
|
||||
|
||||
// If we have a media item and the player hasn't finished, show
|
||||
// the now playing bar.
|
||||
if (snapshot.data!.mediaItem != null) {
|
||||
final item = BaseItemDto.fromJson(
|
||||
snapshot.data!.mediaItem!.extras!["itemJson"]);
|
||||
|
||||
return Material(
|
||||
color: color,
|
||||
elevation: elevation,
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
const ProgressSlider(
|
||||
allowSeeking: false,
|
||||
showBuffer: false,
|
||||
showDuration: false,
|
||||
showPlaceholder: false,
|
||||
),
|
||||
Dismissible(
|
||||
key: const Key("NowPlayingBar"),
|
||||
direction: FinampSettingsHelper.finampSettings.disableGesture ? DismissDirection.none : DismissDirection.horizontal,
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.endToStart) {
|
||||
audioHandler.skipToNext();
|
||||
} else {
|
||||
audioHandler.skipToPrevious();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
background: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.fitHeight,
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Icon(Icons.skip_previous),
|
||||
),
|
||||
),
|
||||
),
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.fitHeight,
|
||||
child: Padding(
|
||||
padding:
|
||||
EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Icon(Icons.skip_next),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => Navigator.of(context)
|
||||
.pushNamed(PlayerScreen.routeName),
|
||||
leading: AlbumImage(item: item),
|
||||
title: Text(
|
||||
snapshot.data!.mediaItem!.title,
|
||||
softWrap: false,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
subtitle: Text(
|
||||
processArtist(
|
||||
snapshot.data!.mediaItem!.artist, context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (snapshot
|
||||
.data!.playbackState.processingState !=
|
||||
AudioProcessingState.idle)
|
||||
IconButton(
|
||||
// We have a key here because otherwise the
|
||||
// InkWell moves over to the play/pause button
|
||||
key: const ValueKey("StopButton"),
|
||||
icon: const Icon(Icons.stop),
|
||||
onPressed: () => audioHandler.stop(),
|
||||
),
|
||||
playing
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.pause),
|
||||
onPressed: () => audioHandler.pause(),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
onPressed: () => audioHandler.play(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/// Flutter doesn't have a nice way of formatting durations for some reason so I stole this code from StackOverflow
|
||||
String printDuration(Duration? duration) {
|
||||
if (duration == null) {
|
||||
return "00:00";
|
||||
}
|
||||
|
||||
String twoDigits(int n) => n.toString().padLeft(2, "0");
|
||||
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
|
||||
if (duration.inHours >= 1) {
|
||||
String twoDigitHours = twoDigits(duration.inHours);
|
||||
return "$twoDigitHours:$twoDigitMinutes:$twoDigitSeconds";
|
||||
}
|
||||
|
||||
return "$twoDigitMinutes:$twoDigitSeconds";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ViewIcon extends StatelessWidget {
|
||||
const ViewIcon({
|
||||
Key? key,
|
||||
required this.collectionType,
|
||||
this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
final String? collectionType;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (collectionType) {
|
||||
case "movies":
|
||||
return Icon(
|
||||
Icons.movie,
|
||||
color: color,
|
||||
);
|
||||
case "tvshows":
|
||||
return Icon(
|
||||
Icons.tv,
|
||||
color: color,
|
||||
);
|
||||
case "music":
|
||||
return Icon(
|
||||
Icons.music_note,
|
||||
color: color,
|
||||
);
|
||||
case "games":
|
||||
return Icon(
|
||||
Icons.games,
|
||||
color: color,
|
||||
);
|
||||
case "books":
|
||||
return Icon(
|
||||
Icons.book,
|
||||
color: color,
|
||||
);
|
||||
case "musicvideos":
|
||||
return Icon(
|
||||
Icons.music_video,
|
||||
color: color,
|
||||
);
|
||||
case "homevideos":
|
||||
return Icon(
|
||||
Icons.videocam,
|
||||
color: color,
|
||||
);
|
||||
case "livetv":
|
||||
return Icon(
|
||||
Icons.live_tv,
|
||||
color: color,
|
||||
);
|
||||
case "channels":
|
||||
return Icon(
|
||||
Icons.settings_remote,
|
||||
color: color,
|
||||
);
|
||||
case "playlists":
|
||||
return Icon(
|
||||
Icons.queue_music,
|
||||
color: color,
|
||||
);
|
||||
default:
|
||||
return Icon(
|
||||
Icons.warning,
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
{
|
||||
"serverUrl": "عنوان الخادم",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "لا يمكن عنوان الخادم يكون فارغ",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "العنوان يجب ان يبدأ مع \"//:http\" أو \"//:https\"",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"logs": "السجلات",
|
||||
"@logs": {},
|
||||
"username": "اسم المستعمل",
|
||||
"@username": {},
|
||||
"unknownName": "إسم غير معروف",
|
||||
"@unknownName": {},
|
||||
"couldNotFindLibraries": "لا يوجد أي مكتب.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"selectMusicLibraries": "إختار مكاتب الأغاني",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"songs": "الأغاني",
|
||||
"@songs": {},
|
||||
"next": "التالي",
|
||||
"@next": {},
|
||||
"albums": "ألبومات",
|
||||
"@albums": {},
|
||||
"artists": "الفنانين",
|
||||
"@artists": {},
|
||||
"genres": "التصنيفات",
|
||||
"@genres": {},
|
||||
"playlists": "قوائم الأغاني",
|
||||
"@playlists": {},
|
||||
"startMix": "خلط فوري",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsArtist": "إكبس طويل على البوم لتضيف أو تزيل هم من بناء الخلطة قبل ان تبدأ الخلطة",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"favourites": "مفضلات",
|
||||
"@favourites": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"clear": "إمسح",
|
||||
"@clear": {},
|
||||
"shuffleAll": "استماع عشوائي للكل",
|
||||
"@shuffleAll": {},
|
||||
"startMixNoSongsAlbum": "إكبس طويل على البوم لتضيف أو تزيل ها من بناء الخلطة قبل ان تبدأ الخلطة",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"music": "موسيقى",
|
||||
"@music": {},
|
||||
"downloads": "التنزيلات",
|
||||
"@downloads": {},
|
||||
"settings": "الإعدادات",
|
||||
"@settings": {},
|
||||
"album": "ألبوم",
|
||||
"@album": {},
|
||||
"sortBy": "فرز",
|
||||
"@sortBy": {},
|
||||
"sortOrder": "ترتيب الفرز",
|
||||
"@sortOrder": {},
|
||||
"offlineMode": "موضع غير الإتصال",
|
||||
"@offlineMode": {},
|
||||
"albumArtist": "فنان الألبوم",
|
||||
"@albumArtist": {},
|
||||
"artist": "الفنان",
|
||||
"@artist": {},
|
||||
"communityRating": "تقييم الجمهور",
|
||||
"@communityRating": {},
|
||||
"criticRating": "تقييم النقاد",
|
||||
"@criticRating": {},
|
||||
"datePlayed": "تاريخ استماع",
|
||||
"@datePlayed": {},
|
||||
"playCount": "عدد الاستمعات",
|
||||
"@playCount": {},
|
||||
"premiereDate": "تاريخ عرض الأول",
|
||||
"@premiereDate": {},
|
||||
"budget": "ميزنية",
|
||||
"@budget": {},
|
||||
"random": "عشوائي",
|
||||
"@random": {},
|
||||
"name": "إسم",
|
||||
"@name": {},
|
||||
"downloadMissingImages": "نزل صور المفقودة",
|
||||
"@downloadMissingImages": {},
|
||||
"revenue": "إيرادات",
|
||||
"@revenue": {},
|
||||
"runtime": "وقت تشغيل",
|
||||
"@runtime": {},
|
||||
"downloadCount": "{count,plural, =1{{count} تنزيل} other{{count} تنزيلات}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "اخطأ التنزيل",
|
||||
"@downloadErrors": {},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} صورة} other{{count} صور}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} بند} other{{count} بنود}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} متكمل",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} فشل",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} ينتضر النزيل",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "اخطأ التنزيل",
|
||||
"@downloadErrorsTitle": {},
|
||||
"dlRunning": "{count} يتم تنزيل الآن",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noErrors": "لا يوجد اخطأ!",
|
||||
"@noErrors": {},
|
||||
"error": "خطأ",
|
||||
"@error": {},
|
||||
"discNumber": "القرس {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "استمع",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "استمع عشوائياً",
|
||||
"@shuffleButtonLabel": {},
|
||||
"songCount": "{count,plural,=1{{count} أغتية} other{{count} أغاني}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateButtonLabel": "تحديث",
|
||||
"@updateButtonLabel": {},
|
||||
"editPlaylistNameTooltip": "غير اسم قائمة الأغاني",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"favourite": "المفضل",
|
||||
"@favourite": {},
|
||||
"playlistNameUpdated": "تم تحديث إسم قائمة الأغاني.",
|
||||
"@playlistNameUpdated": {},
|
||||
"required": "ملزوم",
|
||||
"@required": {},
|
||||
"addButtonLabel": "إضافة",
|
||||
"@addButtonLabel": {},
|
||||
"shareLogs": "شارك السجلات",
|
||||
"@shareLogs": {},
|
||||
"addDownloads": "إضافة تنزيلات",
|
||||
"@addDownloads": {},
|
||||
"downloadsAdded": "تم اضافة التنزيلات",
|
||||
"@downloadsAdded": {},
|
||||
"location": "موقع",
|
||||
"@location": {},
|
||||
"logsCopied": "تم نسخ السجلات.",
|
||||
"@logsCopied": {},
|
||||
"transcoding": "تحويل",
|
||||
"@transcoding": {},
|
||||
"message": "رسالة",
|
||||
"@message": {},
|
||||
"stackTrace": "إشارة تراكمية",
|
||||
"@stackTrace": {},
|
||||
"areYouSure": "هل انت متأكد؟",
|
||||
"@areYouSure": {},
|
||||
"audioService": "خدمة الصوت",
|
||||
"@audioService": {},
|
||||
"enableTranscoding": "تمكين التحويل",
|
||||
"@enableTranscoding": {},
|
||||
"notAvailableInOfflineMode": "عير متوفر في وضع غير الإتصال",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"jellyfinUsesAACForTranscoding": "\"جلي فين\" يستعمل مشفر <AAC> للتحويل",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"layoutAndTheme": "تخطيط و ظاهرة",
|
||||
"@layoutAndTheme": {},
|
||||
"logOut": "تسجيل الخروج",
|
||||
"@logOut": {},
|
||||
"bitrate": "معدل البتات",
|
||||
"@bitrate": {},
|
||||
"customLocation": "موقع مخصص",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "مجلّد التطبيق",
|
||||
"@appDirectory": {},
|
||||
"unknownError": "خطأ غير معروف",
|
||||
"@unknownError": {},
|
||||
"directoryMustBeEmpty": "المجلد يجب ان يكون فارغ",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enterLowPriorityStateOnPause": "تشغيل وضع <low-priority state> عندما توقف استماع مؤقتاً",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"shuffleAllSongCountSubtitle": "عدد الأغاني التي تحمل عندما تستعمل كبسة \"استماع عشوائ لكل الأغاني\".",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"shuffleAllSongCount": "استماع عشوائي لعدد الأغاني",
|
||||
"@shuffleAllSongCount": {},
|
||||
"grid": "شبكة",
|
||||
"@grid": {},
|
||||
"portrait": "عمودي",
|
||||
"@portrait": {},
|
||||
"landscape": "اقفي",
|
||||
"@landscape": {},
|
||||
"list": "قائمة",
|
||||
"@list": {},
|
||||
"viewType": "نوع العرض",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "نوع عرض لصفة الموسيقى",
|
||||
"@viewTypeSubtitle": {},
|
||||
"showTextOnGridView": "أظهر كلمات على عرض الشبك",
|
||||
"@showTextOnGridView": {},
|
||||
"gridCrossAxisCount": "عدد محور معامد في عرض {value}",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridViewSubtitle": "سواء ان تظهر كلمات [الإسم، الفنان، أو الباقي] على صفحة الموسيقى التي تستعمل عرض الشبكي.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "سواء ان تستعمل مخلاف البوم غائم على مشغل الموسيقى.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "إخفاء أسماء فنانين اﻷغنية إذا هن نفس أسماء فنانين الأابوم",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"theme": "الألوان التطبيق",
|
||||
"@theme": {},
|
||||
"light": "ابيض",
|
||||
"@light": {},
|
||||
"cancelSleepTimer": "هل تريد ان تلغي مؤقت النوم؟",
|
||||
"@cancelSleepTimer": {},
|
||||
"dark": "غامق",
|
||||
"@dark": {},
|
||||
"tabs": "التبويبات",
|
||||
"@tabs": {},
|
||||
"noButtonLabel": "ﻷ",
|
||||
"@noButtonLabel": {},
|
||||
"yesButtonLabel": "نعم",
|
||||
"@yesButtonLabel": {},
|
||||
"addToPlaylistTooltip": "إضافة إلى قائمة اغاني",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "إضافة إلى قائمة اغاني",
|
||||
"@addToPlaylistTitle": {},
|
||||
"createButtonLabel": "إنشاء",
|
||||
"@createButtonLabel": {},
|
||||
"setSleepTimer": "حدد مؤقت النوم",
|
||||
"@setSleepTimer": {},
|
||||
"sleepTimerTooltip": "مؤقت النوم",
|
||||
"@sleepTimerTooltip": {},
|
||||
"newPlaylist": "قائمة اغاني جديد",
|
||||
"@newPlaylist": {},
|
||||
"invalidNumber": "رقم خاطىء",
|
||||
"@invalidNumber": {},
|
||||
"unknownArtist": "فنان غير معروف",
|
||||
"@unknownArtist": {},
|
||||
"playlistCreated": "تم إنشاء قائمة الأغاني.",
|
||||
"@playlistCreated": {},
|
||||
"streaming": "بث",
|
||||
"@streaming": {},
|
||||
"noAlbum": "بدون البوم",
|
||||
"@noAlbum": {},
|
||||
"noItem": "لا يوجد بند",
|
||||
"@noItem": {},
|
||||
"transcode": "تحويل",
|
||||
"@transcode": {},
|
||||
"goToAlbum": "إذهب إلى الألبوم",
|
||||
"@goToAlbum": {},
|
||||
"addToQueue": "إضافة إلى قائمة الانتظار",
|
||||
"@addToQueue": {},
|
||||
"downloaded": "نزلت",
|
||||
"@downloaded": {},
|
||||
"queue": "قائمة الإنتظار",
|
||||
"@queue": {},
|
||||
"direct": "مباشر",
|
||||
"@direct": {},
|
||||
"instantMix": "خلط فوري",
|
||||
"@instantMix": {},
|
||||
"statusError": "خطأ حالة",
|
||||
"@statusError": {},
|
||||
"addFavourite": "إضافة إلى المفضلات",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "تم إضافة إالى قائمة الانتظار",
|
||||
"@addedToQueue": {},
|
||||
"queueReplaced": "تم تبديل قائمة الإنتظار.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "ابتداء خلط فوري.",
|
||||
"@startingInstantMix": {},
|
||||
"addDownloadLocation": "إضافة مكان التنزيل",
|
||||
"@addDownloadLocation": {},
|
||||
"errorScreenError": "حدث خطأ اثناء حصول على قائمة الأخطأ! في هذه المرحلة ربما يجب عليك ان تفتح تعليقة على <GitHub> و تمسح تخزين التطبيق",
|
||||
"@errorScreenError": {},
|
||||
"downloadLocations": "مواقع التنزيل",
|
||||
"@downloadLocations": {},
|
||||
"editPlaylistNameTitle": "غير اسم قائمة الأغاني",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"enableTranscodingSubtitle": "إذا ممكن, بث الموسيقى سايتحول من الخادم",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"responseError": "{error} رمز الخطأ {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError401": "{error} رمز الخطأ {statusCode}. ربما يعني ان استخدمت الإسم أو الكامة السرية الخاطئة, أو عميلك لا يعد موثوق.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bitrateSubtitle": "معدل بتات اعلى يعطي جودة صوت أحسن واكن يستعمل اكثر بيانات.",
|
||||
"@bitrateSubtitle": {},
|
||||
"dateAdded": "تاريخ الإضافة",
|
||||
"@dateAdded": {},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"productionYear": "سنة الإنتاج",
|
||||
"@productionYear": {},
|
||||
"removeFavourite": "إزالة من المفضلات",
|
||||
"@removeFavourite": {},
|
||||
"replaceQueue": "تبديل قائمة الإنتظار",
|
||||
"@replaceQueue": {},
|
||||
"system": "نظام",
|
||||
"@system": {},
|
||||
"startupError": "‐شيء خطأ حصل اثناء تفتيح التطبيك! الخطأ هو:{error}\n\nاقتح تعليقةعلى <GitHub> في الموقع <github.com/UnicornsOnLSD/finamp> مع لقتة شاشة من هذا الصفحة. إذا هذا الخطأ يستمر, إمسح تخزين التطبيق.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noArtist": "بدون فنان",
|
||||
"@noArtist": {},
|
||||
"password": "كلمة السرية",
|
||||
"@password": {},
|
||||
"selectDirectory": "إختار المجلد",
|
||||
"@selectDirectory": {},
|
||||
"downloadedSongsWillNotBeDeleted": "لن تزيل لأغني التي تم تنزيله",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"downloadsDeleted": "تم إزالة التنزيلات",
|
||||
"@downloadsDeleted": {},
|
||||
"failedToGetSongFromDownloadId": "فشل ان يجد الاغنية من رقم التنزيل",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"internalExternalIpExplanation": "إذا تريد ان توصل خدامة \"جلي فين\" من بعيد, يجيب ان تستعمل آي بي(IP) الخارجي.\n\nإذا خادمك يستعمل منافذ الويب(80 أو 443), لا يجب ان تحدد منفذ. هذه محتمل جدا إذا خادمك بستعمل\"Reverse proxy\".",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"pathReturnSlashErrorMessage": "مسارات التي ترجع \"/\" لا يمكن ان تستعمل",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"showCoverAsPlayerBackground": "أظهر مخلاف البوم غائم على مشغل الموسيقى",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"urlTrailingSlash": "العنوان لا يمكن ان ينهي مع \"/\"",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "إذا ممكن، يمكن ان تمسح الإعلام عنمدا توقف مؤقتاً للاستماع. و يمكن \"اندرويد\" ان يقتل الخدمة.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "سواء ان تخفي فنانين الأغنية من صفحة الألبوم إذا يختلف من فنانين الألبوم.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"applicationLegalese": "مرخص مع رخصة موزيلا العمومية <Mozilla Public License 2.0>. الشريفة توجد على:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"gridCrossAxisCountSubtitle": "كم مربعات تستعمل لكل صف عندما الشاشة تكون {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedMissingImages": "{count,plural, =0{لا يوجد صور مفقودة} =1{تم تنزيل {count} صورة مفقودة} other{ تم تنزيل {count} صور المفقودة}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"anErrorHasOccured": "حدث خطأ.",
|
||||
"@anErrorHasOccured": {},
|
||||
"customLocationsBuggy": "المواقع المخصصة عرضة للأخطأ لأن يوجد مشاكل مع الأذونات. أنا أحوال إصلاحه, و لكن لا أنصح ان تستغدمه.",
|
||||
"@customLocationsBuggy": {},
|
||||
"addToMix": "إضافة إالى الخلطة",
|
||||
"@addToMix": {},
|
||||
"removeFromMix": "إزالة من الخلطة",
|
||||
"@removeFromMix": {},
|
||||
"minutes": "دقائق",
|
||||
"@minutes": {},
|
||||
"removeFromPlaylistTooltip": "إزالة من قائمة الأغاني",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "إزالة من قائمة الأغاني",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"removedFromPlaylist": "تمت الإزالة من قائمة الأغاني.",
|
||||
"@removedFromPlaylist": {},
|
||||
"language": "اللغة",
|
||||
"@language": {}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
{
|
||||
"serverUrl": "URL на сървъра",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "URL адресът на сървъра не може да бъде празен",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL адресът трябва да започва с http:// или https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL адресът не трябва да включва наклонена черта",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Потребителско име",
|
||||
"@username": {},
|
||||
"password": "Парола",
|
||||
"@password": {},
|
||||
"logs": "Логове",
|
||||
"@logs": {},
|
||||
"next": "Следващ",
|
||||
"@next": {},
|
||||
"couldNotFindLibraries": "Не могат да бъдат открити библиотеки.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"unknownName": "Неизвестно Име",
|
||||
"@unknownName": {},
|
||||
"songs": "Песни",
|
||||
"@songs": {},
|
||||
"albums": "Албуми",
|
||||
"@albums": {},
|
||||
"artists": "Изпълнители",
|
||||
"@artists": {},
|
||||
"genres": "Жанрове",
|
||||
"@genres": {},
|
||||
"playlists": "Плейлисти",
|
||||
"@playlists": {},
|
||||
"startMix": "Стартирайте разбъркано възпроизвеждане",
|
||||
"@startMix": {},
|
||||
"music": "Музика",
|
||||
"@music": {},
|
||||
"clear": "Изчисти",
|
||||
"@clear": {},
|
||||
"favourites": "Любими",
|
||||
"@favourites": {},
|
||||
"shuffleAll": "Възпроизвеждане в случаен ред",
|
||||
"@shuffleAll": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"downloads": "Изтегляния",
|
||||
"@downloads": {},
|
||||
"settings": "Настройки",
|
||||
"@settings": {},
|
||||
"offlineMode": "Офлайн режим",
|
||||
"@offlineMode": {},
|
||||
"sortOrder": "Подредба",
|
||||
"@sortOrder": {},
|
||||
"sortBy": "Подредба по",
|
||||
"@sortBy": {},
|
||||
"album": "Албум",
|
||||
"@album": {},
|
||||
"artist": "Изпълнител",
|
||||
"@artist": {},
|
||||
"albumArtist": "Изпълнител на албума",
|
||||
"@albumArtist": {},
|
||||
"budget": "Бюджет",
|
||||
"@budget": {},
|
||||
"communityRating": "Обществен рейтинг",
|
||||
"@communityRating": {},
|
||||
"criticRating": "Рейтинг на професионалната общност",
|
||||
"@criticRating": {},
|
||||
"dateAdded": "Дата на добавяне",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Дата на последно изпълнение",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Брой изпълнения",
|
||||
"@playCount": {},
|
||||
"premiereDate": "Премиерна дата",
|
||||
"@premiereDate": {},
|
||||
"name": "Име",
|
||||
"@name": {},
|
||||
"random": "Произволно",
|
||||
"@random": {},
|
||||
"revenue": "Приходи",
|
||||
"@revenue": {},
|
||||
"runtime": "Продължителност",
|
||||
"@runtime": {},
|
||||
"downloadErrors": "Грешки при изтеглянето",
|
||||
"@downloadErrors": {},
|
||||
"dlComplete": "{count} завършено",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} грешка",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} опашка",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} изпълнява се",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Грешки при изтегляне",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "Без грешки!",
|
||||
"@noErrors": {},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} изображение} other{{count} изображения}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failedToGetSongFromDownloadId": "Неуспех при получаване на песен, посредством ID на изтегляне",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"error": "Грешка",
|
||||
"@error": {},
|
||||
"discNumber": "Диск {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "ПУСНИ",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "РАЗБЪРКАНО",
|
||||
"@shuffleButtonLabel": {},
|
||||
"editPlaylistNameTooltip": "Редактирай името на списъка за изпълнение",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"required": "Задължително",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "АКТУАЛИЗАЦИЯ",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Името на списъка за изпълнение е обновено.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Любими",
|
||||
"@favourite": {},
|
||||
"addDownloads": "Добави за изтегляне",
|
||||
"@addDownloads": {},
|
||||
"location": "Местоположение",
|
||||
"@location": {},
|
||||
"shareLogs": "Споделете логовете",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Логове копирани.",
|
||||
"@logsCopied": {},
|
||||
"message": "Съобщение",
|
||||
"@message": {},
|
||||
"stackTrace": "Проследяване на стека",
|
||||
"@stackTrace": {},
|
||||
"transcoding": "Транскодиране",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Местоположение на изтеглянията",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Аудио услуга",
|
||||
"@audioService": {},
|
||||
"layoutAndTheme": "Оформление и тема",
|
||||
"@layoutAndTheme": {},
|
||||
"notAvailableInOfflineMode": "Недостъпно в офлайн режим",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Излезте от профила си",
|
||||
"@logOut": {},
|
||||
"areYouSure": "Сигурни ли сте?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin използва AAC за транскодиране",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Активиране на транскодирането",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Транскодирането на музикалните потоци се осъществява от страна на сървъра.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"bitrate": "Битрейт",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "По-високият битрейт гарантира висококачествено аудио за сметка на завишен мрежови трафик.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Персонализирано местоположение",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "Директория на приложението",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Добавете местоположение за изтегляне",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Изберете директория",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Неизвестна грешка",
|
||||
"@unknownError": {},
|
||||
"pathReturnSlashErrorMessage": "Невъзможно е указването на път, използващ \"/\"",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"directoryMustBeEmpty": "Директорията трябва да е празна",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enterLowPriorityStateOnPause": "При пауза, използвай режим на нисък приоритет",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"shuffleAllSongCount": "Разбъркайте всички песни",
|
||||
"@shuffleAllSongCount": {},
|
||||
"shuffleAllSongCountSubtitle": "Брой песни за зареждане при използване на бутона за разбъркване на всички песни.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewType": "Вид преглед",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Вид преглед на плейъра",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "Списък",
|
||||
"@list": {},
|
||||
"grid": "Решетка",
|
||||
"@grid": {},
|
||||
"portrait": "Портретен",
|
||||
"@portrait": {},
|
||||
"landscape": "Пейзажен",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCountSubtitle": "Броят на показваните елементи на ред, когато {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Показване на текст в решетъчен изглед",
|
||||
"@showTextOnGridView": {},
|
||||
"showCoverAsPlayerBackground": "Показване на замъглена обложка като фон на плейъра",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Скриване изпълнителя на песента, ако е същият като изпълнителя на албума",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Дали да се покаже изпълнителят на песента на екрана, ако не се различава от изпълнителя на албума.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"theme": "Тема",
|
||||
"@theme": {},
|
||||
"system": "Система",
|
||||
"@system": {},
|
||||
"light": "Светла",
|
||||
"@light": {},
|
||||
"dark": "Тъмна",
|
||||
"@dark": {},
|
||||
"tabs": "Раздели",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Отмяна на таймера?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "Да",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "Не",
|
||||
"@noButtonLabel": {},
|
||||
"setSleepTimer": "Задаване на таймер",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Минути",
|
||||
"@minutes": {},
|
||||
"invalidNumber": "Невалидно число",
|
||||
"@invalidNumber": {},
|
||||
"sleepTimerTooltip": "Таймер",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Добавяне към списъка за изпълнение",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Добавяне към списъка за изпълнение",
|
||||
"@addToPlaylistTitle": {},
|
||||
"newPlaylist": "Нов списък за изпълнение",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "Създаване",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Списъкът за изпълнение е създаден.",
|
||||
"@playlistCreated": {},
|
||||
"noItem": "Липсващ файл",
|
||||
"@noItem": {},
|
||||
"noAlbum": "Липсващ албум",
|
||||
"@noAlbum": {},
|
||||
"unknownArtist": "Неизвестен изпълнител",
|
||||
"@unknownArtist": {},
|
||||
"streaming": "Стрийминг",
|
||||
"@streaming": {},
|
||||
"downloaded": "Изтегляне",
|
||||
"@downloaded": {},
|
||||
"transcode": "Транскодиране",
|
||||
"@transcode": {},
|
||||
"statusError": "Грешка в състоянието",
|
||||
"@statusError": {},
|
||||
"queue": "Опашка",
|
||||
"@queue": {},
|
||||
"addToQueue": "Добавяне към опашката",
|
||||
"@addToQueue": {},
|
||||
"replaceQueue": "Заменете опашката",
|
||||
"@replaceQueue": {},
|
||||
"goToAlbum": "Отидете в албума",
|
||||
"@goToAlbum": {},
|
||||
"removeFavourite": "Премахнете любими",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Добавете в любими",
|
||||
"@addFavourite": {},
|
||||
"queueReplaced": "Опашката е заменена.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "Стартиране на незабавен микс.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "Появи се грешка.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Код на грешката {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Премахни от микса",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Добави към микса",
|
||||
"@addToMix": {},
|
||||
"startupError": "Нещо се обърка по време на стартиране на приложението. Грешката беше:{error}\n\nДокладвайте проблема, с екранна снимка, на github.com/UnicornsOnLSD/finamp, Ако проблемът продължава, изчистете кеша или преинсталирайте приложението.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internalExternalIpExplanation": "В случай, че искате да имате достъп до вашия Jellyfin сървър дистанционно, трябва да използвате външния си IP адрес.\n\nВ случай, че сървърът ви работи на HTTP порт (80/443), не е нужно да посочвате такъв. Например ако използвате обратно прокси.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"selectMusicLibraries": "Изберете музикални библиотеки",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"startMixNoSongsArtist": "Натиснете и задръжте върху изпълнител, за да го добавите или премахнете, от разбъркано възпроизвеждане",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Натиснете и задръжте върху албум, за да го добавите или премахнете от разбъркано възпроизвеждане",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"productionYear": "Година на издаване",
|
||||
"@productionYear": {},
|
||||
"downloadMissingImages": "Изтегляне на липсващите изображения",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Не са открити липсващи изображения} =1{Изтегляне {count} липсващи изображения} other{Изтегляне {count} липсващи изображения}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadCount": "{count,plural, =1{{count} изтегляне} other{{count} изтегляне}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorScreenError": "Възникна грешка при получаване на списъка с грешки! Преинсталирайте приложението и съобщете за проблема на GitHub",
|
||||
"@errorScreenError": {},
|
||||
"editPlaylistNameTitle": "Редактирай името на списъка за изпълнение",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"downloadsDeleted": "Изтеглянията са изтрити.",
|
||||
"@downloadsDeleted": {},
|
||||
"downloadsAdded": "Добавено в изтегляния.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "ДОБАВИ",
|
||||
"@addButtonLabel": {},
|
||||
"applicationLegalese": "Лиценз Mozilla Public License 2.0. Програмният код е достъпен на:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Изтеглените песни няма да бъдат изтрити",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"songCount": "{count,plural,=1{{count} Песен} other{{count} Песни}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Известията да бъдат отхвърлени при пауза. Също така позволи на Android да прекрати услугата при пауза.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"customLocationsBuggy": "Персонализираните местоположения са изключително нестабилни, поради проблеми с правата. Обмислям варианти са справяне с проблема, но за сега непрепоръчвам тяхното използване.",
|
||||
"@customLocationsBuggy": {},
|
||||
"gridCrossAxisCount": "{value} Брой елементи в решетката",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridViewSubtitle": "Дали да се показва или не текст (името на песента, изпълнителя и т.н.), в решетъчен режим.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Дали да се показва или не, замъглена обложка на албума като фон на плейъра.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"noArtist": "Липсващ изпълнител",
|
||||
"@noArtist": {},
|
||||
"direct": "Непосредствен",
|
||||
"@direct": {},
|
||||
"instantMix": "Незабавен микс",
|
||||
"@instantMix": {},
|
||||
"addedToQueue": "Добавено към опашката.",
|
||||
"@addedToQueue": {},
|
||||
"responseError401": "{error} Код на грешката {statusCode}. Това вероятно означава, че сте използвали грешно потребителско име/парола, или вашият клиент вече не е удостоверен.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redownloadedItems": "{count,plural, =0{Не е необходимо повторно изтегляне.} =1{Повторно изтегляне {count} елемент} other{Повторно изтегляне {count} елементи}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} елемент} other{{count} елементи}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"serverUrl": "URL del servidor",
|
||||
"@serverUrl": {},
|
||||
"downloaded": "DESCARREGAT",
|
||||
"@downloaded": {},
|
||||
"removeFavourite": "Suprimeix el favorit",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Afegeix favorit",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "S'ha afegit a la cua.",
|
||||
"@addedToQueue": {},
|
||||
"addToMix": "Afegir a la barreja",
|
||||
"@addToMix": {},
|
||||
"statusError": "ERROR D'ESTAT",
|
||||
"@statusError": {},
|
||||
"startupError": "S'ha produït un error durant l'inici de l'aplicació. L'error va ser: {error}\n\nCreeu un problema a github.com/UnicornsOnLSD/finamp amb una captura de pantalla d'aquesta pàgina. Si aquest problema persisteix, podeu esborrar les dades de l'aplicació per restablir-la.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queue": "Cua",
|
||||
"@queue": {},
|
||||
"replaceQueue": "Substitueix la cua",
|
||||
"@replaceQueue": {},
|
||||
"internalExternalIpExplanation": "Si voleu poder accedir al vostre servidor Jellyfin de forma remota, heu d'utilitzar la vostra IP externa.\n\nSi el vostre servidor està en un port HTTP (80/443), no cal que especifiqueu cap port. És probable que aquest sigui el cas si el vostre servidor està darrere d'un servidor intermediari invers.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"transcode": "TRANSCODIFICACIÓ",
|
||||
"@transcode": {},
|
||||
"direct": "DIRECTE",
|
||||
"@direct": {},
|
||||
"instantMix": "Barreja instantània",
|
||||
"@instantMix": {},
|
||||
"addToQueue": "Afegir a la cua",
|
||||
"@addToQueue": {},
|
||||
"goToAlbum": "Vés a l'àlbum",
|
||||
"@goToAlbum": {},
|
||||
"removedFromPlaylist": "S'ha suprimit de la llista de reproducció.",
|
||||
"@removedFromPlaylist": {},
|
||||
"queueReplaced": "S'ha substituït la cua.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "Inici de la barreja instantània.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "S'ha produït un error.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Codi d'estat {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError401": "{error} Codi d'estat {statusCode}. Això probablement vol dir que heu utilitzat un nom d'usuari/contrasenya incorrecte o que el vostre client ha tancat la sessió.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Eliminar de la barreja",
|
||||
"@removeFromMix": {}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
{
|
||||
"albums": "Alba",
|
||||
"@albums": {},
|
||||
"artists": "Umělci",
|
||||
"@artists": {},
|
||||
"clear": "Vymazat",
|
||||
"@clear": {},
|
||||
"downloads": "Stažené",
|
||||
"@downloads": {},
|
||||
"favourites": "Oblíbené",
|
||||
"@favourites": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"albumArtist": "Umělec alba",
|
||||
"@albumArtist": {},
|
||||
"errorScreenError": "Při načítání seznamu chyb došlo k chybě! Vytvořte prosím problém na GitHubu a vymažte data aplikace",
|
||||
"@errorScreenError": {},
|
||||
"artist": "Umělec",
|
||||
"@artist": {},
|
||||
"dlComplete": "{count} dokončeno",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} ve frontě",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} selhalo",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateAdded": "Datum přidání",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Datum přehrání",
|
||||
"@datePlayed": {},
|
||||
"discNumber": "Disk {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadMissingImages": "Stáhnout chybějící obrázky",
|
||||
"@downloadMissingImages": {},
|
||||
"failedToGetSongFromDownloadId": "Error nelze sehnat skladbu s ID pro stažení",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"editPlaylistNameTooltip": "Upravit název seznamu skladeb",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"editPlaylistNameTitle": "Upravit název seznamu skladeb",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"addButtonLabel": "PŘIDAT",
|
||||
"@addButtonLabel": {},
|
||||
"addDownloadLocation": "Přidat umístění stažených",
|
||||
"@addDownloadLocation": {},
|
||||
"bitrateSubtitle": "Vyšší datový tok poskytuje vyšší kvalitu zvuku, ale zvýší využití internetu.",
|
||||
"@bitrateSubtitle": {},
|
||||
"appDirectory": "Adresář aplikací",
|
||||
"@appDirectory": {},
|
||||
"areYouSure": "Opravdu?",
|
||||
"@areYouSure": {},
|
||||
"bitrate": "Datový tok",
|
||||
"@bitrate": {},
|
||||
"directoryMustBeEmpty": "Adresář musí být prázdný",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Stažené skladby nebudou odstraněny",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"favourite": "Oblíbené",
|
||||
"@favourite": {},
|
||||
"gridCrossAxisCountSubtitle": "Počet položek mřížky na řádek {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleAllSongCountSubtitle": "Počet skladeb, které se mají načíst při použití tlačítka náhodného přehrávání všech skladeb.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"cancelSleepTimer": "Zrušit časovač spánku?",
|
||||
"@cancelSleepTimer": {},
|
||||
"grid": "Mřížka",
|
||||
"@grid": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Nezobrazovat umělce, pokud je totožný s umělcem alba",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"addToPlaylistTooltip": "Přidat do seznamu skladeb",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Přidat do seznamu skladeb",
|
||||
"@addToPlaylistTitle": {},
|
||||
"responseError": "{error} Stavový kód {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"addedToQueue": "Přidáno do fronty.",
|
||||
"@addedToQueue": {},
|
||||
"addFavourite": "Přidat do oblíbených",
|
||||
"@addFavourite": {},
|
||||
"addToMix": "Přidat do mixu",
|
||||
"@addToMix": {},
|
||||
"addToQueue": "Přidat do fronty",
|
||||
"@addToQueue": {},
|
||||
"anErrorHasOccured": "Vyskytla se chyba.",
|
||||
"@anErrorHasOccured": {},
|
||||
"downloaded": "STAŽENO",
|
||||
"@downloaded": {},
|
||||
"goToAlbum": "Přejít na album",
|
||||
"@goToAlbum": {},
|
||||
"couldNotFindLibraries": "Nebyly nalezeny žádné knihovny.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"enableTranscoding": "Zapnout překódování",
|
||||
"@enableTranscoding": {},
|
||||
"genres": "Žánry",
|
||||
"@genres": {},
|
||||
"logs": "Protokoly",
|
||||
"@logs": {},
|
||||
"password": "Heslo",
|
||||
"@password": {},
|
||||
"next": "Další",
|
||||
"@next": {},
|
||||
"username": "Uživatelské jméno",
|
||||
"@username": {},
|
||||
"minutes": "Minuty",
|
||||
"@minutes": {},
|
||||
"disableGesture": "Zakázat gesta",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Zda zakázat gesta.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"removeFromPlaylistTooltip": "Odebrat ze seznamu skladeb",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"replaceQueue": "Nahradit frontu",
|
||||
"@replaceQueue": {},
|
||||
"serverUrl": "Adresa URL serveru",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "Adresa URL serveru nemůže být prázdná",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"selectMusicLibraries": "Vyberte hudební knihovny",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"settings": "Nastavení",
|
||||
"@settings": {},
|
||||
"offlineMode": "Režim offline",
|
||||
"@offlineMode": {},
|
||||
"sortOrder": "Pořadí řazení",
|
||||
"@sortOrder": {},
|
||||
"internalExternalIpExplanation": "Pokud budete chtít vzdáleně přistupovat k vašemu serveru Jellyfin, budete muset použít vaši externí IP.\n\nPokud je váš server na portu HTTP (80/443), nemusíte specifikovat port. Toto bývá obvyklé, když máte server za reverzní proxy.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"startMixNoSongsArtist": "Dlouze podržte prst na umělci pro jeho přidání nebo odebrání z tvorby mixu před jeho spuštěním",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Dlouze podržte prst na albu pro jeho přidání nebo odebrání z tvorby mixu před jeho spuštěním",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"budget": "Rozpočet",
|
||||
"@budget": {},
|
||||
"communityRating": "Komunitní hodnocení",
|
||||
"@communityRating": {},
|
||||
"playCount": "Počet přehrání",
|
||||
"@playCount": {},
|
||||
"premiereDate": "Datum premiéry",
|
||||
"@premiereDate": {},
|
||||
"revenue": "Výnos",
|
||||
"@revenue": {},
|
||||
"runtime": "Doba běhu",
|
||||
"@runtime": {},
|
||||
"shuffleButtonLabel": "NÁHODNĚ",
|
||||
"@shuffleButtonLabel": {},
|
||||
"removedFromPlaylist": "Odebráno ze seznamu skladeb.",
|
||||
"@removedFromPlaylist": {},
|
||||
"startingInstantMix": "Spouštění okamžitého mixu.",
|
||||
"@startingInstantMix": {},
|
||||
"bufferDuration": "Trvání vyrovnávací paměti",
|
||||
"@bufferDuration": {},
|
||||
"showUncensoredLogMessage": "Tento protokol zobrazuje vaše přihlašovací informace. Chcete jej zobrazit?",
|
||||
"@showUncensoredLogMessage": {},
|
||||
"resetTabs": "Obnovit karty",
|
||||
"@resetTabs": {},
|
||||
"message": "Zpráva",
|
||||
"@message": {},
|
||||
"stackTrace": "Trasování",
|
||||
"@stackTrace": {},
|
||||
"applicationLegalese": "Licence Mozilla Public License 2.0. Zdrojový kód je dostupný na stránce:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"notAvailableInOfflineMode": "Není dostupné v režimu offline",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"layoutAndTheme": "Rozložení a motiv",
|
||||
"@layoutAndTheme": {},
|
||||
"logOut": "Odhlásit se",
|
||||
"@logOut": {},
|
||||
"customLocation": "Vlastní umístění",
|
||||
"@customLocation": {},
|
||||
"unknownError": "Neznámá chyba",
|
||||
"@unknownError": {},
|
||||
"pathReturnSlashErrorMessage": "Cesty, které vracejí „/“, nelze použít",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"customLocationsBuggy": "Vlastní umístění bývají kvůli problémům s oprávněními extrémně chybové. Snažíme se tento problém opravit, do té doby ale nedoporučujeme jejich používání.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPause": "Po pozastavení vstoupit do stavu nízké priority",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"shuffleAllSongCount": "Počet skladeb pro náhodné přehrávání všeho",
|
||||
"@shuffleAllSongCount": {},
|
||||
"portrait": "Na výšku",
|
||||
"@portrait": {},
|
||||
"viewType": "Typ zobrazení",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Typ zobrazení pro hudební obrazovku",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "Seznam",
|
||||
"@list": {},
|
||||
"showCoverAsPlayerBackground": "Zobrazit rozmazaný obal jako pozadí přehrávače",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Zda použít rozmazaný obal alba jako pozadí na obrazovce přehrávače.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"system": "Systémový",
|
||||
"@system": {},
|
||||
"light": "Světlý",
|
||||
"@light": {},
|
||||
"theme": "Motiv",
|
||||
"@theme": {},
|
||||
"dark": "Tmavý",
|
||||
"@dark": {},
|
||||
"tabs": "Karty",
|
||||
"@tabs": {},
|
||||
"setSleepTimer": "Nastavit časovač spánku",
|
||||
"@setSleepTimer": {},
|
||||
"invalidNumber": "Neplatné číslo",
|
||||
"@invalidNumber": {},
|
||||
"createButtonLabel": "VYTVOŘIT",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Seznam skladeb vytvořen.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "Žádné album",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Žádná položka",
|
||||
"@noItem": {},
|
||||
"noArtist": "Žádný umělec",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Neznámý umělec",
|
||||
"@unknownArtist": {},
|
||||
"streaming": "STREAMOVÁNÍ",
|
||||
"@streaming": {},
|
||||
"transcode": "PŘEKÓDOVÁNÍ",
|
||||
"@transcode": {},
|
||||
"direct": "PŘÍMÝ",
|
||||
"@direct": {},
|
||||
"statusError": "CHYBA STAVU",
|
||||
"@statusError": {},
|
||||
"queue": "Fronta",
|
||||
"@queue": {},
|
||||
"instantMix": "Okamžitý mix",
|
||||
"@instantMix": {},
|
||||
"removeFavourite": "Odebrat z oblíbených",
|
||||
"@removeFavourite": {},
|
||||
"queueReplaced": "Fronta nahrazena.",
|
||||
"@queueReplaced": {},
|
||||
"removeFromMix": "Odebrat z mixu",
|
||||
"@removeFromMix": {},
|
||||
"noMusicLibrariesTitle": "Žádné hudební knihovny",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"refresh": "OBNOVIT",
|
||||
"@refresh": {},
|
||||
"language": "Jazyk",
|
||||
"@language": {},
|
||||
"syncDownloadedPlaylists": "Synchronizovat stažené seznamy skladeb",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Nenalezeny žádné chybějící obrázky} =1{Stažen {count} chybějící obrázek} few{Staženy {count} chybějící obrázky} other{Staženo {count} chybějících obrázků}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "Chyby stahování",
|
||||
"@downloadErrors": {},
|
||||
"downloadCount": "{count,plural, =1{{count} stahování} other{{count} stahování}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} položka} few{{count} položky} other{{count} položek}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} obrázek} few{{count} obrázky} other{{count} obrázků}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} běží",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Chyby stahování",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "Žádné chyby!",
|
||||
"@noErrors": {},
|
||||
"deleteDownloadsPrompt": "Opravdu chcete odstranit {itemType, select, album{album} playlist{seznam skladeb} artist{umělce} genre{žánr} track{skladbu} other{}} '{itemName}' z tohoto zařízení?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"playButtonLabel": "PŘEHRÁT",
|
||||
"@playButtonLabel": {},
|
||||
"songCount": "{count,plural,=1{{count} skladba} few{{count} skladby} other{{count} skladeb}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": "Vyžadováno",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "UPRAVIT",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Název seznamu skladeb upraven.",
|
||||
"@playlistNameUpdated": {},
|
||||
"downloadsDeleted": "Stahování odstraněna.",
|
||||
"@downloadsDeleted": {},
|
||||
"addDownloads": "Přidat stahování",
|
||||
"@addDownloads": {},
|
||||
"location": "Umístění",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Stahování přidána.",
|
||||
"@downloadsAdded": {},
|
||||
"shareLogs": "Sdílet protokoly",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Protokoly zkopírovány.",
|
||||
"@logsCopied": {},
|
||||
"transcoding": "Překódování",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Umístění stažených",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Služba zvuku",
|
||||
"@audioService": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin používá pro překódování formát AAC",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Překóduje hudební streamy na straně serveru.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"selectDirectory": "Vybrat adresář",
|
||||
"@selectDirectory": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Zda zobrazit umělce skladby na obrazovce alba, pokud se neliší od umělců alba.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"showFastScroller": "Zobrazit rychlý posuvník",
|
||||
"@showFastScroller": {},
|
||||
"landscape": "Na šířku",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCount": "Počet položek mřížky {value}",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Zobrazit text v zobrazení v mřížce",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Zda zobrazit text (název, umělce atd.) v mřížkovém zobrazení hudební obrazovky.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"yesButtonLabel": "ANO",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "NE",
|
||||
"@noButtonLabel": {},
|
||||
"sleepTimerTooltip": "Časovač spánku",
|
||||
"@sleepTimerTooltip": {},
|
||||
"removeFromPlaylistTitle": "Odebrat ze seznamu skladeb",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"newPlaylist": "Nový seznam skladeb",
|
||||
"@newPlaylist": {},
|
||||
"responseError401": "{error} Stavový kód {statusCode}. Toto nejspíše znamená, že jste použili nesprávné uživatelské jméno / heslo, nebo váš klient již není přihlášen.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redownloadedItems": "{count,plural, =0{Nejsou potřeba žádná opětovná stažení.} =1{Znovu stažena{count} položka} few{Znovu staženy{count} položky} other{Znovu staženo {count} položek}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDurationSubtitle": "Kolik sekund dopředu by měl přehrávač uložit do vyrovnávací paměti. Vyžaduje restart aplikace.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"startupError": "Při spouštění aplikace se něco pokazilo. Chyba: {error}\n\nNahlaste prosím problém na stránce github.com/UnicornsOnLSD/finamp společně se snímkem obrazovky této stránky. Pokud tento problém přetrvává, můžete vymazat data aplikace pro její obnovení.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"urlStartWithHttps": "Adresa URL musí začínat s http:// nebo https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "Adresa URL nesmí obsahovat koncové lomítko",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"startMix": "Spustit mix",
|
||||
"@startMix": {},
|
||||
"music": "Hudba",
|
||||
"@music": {},
|
||||
"shuffleAll": "Vše náhodně",
|
||||
"@shuffleAll": {},
|
||||
"unknownName": "Neznámý název",
|
||||
"@unknownName": {},
|
||||
"playlists": "Seznamy skladeb",
|
||||
"@playlists": {},
|
||||
"sortBy": "Řadit podle",
|
||||
"@sortBy": {},
|
||||
"songs": "Skladby",
|
||||
"@songs": {},
|
||||
"criticRating": "Hodnocení kritiků",
|
||||
"@criticRating": {},
|
||||
"productionYear": "Rok produkce",
|
||||
"@productionYear": {},
|
||||
"name": "Název",
|
||||
"@name": {},
|
||||
"random": "Náhodně",
|
||||
"@random": {},
|
||||
"deleteDownloadsAbortButtonText": "Zrušit",
|
||||
"@deleteDownloadsAbortButtonText": {},
|
||||
"deleteDownloadsConfirmButtonText": "Odstranit",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"error": "Chyba",
|
||||
"@error": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Umožní odstranění notifikace při pozastavení. Také umožní systému ukončit službu.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"playNext": "Přehrát další",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"insertedIntoQueue": "Vloženo do fronty.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"confirm": "Potvrdit",
|
||||
"@confirm": {},
|
||||
"noMusicLibrariesBody": "Finamp nenalezl žádné hudební knihovny. Ujistěte se prosím, že váš server Jellyfin obsahuje alespoň jednu knihovnu s typem obsahu nastaveným na „Hudba“.",
|
||||
"@noMusicLibrariesBody": {},
|
||||
"swipeInsertQueueNextSubtitle": "Zapněte pro vložení skladby jako další položku do fronty po posunutí prstem na skladbě v seznamu skladeb, místo jejího přiřazení na konec.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"interactions": "Interakce",
|
||||
"@interactions": {},
|
||||
"swipeInsertQueueNext": "Přehrát posunutou skladbu jako další",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"redesignBeta": "Vyzkoušejte beta verzi",
|
||||
"@redesignBeta": {},
|
||||
"playbackOrderShuffledTooltip": "Náhodně. Klepnutím přepnete.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Přehrávání v pořadí. Klepnutím přepnete.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"loopModeAllTooltip": "Opakování všeho. Klepnutím přepnete.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Opakování jedné. Klepnutím přepnete.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"loopModeNoneTooltip": "Bez opakování. Klepnutím přepnete.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"skipToPrevious": "Přeskočit na předchozí skladbu",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Přeskočit na další skladbu",
|
||||
"@skipToNext": {},
|
||||
"togglePlayback": "Přepnout přehrávání",
|
||||
"@togglePlayback": {},
|
||||
"downloadArtist": "Stáhnout všechna alba umělce {artist}",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Přehrát náhodně všechna alba umělce {artist}",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playArtist": "Přehrát všechna alba umělce {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFromDevice": "Smazat ze zařízení",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Stáhnout",
|
||||
"@download": {},
|
||||
"sync": "Synchronizovat se serverem",
|
||||
"@sync": {},
|
||||
"about": "O aplikaci Finamp",
|
||||
"@about": {}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
{
|
||||
"serverUrl": "Server URL",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "Server URL kan ikke være tom",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL skal starte med http:// eller https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL må ikke indeholde en efterfølgnede stråstreg",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Brugernavn",
|
||||
"@username": {},
|
||||
"password": "Adgangskode",
|
||||
"@password": {},
|
||||
"logs": "Log",
|
||||
"@logs": {},
|
||||
"next": "Næste",
|
||||
"@next": {},
|
||||
"selectMusicLibraries": "Vælg musik biblioteker",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"couldNotFindLibraries": "Kunne ikke finde nogle biblioteker.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"songs": "Sange",
|
||||
"@songs": {},
|
||||
"artists": "Kunstnere",
|
||||
"@artists": {},
|
||||
"genres": "Genre",
|
||||
"@genres": {},
|
||||
"startMix": "Start blanding",
|
||||
"@startMix": {},
|
||||
"music": "Musik",
|
||||
"@music": {},
|
||||
"clear": "Ryd",
|
||||
"@clear": {},
|
||||
"favourites": "Favoritter",
|
||||
"@favourites": {},
|
||||
"shuffleAll": "Bland alle",
|
||||
"@shuffleAll": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"downloads": "Overførelser",
|
||||
"@downloads": {},
|
||||
"settings": "Indstillinger",
|
||||
"@settings": {},
|
||||
"offlineMode": "Offline tilstand",
|
||||
"@offlineMode": {},
|
||||
"sortOrder": "Sorter efter",
|
||||
"@sortOrder": {},
|
||||
"sortBy": "Sortér efter",
|
||||
"@sortBy": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"albumArtist": "Album kunstner",
|
||||
"@albumArtist": {},
|
||||
"artist": "Kunstner",
|
||||
"@artist": {},
|
||||
"criticRating": "Kritiker bedømmelse",
|
||||
"@criticRating": {},
|
||||
"communityRating": "Fællesskab bedømmelse",
|
||||
"@communityRating": {},
|
||||
"dateAdded": "Dato tilføjet",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Afspillet den",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Antal afspilninger",
|
||||
"@playCount": {},
|
||||
"premiereDate": "Udgivelsesdato",
|
||||
"@premiereDate": {},
|
||||
"productionYear": "Produktionsår",
|
||||
"@productionYear": {},
|
||||
"name": "Navn",
|
||||
"@name": {},
|
||||
"random": "Tilfældig",
|
||||
"@random": {},
|
||||
"revenue": "Omsætning",
|
||||
"@revenue": {},
|
||||
"budget": "Budget",
|
||||
"@budget": {},
|
||||
"downloadMissingImages": "Overførelse mangler billeder",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadErrors": "Overførelsesfejl",
|
||||
"@downloadErrors": {},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} emne} other{{count} emner}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} billede} other{{count} billeder}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} færdige",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} fejlede",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} i kø",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noErrors": "Ingen fejl!",
|
||||
"@noErrors": {},
|
||||
"failedToGetSongFromDownloadId": "Fejlede under hentning af sang fra overførelse ID",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"error": "Fejl",
|
||||
"@error": {},
|
||||
"discNumber": "Disk {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "AFSPIL",
|
||||
"@playButtonLabel": {},
|
||||
"editPlaylistNameTooltip": "Rediger afspilningslistens navn",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"editPlaylistNameTitle": "Rediger afspilningslistens navn",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"required": "Påkrævet",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "OPDATÉR",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Afspilningslistens navn er opdateret.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Favorit",
|
||||
"@favourite": {},
|
||||
"addDownloads": "Tilføj overførelser",
|
||||
"@addDownloads": {},
|
||||
"location": "Placering",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Overførelse tilføjet.",
|
||||
"@downloadsAdded": {},
|
||||
"shareLogs": "Del log",
|
||||
"@shareLogs": {},
|
||||
"stackTrace": "Bunke spor",
|
||||
"@stackTrace": {},
|
||||
"transcoding": "Omkoder",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Overførelse placering",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Lyd tjeneste",
|
||||
"@audioService": {},
|
||||
"layoutAndTheme": "Udseende & tema",
|
||||
"@layoutAndTheme": {},
|
||||
"notAvailableInOfflineMode": "Ikke tilgængelig i offline tilstand",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Log af",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Overførte sange vil ikke blive slettet",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"areYouSure": "Er du sikker?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin bruger AAC ved omkodning",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Aktivér omkodning",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Omkoder musik under afspilning på serveren.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"bitrateSubtitle": "En højere bithastighed giver højere lydkvalitet men bruger mere båndbredde.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Vælg placering",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "App katalog",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Tilføj overførelse placering",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Vælg katalog",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Ukendt fejl",
|
||||
"@unknownError": {},
|
||||
"directoryMustBeEmpty": "Katalog skal være tomt",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enterLowPriorityStateOnPause": "Gå i lav prioritet tilstand når musik afspilning er på pause",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Tillader notifikationer bliver strøget væk, når afspilning er sat på pause. Dette tillader også Android, at afbryde servicen når den er på pause.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"shuffleAllSongCount": "Bland alle sange antal",
|
||||
"@shuffleAllSongCount": {},
|
||||
"viewType": "Visning tilstand",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Visningstype for musik skærmen",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "Liste",
|
||||
"@list": {},
|
||||
"grid": "Gitter",
|
||||
"@grid": {},
|
||||
"portrait": "Portræt",
|
||||
"@portrait": {},
|
||||
"landscape": "Landskab",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCountSubtitle": "Antal gitrebrikker som bruges per række {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Vis tekst i gitter visning",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Vælg om tekst skal vises (titel, kunstner osv.) på gitteret musik skærm.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Vis sløret cover som afspiller baggrund",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Vælg om sangens kunstner skal vises på albummet, hvis den ikke er forskellig fra albummets kunstner.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"disableGesture": "Deaktivér bevægelser",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Vælg om bevægelser skal deaktiveres.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"theme": "Tema",
|
||||
"@theme": {},
|
||||
"light": "Lys",
|
||||
"@light": {},
|
||||
"dark": "Mørk",
|
||||
"@dark": {},
|
||||
"tabs": "Faner",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Vil du annullere sove timeren?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "JA",
|
||||
"@yesButtonLabel": {},
|
||||
"minutes": "Minutter",
|
||||
"@minutes": {},
|
||||
"sleepTimerTooltip": "Sove timer",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Tilføj til afspilningsliste",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Tilføj til afspilningsliste",
|
||||
"@addToPlaylistTitle": {},
|
||||
"removeFromPlaylistTooltip": "Fjern fra afspilningsliste",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Fjern fra afspilningsliste",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"newPlaylist": "Ny afspilningsliste",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "OPRET",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Afspilningsliste er oprettet.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "Intet album",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Intet objekt",
|
||||
"@noItem": {},
|
||||
"invalidNumber": "Ugyldig nummer",
|
||||
"@invalidNumber": {},
|
||||
"streaming": "STREAMER",
|
||||
"@streaming": {},
|
||||
"downloaded": "OVERFØRELSER",
|
||||
"@downloaded": {},
|
||||
"transcode": "OMKOD",
|
||||
"@transcode": {},
|
||||
"direct": "DIREKTE",
|
||||
"@direct": {},
|
||||
"statusError": "Status fejl",
|
||||
"@statusError": {},
|
||||
"queue": "Kø",
|
||||
"@queue": {},
|
||||
"addToQueue": "Tilføj til kø",
|
||||
"@addToQueue": {},
|
||||
"replaceQueue": "Udskift køen",
|
||||
"@replaceQueue": {},
|
||||
"instantMix": "Hurtig blanding",
|
||||
"@instantMix": {},
|
||||
"removeFavourite": "Fjern favorit",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Tilføj favorit",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "Tilføjet til kø.",
|
||||
"@addedToQueue": {},
|
||||
"queueReplaced": "Køen er udskiftet.",
|
||||
"@queueReplaced": {},
|
||||
"removedFromPlaylist": "Fjernet fra afspilningslisten.",
|
||||
"@removedFromPlaylist": {},
|
||||
"startingInstantMix": "Starter hurtig blanding.",
|
||||
"@startingInstantMix": {},
|
||||
"downloadCount": "{count,plural, =1{{count} overført} other{{count} overførelser}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Fjern fra blanding",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Tilføj til blanding",
|
||||
"@addToMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{Ingen genoverførsel er nødvendig.} =1{Genoverførte {count} objekt} other{Genoverfører {count} objekter}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDuration": "Buffer varighed",
|
||||
"@bufferDuration": {},
|
||||
"bufferDurationSubtitle": "Hvor meget afspilleren skal forudindlæse i sekunder. En genstart er nødvendig.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"startupError": "Noget gik galt under app opstarten. Fejlen er {error}\n\nVenligst opret en sag på github.com/UnicornsOnLSD/finamp med et skærmbillede af denne side. Hvis dette problem forbliver, så kan du fjerne dine app data for at nulstille appen.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internalExternalIpExplanation": "Hvis du vil være i stand til at tilgå din Jellyfin server udefra, skal du bruge din eksterne IP.\n\nHvis din server er på HTTP porten (80/443), behøver du ikke angive en port. Dette er som regel tilfældet, hvis din server er bag en reverse proxy.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"unknownName": "Ukendt navn",
|
||||
"@unknownName": {},
|
||||
"albums": "Albummer",
|
||||
"@albums": {},
|
||||
"playlists": "Afspilningslister",
|
||||
"@playlists": {},
|
||||
"startMixNoSongsArtist": "Tryk (lang tid) på en kunstner for at tilføje eller fjerne den fra blandingen før du starter en blanding",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Tryk (lang tid) på et album for at tilføje eller fjerne det fra blandingen for du starter en blanding",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"runtime": "Spilletid",
|
||||
"@runtime": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Ingen manglende billeder blev fundet} =1{Overførte {count} manglende billeder} other{Overførte {count} manglende billeder}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} er igang",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Overførelsesfejl",
|
||||
"@downloadErrorsTitle": {},
|
||||
"shuffleButtonLabel": "BLAND",
|
||||
"@shuffleButtonLabel": {},
|
||||
"errorScreenError": "Der opstod en fejl under hentningen af listen med fejl! På dette tidspunkt, bedes du oprette en fejl på Github og slette appens data",
|
||||
"@errorScreenError": {},
|
||||
"songCount": "{count,plural,=1{{count} Sang} other{{count} Sange}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadsDeleted": "Overførelser slettet.",
|
||||
"@downloadsDeleted": {},
|
||||
"addButtonLabel": "TILFØJ",
|
||||
"@addButtonLabel": {},
|
||||
"bitrate": "Bithastighed",
|
||||
"@bitrate": {},
|
||||
"logsCopied": "Log kopieret.",
|
||||
"@logsCopied": {},
|
||||
"message": "Besked",
|
||||
"@message": {},
|
||||
"applicationLegalese": "Licenseret med Mozilla Public License 2.0. Kildeteksten er tilgængelig på:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"pathReturnSlashErrorMessage": "Stier, der returnerer \"/\", kan ikke anvendes",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"customLocationsBuggy": "Valgfrie placeringer er ekstremt fejlramte grundet manglende rettigheder. Jeg prøver at finde måder at løse dette, men for nu anbefaler jeg ikke, at de anvendes.",
|
||||
"@customLocationsBuggy": {},
|
||||
"noArtist": "Ingen kunstner",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Ukendt kunstner",
|
||||
"@unknownArtist": {},
|
||||
"shuffleAllSongCountSubtitle": "Mængde af sange der skal indlæses, når bland alle sange knappen bruges.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Skjul sang kunstner, hvis det er den samme som albummets kunstner",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"gridCrossAxisCount": "{value} Gitter tværakse antal",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"setSleepTimer": "Indtil sove timer",
|
||||
"@setSleepTimer": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Vælg om der skal bruges sløret omslagskunst som vises som baggrund på afspiller skærmen.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"system": "System",
|
||||
"@system": {},
|
||||
"noButtonLabel": "NEJ",
|
||||
"@noButtonLabel": {},
|
||||
"goToAlbum": "Gå til album",
|
||||
"@goToAlbum": {},
|
||||
"anErrorHasOccured": "Der opstod en fejl.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError401": "{error} Status kode {statusCode}. Dette betyder, at du har anvendt et forkert brugernavn/adgangskode eller at din klient ikke længere er logget på.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError": "{error} Status kode {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": "Sprog",
|
||||
"@language": {}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
{
|
||||
"music": "Musik",
|
||||
"@music": {},
|
||||
"areYouSure": "Bist du sicher?",
|
||||
"@areYouSure": {},
|
||||
"albums": "Alben",
|
||||
"@albums": {},
|
||||
"artists": "Künstler",
|
||||
"@artists": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"albumArtist": "Albumkünstler",
|
||||
"@albumArtist": {},
|
||||
"artist": "Künstler",
|
||||
"@artist": {},
|
||||
"bitrate": "Bitrate",
|
||||
"@bitrate": {},
|
||||
"emptyServerUrl": "Server-URL darf nicht leer sein",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL muss mit http:// oder https:// beginnen",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL darf keinen Schrägstrich am Ende enthalten",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"couldNotFindLibraries": "Konnte keine Bibliotheken finden.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"startMix": "Starte Mix",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsAlbum": "Vor dem Starten eines Mixes lange auf ein Album gedrückt halten, um es zum Mixzusammensteller hinzuzufügen oder zu entfernen",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"clear": "Entfernen",
|
||||
"@clear": {},
|
||||
"internalExternalIpExplanation": "Wenn du deinen Jellyfin Server aus der Ferne erreichen möchtest, benötigst du eine externe IP.\n\nWenn dein Server einen HTTP Port (80/443) benutzt, musst du keinen Port festlegen. Dies ist vermutlich der Fall wenn dein Server sich hinter einer Reverse Proxy befindet.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"next": "Weiter",
|
||||
"@next": {},
|
||||
"logs": "Protokolle",
|
||||
"@logs": {},
|
||||
"startMixNoSongsArtist": "Vor dem Starten eines Mixes lange auf einen Künstler gedrückt halten, um ihn zum Mixzusammensteller hinzuzufügen oder zu entfernen",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"unknownName": "Unbekannter Name",
|
||||
"@unknownName": {},
|
||||
"username": "Benutzername",
|
||||
"@username": {},
|
||||
"genres": "Genres",
|
||||
"@genres": {},
|
||||
"playlists": "Playlists",
|
||||
"@playlists": {},
|
||||
"songs": "Lieder",
|
||||
"@songs": {},
|
||||
"favourites": "Favoriten",
|
||||
"@favourites": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"settings": "Einstellungen",
|
||||
"@settings": {},
|
||||
"communityRating": "Community-Bewertung",
|
||||
"@communityRating": {},
|
||||
"offlineMode": "Offline-Modus",
|
||||
"@offlineMode": {},
|
||||
"sortBy": "Sortieren nach",
|
||||
"@sortBy": {},
|
||||
"sortOrder": "Sortierreihenfolge",
|
||||
"@sortOrder": {},
|
||||
"productionYear": "Produktionsjahr",
|
||||
"@productionYear": {},
|
||||
"random": "Zufällig",
|
||||
"@random": {},
|
||||
"error": "Fehler",
|
||||
"@error": {},
|
||||
"required": "Erforderlich",
|
||||
"@required": {},
|
||||
"message": "Nachricht",
|
||||
"@message": {},
|
||||
"logOut": "Abmelden",
|
||||
"@logOut": {},
|
||||
"notAvailableInOfflineMode": "Im Offline-Modus nicht verfügbar",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"directoryMustBeEmpty": "Verzeichnis muss leer sein",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enableTranscoding": "Transkodierung aktivieren",
|
||||
"@enableTranscoding": {},
|
||||
"grid": "Raster",
|
||||
"@grid": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin verwendet AAC zur Transkodierung",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"list": "Liste",
|
||||
"@list": {},
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"@unknownError": {},
|
||||
"invalidNumber": "Ungültige Zahl",
|
||||
"@invalidNumber": {},
|
||||
"noButtonLabel": "NEIN",
|
||||
"@noButtonLabel": {},
|
||||
"yesButtonLabel": "JA",
|
||||
"@yesButtonLabel": {},
|
||||
"createButtonLabel": "ERSTELLEN",
|
||||
"@createButtonLabel": {},
|
||||
"direct": "DIREKT",
|
||||
"@direct": {},
|
||||
"newPlaylist": "Neue Wiedergabeliste",
|
||||
"@newPlaylist": {},
|
||||
"playlistCreated": "Wiedergabeliste erstellt.",
|
||||
"@playlistCreated": {},
|
||||
"removeFavourite": "Favorit entfernen",
|
||||
"@removeFavourite": {},
|
||||
"unknownArtist": "Unbekannter Künstler",
|
||||
"@unknownArtist": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Heruntergeladene Lieder werden nicht gelöscht",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"favourite": "Favorit",
|
||||
"@favourite": {},
|
||||
"name": "Name",
|
||||
"@name": {},
|
||||
"password": "Passwort",
|
||||
"@password": {},
|
||||
"showTextOnGridView": "Text in Rasteransicht anzeigen",
|
||||
"@showTextOnGridView": {},
|
||||
"noErrors": "Keine Fehler!",
|
||||
"@noErrors": {},
|
||||
"selectMusicLibraries": "Musikbibliotheken auswählen",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"startupError": "Während dem Starten der App ist etwas schief gelaufen. Der Fehler war: {error}\n\nBitte öffne ein Issue auf github.com/UnicornsOnLSD/finamp mit einem Bildschirmfoto von dieser Seite. Wenn dieses Problem weiterhin besteht, kannst du deine App-Daten löschen, um die App zurückzusetzen.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverUrl": "Server-URL",
|
||||
"@serverUrl": {},
|
||||
"shuffleAll": "Alle mischen",
|
||||
"@shuffleAll": {},
|
||||
"downloads": "Downloads",
|
||||
"@downloads": {},
|
||||
"budget": "Budget",
|
||||
"@budget": {},
|
||||
"criticRating": "Kritikerbewertung",
|
||||
"@criticRating": {},
|
||||
"dateAdded": "Datum hinzugefügt",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Datum gespielt",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Anzahl abgespielt",
|
||||
"@playCount": {},
|
||||
"revenue": "Einspielergebnis",
|
||||
"@revenue": {},
|
||||
"runtime": "Dauer",
|
||||
"@runtime": {},
|
||||
"downloadMissingImages": "Fehlende Bilder herunterladen",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadErrors": "Download-Fehler",
|
||||
"@downloadErrors": {},
|
||||
"downloadCount": "{count,plural, =1{{count} Download} other{{count} Downloads}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} fehlgeschlagen",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} fertiggestellt",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} in der Warteschlange",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} laufend",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Download-Fehler",
|
||||
"@downloadErrorsTitle": {},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failedToGetSongFromDownloadId": "Song konnte nicht über die Download-ID abgerufen werden",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"playButtonLabel": "ABSPIELEN",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "MISCHEN",
|
||||
"@shuffleButtonLabel": {},
|
||||
"editPlaylistNameTitle": "Wiedergabeliste-Name editieren",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"updateButtonLabel": "AKTUALISIERUNG",
|
||||
"@updateButtonLabel": {},
|
||||
"downloadsDeleted": "Downloads gelöscht.",
|
||||
"@downloadsDeleted": {},
|
||||
"addDownloads": "Downloads hinzufügen",
|
||||
"@addDownloads": {},
|
||||
"location": "Ort",
|
||||
"@location": {},
|
||||
"shareLogs": "Protokolle teilen",
|
||||
"@shareLogs": {},
|
||||
"stackTrace": "Stacktrace",
|
||||
"@stackTrace": {},
|
||||
"downloadLocations": "Download Orte",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Audio Service",
|
||||
"@audioService": {},
|
||||
"layoutAndTheme": "Aufbau & Thema",
|
||||
"@layoutAndTheme": {},
|
||||
"transcoding": "Transkodierung",
|
||||
"@transcoding": {},
|
||||
"enableTranscodingSubtitle": "Transkodiert Musikstreams serverseitig.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"bitrateSubtitle": "Eine höhere Bitrate verbessert die Audioqualität auf Kosten der benötigten Bandbreite.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Benutzerdefinierter Ort",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "App-Verzeichnis",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Download-Ort hinzufügen",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Verzeichnis auswählen",
|
||||
"@selectDirectory": {},
|
||||
"pathReturnSlashErrorMessage": "Pfade die mit \"/\" antworten können nicht genutzt werden",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"customLocationsBuggy": "Benutzerdefinierte Orte sind extrem fehlerbehaftet aufgrund von Problemen mit Berechtigungen. Ich denke über Möglichkeiten nach, um dies zu beheben, aber im Moment würde ich davon abraten, diese zu nutzen.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPause": "Niedrige Priorität-Modus beim Pausieren aktivieren",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"shuffleAllSongCount": "Mische-Alle Titelanzahl",
|
||||
"@shuffleAllSongCount": {},
|
||||
"shuffleAllSongCountSubtitle": "Die Anzahl der zu ladenden Titel wenn der Mische-Alle Knopf betätigt wird.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewType": "Ansichtsart",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Ansichtsart für den Musik-Bildschirm",
|
||||
"@viewTypeSubtitle": {},
|
||||
"portrait": "Portrait",
|
||||
"@portrait": {},
|
||||
"landscape": "Landschaft",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCountSubtitle": "Menge der zu benutzenden Rasterkacheln wenn {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridViewSubtitle": "Ob oder nicht Text (Titel, Künstler etc.) auf dem Raster des Musikbildschirms angezeigt werden soll.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Zeige verschwommene Cover als Player-Hintergrund",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Ob oder nicht verschwommene Cover als Hintergrund im Player-Bildschirm benutzt werden sollen.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Verstecke Titel-Künstler falls derselbe wie Album-Künstler",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"theme": "Thema",
|
||||
"@theme": {},
|
||||
"system": "System",
|
||||
"@system": {},
|
||||
"light": "Hell",
|
||||
"@light": {},
|
||||
"dark": "Dunkel",
|
||||
"@dark": {},
|
||||
"tabs": "Tabs",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Schlaf-Timer abbrechen?",
|
||||
"@cancelSleepTimer": {},
|
||||
"setSleepTimer": "Schlaf-Timer einstellen",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Minuten",
|
||||
"@minutes": {},
|
||||
"downloaded": "HERUNTERGELADEN",
|
||||
"@downloaded": {},
|
||||
"transcode": "TRANSKODIERUNG",
|
||||
"@transcode": {},
|
||||
"addToPlaylistTitle": "Zur Wiedergabeliste hinzufügen",
|
||||
"@addToPlaylistTitle": {},
|
||||
"noAlbum": "Kein Album",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Kein Element",
|
||||
"@noItem": {},
|
||||
"noArtist": "Kein Künstler",
|
||||
"@noArtist": {},
|
||||
"streaming": "STREAMING",
|
||||
"@streaming": {},
|
||||
"statusError": "STATUS FEHLER",
|
||||
"@statusError": {},
|
||||
"queue": "Warteschlange",
|
||||
"@queue": {},
|
||||
"addToQueue": "Zur Warteschlange hinzufügen",
|
||||
"@addToQueue": {},
|
||||
"replaceQueue": "Warteschlange austauschen",
|
||||
"@replaceQueue": {},
|
||||
"instantMix": "Sofort Mix",
|
||||
"@instantMix": {},
|
||||
"goToAlbum": "Gehe zu Album",
|
||||
"@goToAlbum": {},
|
||||
"addFavourite": "Favorit hinzufügen",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "Zur Warteschlange hinzugefügt.",
|
||||
"@addedToQueue": {},
|
||||
"queueReplaced": "Warteschlange ausgetauscht.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "Starte Sofort-Mix.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "Ein Fehler ist aufgetreten.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Status Code {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discNumber": "CD {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"songCount": "{count,plural,=1{{count} Titel} other{{count} Titel}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Wiedergabeliste-Name editieren",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"playlistNameUpdated": "Wiedergabeliste-Name aktualisiert.",
|
||||
"@playlistNameUpdated": {},
|
||||
"logsCopied": "Protokolle kopiert.",
|
||||
"@logsCopied": {},
|
||||
"downloadsAdded": "Downloads hinzugefügt.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "Hinzufügen",
|
||||
"@addButtonLabel": {},
|
||||
"applicationLegalese": "Lizensiert mit der Mozilla Public License 2.0. Quellcode verfügbar unter:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Ermöglicht, dass die Benachrichtigung im pausierten Zustand weggewischt werden kann. Dies erlaubt es Android ebenfalls, den Prozess im pausierten Zustand zu terminieren.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"gridCrossAxisCount": "{value} Raster Querachsen-Anzahl",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Ob Titel-Künstler in der Albenansicht angezeigt werden sollen, falls sie sich nicht von den Album-Künstlern unterscheiden.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"sleepTimerTooltip": "Schlaf-Timer",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Zur Wiedergabeliste hinzufügen",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"responseError401": "{error} Status Code {statusCode}. Dies bedeutet vermutlich, dass du den falschen Benutzernamen oder Passwort benutzt hast oder dass dein Client nicht länger authentifiziert ist.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} Bild} other{{count} Bilder}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} Element} other{{count} Elemente}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiereDate": "Erscheinungsdatum",
|
||||
"@premiereDate": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Keine fehlenden Bilder gefunden} =1{{count} fehlendes Bild heruntergeladen} other{{count} fehlende Bilder heruntergeladen}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorScreenError": "Beim Erstellen der Fehlerliste kam es zu einem Fehler! An dieser Stelle solltest du ein Fehlerticket auf GitHub erstellen und die App-Daten löschen",
|
||||
"@errorScreenError": {},
|
||||
"addToMix": "Zu Mix hinzufügen",
|
||||
"@addToMix": {},
|
||||
"removeFromMix": "Aus Mix entfernen",
|
||||
"@removeFromMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{Keine erneuten Downloads notwendig.} =1{{count} Element erneut heruntergeladen} other{{count} Elemente erneut heruntergeladen}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDuration": "Pufferdauer",
|
||||
"@bufferDuration": {},
|
||||
"disableGesture": "Gesten deaktivieren",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Ob Gesten deaktiviert werden sollen.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"removeFromPlaylistTooltip": "Aus Wiedergabeliste entfernen",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Aus Wiedergabeliste entfernen",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"removedFromPlaylist": "Aus der Wiedergabeliste entfernt.",
|
||||
"@removedFromPlaylist": {},
|
||||
"bufferDurationSubtitle": "Wie viel gepuffert werden soll, in Sekunden. Neustart erforderlich.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"language": "Sprache",
|
||||
"@language": {},
|
||||
"confirm": "Bestätigen",
|
||||
"@confirm": {},
|
||||
"showUncensoredLogMessage": "Dieses Protokoll enthält deine Anmeldedaten. Anzeigen?",
|
||||
"@showUncensoredLogMessage": {},
|
||||
"resetTabs": "Tabs zurücksetzen",
|
||||
"@resetTabs": {},
|
||||
"insertedIntoQueue": "Zur Warteschlange hinzufügen.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"deleteDownloadsPrompt": "Bist du sicher, dass du {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' von diesem Gerät löschen willst?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"deleteDownloadsAbortButtonText": "Abbrechen",
|
||||
"@deleteDownloadsAbortButtonText": {},
|
||||
"deleteDownloadsConfirmButtonText": "Löschen",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"syncDownloadedPlaylists": "Heruntergeladene Playlisten synchronisieren",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"showFastScroller": "Schnellen Scroller anzeigen",
|
||||
"@showFastScroller": {},
|
||||
"swipeInsertQueueNext": "Gewischtes Lied als Nächstes abspielen",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"swipeInsertQueueNextSubtitle": "Aktivieren, um das in der Liste nach links/rechts gewischte Lied als Nächstes abzuspielen, statt es am Ende der Warteschlange einzufügen.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"playNext": "Als Nächstes wiedergeben",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"noMusicLibrariesTitle": "Keine Musik-Bibliotheken",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"refresh": "AKTUALISIEREN",
|
||||
"@refresh": {},
|
||||
"noMusicLibrariesBody": "Finamp konnte keine Musikbibliotheken finden. Bitte stelle sicher, dass dein Jellyfin-Server mindestens eine Bibliothek mit dem Medientyp \"Musik\" enthält.",
|
||||
"@noMusicLibrariesBody": {},
|
||||
"interactions": "Interaktionen",
|
||||
"@interactions": {},
|
||||
"redesignBeta": "Teste die Beta",
|
||||
"@redesignBeta": {},
|
||||
"playbackOrderShuffledTooltip": "Mischen. Zum Umschalten tippen.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"togglePlayback": "Wiedergabe umschalten",
|
||||
"@togglePlayback": {},
|
||||
"playbackOrderLinearTooltip": "Abspielen in Reihenfolge. Zum umschalten tippen.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"loopModeAllTooltip": "Wiederhole alle. Zum umschalten tippen.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Wiederhole einen. Zum umschalten tippen.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"skipToNext": "Springe zum nächsten Lied",
|
||||
"@skipToNext": {},
|
||||
"skipToPrevious": "Springe zum vorherigen Lied",
|
||||
"@skipToPrevious": {},
|
||||
"playArtist": "Spiele alle Alben von {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Spiele alle Alben von {artist} in zufälliger Reihenfolge",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"download": "Herunterladen",
|
||||
"@download": {},
|
||||
"about": "Über Finamp",
|
||||
"@about": {},
|
||||
"downloadArtist": "Lade alle Alben von {artist} herunter",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loopModeNoneTooltip": "Wiederholt nicht. Zum umschalten tippen.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"deleteFromDevice": "Vom Gerät Löschen",
|
||||
"@deleteFromDevice": {},
|
||||
"sync": "Mit Server synchronisieren",
|
||||
"@sync": {}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"startupError": "Ωχ, κάτι δεν πήγε καλά κατά την έναρξη της εφαρμογής. Ο αριθμός σφάλματος είναι:{error}\nΔημιούργησε ένα issue στο\ngithub.com/UnicornsOnLSD/finamp μαζί με ένα στιγμιότυπο οθόνης από αυτήν την σελίδα. Εάν το πρόβλημα παραμένει μπορείς να διαγράψεις τα δεδομένα της εφαρμογής με σκοπό την επαναφορά της.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internalExternalIpExplanation": "Αν θέλεις να έχεις πρόσβαση στον jellyfin διακομιστή σου εξ-αποστασεως, πρέπει να χρησιμοποιήσεις την εξωτερική Διεύθυνση IP.\n\nΑν ο διακομιστής βρίσκεται σε πύλη HTTP(80/443), δεν είναι απαραίτητος ο ορισμός πύλης. Αυτό είναι πιθανόν εφόσον ο διακομιστής σου βρίσκεται πίσω από reverse proxy.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"artists": "Καλλιτέχνες",
|
||||
"@artists": {},
|
||||
"next": "Επόμενο",
|
||||
"@next": {},
|
||||
"emptyServerUrl": "Η διεύθυνση διακομιστή δεν μπορεί να είναι κενή",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"songs": "Τραγούδια",
|
||||
"@songs": {},
|
||||
"urlStartWithHttps": "Η διεύθυνση πρέπει να ξεκινά με http:// ή https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"serverUrl": "Διεύθυνση διακομιστή",
|
||||
"@serverUrl": {},
|
||||
"downloads": "Λήψεις",
|
||||
"@downloads": {},
|
||||
"selectMusicLibraries": "Επέλεξε τις βιβλιοθήκες μουσικής σου",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"albums": "Άλμπουμ",
|
||||
"@albums": {},
|
||||
"urlTrailingSlash": "Η διεύθυνση δεν πρέπει να περιέχει ακολουθούμενη κάθετο",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Όνομα χρήστη",
|
||||
"@username": {},
|
||||
"password": "Συνθηματικό χρήστη",
|
||||
"@password": {},
|
||||
"logs": "Αρχείο καταγραφών",
|
||||
"@logs": {},
|
||||
"couldNotFindLibraries": "Δεν βρέθηκαν συλλογές.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"unknownName": "Άγνωστη όνομα",
|
||||
"@unknownName": {},
|
||||
"genres": "Είδη",
|
||||
"@genres": {},
|
||||
"startMixNoSongsArtist": "Κράτησε πατημένο έναν καλλιτέχνη, προκειμένου να προστεθεί ή να αφαιρεθεί από τον κατασκευαστή μίξεων, προ-εκκινήσεως μίξης",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"playlists": "Λίστες αναπαραγωγής",
|
||||
"@playlists": {},
|
||||
"startMix": "Έναρξη μίξης",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsAlbum": "Κράτησε πατημένο ένα άλμπουμ, προκειμένου να προστεθεί ή να αφαιρεθεί από τον κατασκευαστή μίξεων, προ-εκκινήσεως μίξης",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"music": "Μουσική",
|
||||
"@music": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"communityRating": "Βαθμολογία κοινότητας",
|
||||
"@communityRating": {},
|
||||
"dlFailed": "{count} απέτυχαν",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clear": "Εκκαθάριση",
|
||||
"@clear": {},
|
||||
"favourites": "Αγαπημένα",
|
||||
"@favourites": {},
|
||||
"shuffleAll": "Τυχαία αναπαραγωγή",
|
||||
"@shuffleAll": {},
|
||||
"sortOrder": "Σειρά ταξινόμησης",
|
||||
"@sortOrder": {},
|
||||
"settings": "Ρυθμίσεις",
|
||||
"@settings": {},
|
||||
"offlineMode": "Λειτουργία εκτός σύνδεσης",
|
||||
"@offlineMode": {},
|
||||
"album": "Άλμπουμ",
|
||||
"@album": {},
|
||||
"budget": "Προϋπολογισμός",
|
||||
"@budget": {},
|
||||
"sortBy": "Ταξινόμηση κατά",
|
||||
"@sortBy": {},
|
||||
"criticRating": "Βαθμολογία κριτών",
|
||||
"@criticRating": {},
|
||||
"albumArtist": "Καλλιτέχνης άλμπουμ",
|
||||
"@albumArtist": {},
|
||||
"artist": "Καλλιτέχνης",
|
||||
"@artist": {},
|
||||
"dateAdded": "Ημερομηνία προσθήκης",
|
||||
"@dateAdded": {},
|
||||
"playCount": "Πλήθος αναπαραγωγών",
|
||||
"@playCount": {},
|
||||
"datePlayed": "Ημερομηνία αναπαραγωγής",
|
||||
"@datePlayed": {},
|
||||
"random": "Τυχαίο",
|
||||
"@random": {},
|
||||
"premiereDate": "Ημερομηνία πρεμιέρας",
|
||||
"@premiereDate": {},
|
||||
"productionYear": "Χρονολογία παραγωγής",
|
||||
"@productionYear": {},
|
||||
"revenue": "Έσοδα",
|
||||
"@revenue": {},
|
||||
"downloadMissingImages": "Λήψη ελλείποντων εικόνων",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} εικόνα} other{{count} εικόνες}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": "Όνομα",
|
||||
"@name": {},
|
||||
"downloadCount": "{count,plural, =1{{count} λήψη} other{{count} λήψεις}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} ολοκληρώθηκαν",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failedToGetSongFromDownloadId": "Απέτυχε η λήψη τραγουδιού από το download ID",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"playlistNameUpdated": "Ανανεώθηκε το όνομα λίστας αναπαραγωγής.",
|
||||
"@playlistNameUpdated": {},
|
||||
"runtime": "Διάρκεια",
|
||||
"@runtime": {},
|
||||
"noErrors": "Κανένα σφάλμα!",
|
||||
"@noErrors": {},
|
||||
"stackTrace": "Stack Trace",
|
||||
"@stackTrace": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{δεν βρέθηκαν ελλειπείς εικόνες} =1{ελήφθησαν {count} ελλειπής εικόνες} other{ελήφθησαν {count} ελλειπής εικόνες}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "Σφάλμα στην λήψη",
|
||||
"@downloadErrors": {},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} αντικείμενο} other{{count} αντικείμενα}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} στην σειρά",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} τρέχοντα",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Σφάλματα λήψης",
|
||||
"@downloadErrorsTitle": {},
|
||||
"error": "Σφάλμα",
|
||||
"@error": {},
|
||||
"shuffleButtonLabel": "Τυχαία αναπαραγωγή",
|
||||
"@shuffleButtonLabel": {},
|
||||
"errorScreenError": "Προέκυψε σφάλμα κατά την λήψη λίστας σφαλμάτων! Σε αυτό το σημείο, καλό είναι να δημιουργήσεις issue στο GitHub και να διαγράψεις τα δεδομένα της εφαρμογής",
|
||||
"@errorScreenError": {},
|
||||
"discNumber": "Δίσκος {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "Αναπαραγωγή",
|
||||
"@playButtonLabel": {},
|
||||
"addDownloads": "Προσθήκη λήψεων",
|
||||
"@addDownloads": {},
|
||||
"logsCopied": "Αντιγράφηκαν οι καταγραφές.",
|
||||
"@logsCopied": {},
|
||||
"message": "Μύνημα",
|
||||
"@message": {},
|
||||
"required": "Απαραίτητο",
|
||||
"@required": {},
|
||||
"favourite": "Αγαπημένο",
|
||||
"@favourite": {},
|
||||
"downloadsAdded": "Προστέθηκαν λήψεις.",
|
||||
"@downloadsAdded": {},
|
||||
"audioService": "Audio Service",
|
||||
"@audioService": {},
|
||||
"songCount": "{count,plural,=1{{count} τίτλος} other{{count} τίτλοι}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Τροποποίηση λίστας αναπαραγωγής",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"downloadsDeleted": "Διαγράφτηκαν οι λήψεις.",
|
||||
"@downloadsDeleted": {},
|
||||
"editPlaylistNameTitle": "Τροποποίηση ονόματος λίστας",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"updateButtonLabel": "Ανανέωση",
|
||||
"@updateButtonLabel": {},
|
||||
"transcoding": "Μετακωδικοποίηση",
|
||||
"@transcoding": {},
|
||||
"notAvailableInOfflineMode": "Μη διαθέσιμο σε λειτουργία εκτός σύνδεσης",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Έξοδος",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Κατεβασμένα τραγούδια δεν θα διαγραφούν",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"areYouSure": "Είσαι σίγουρος?",
|
||||
"@areYouSure": {},
|
||||
"location": "Τοποθεσία",
|
||||
"@location": {},
|
||||
"addButtonLabel": "Προσθήκη",
|
||||
"@addButtonLabel": {},
|
||||
"layoutAndTheme": "Διάταξη καί εμφάνιση",
|
||||
"@layoutAndTheme": {},
|
||||
"enableTranscodingSubtitle": "μετακωδικοποιει την ροή μουσικής από την πλευρά του διακομιστή.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"shareLogs": "Μοιραστείτε τις καταγραφές",
|
||||
"@shareLogs": {},
|
||||
"applicationLegalese": "Αδειοδοτηθηκε με την Άδεια Δημόσιας Χρήσης της Mozilla Public License2.0. Ο κώδικας πηγής διαθέσιμος στο:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"downloadLocations": "Τοποθεσίες λήψεων",
|
||||
"@downloadLocations": {},
|
||||
"jellyfinUsesAACForTranscoding": "Το Jellyfin χρησιμοποιεί AAC για την μετακωδικοποίηση",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Ενεργοποίηση μετακωδικοποίησης",
|
||||
"@enableTranscoding": {},
|
||||
"bitrate": "Bitrate",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "Ο ψηλότερος bitrate προσφέρει υψηλότερης ποιότητας ήχο σε βάρος εύρους ζώνης.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Εξατομικευμένη τοποθεσία",
|
||||
"@customLocation": {},
|
||||
"customLocationsBuggy": "Οι εξατομικευμένες τοποθεσίες έχουν αρκετά bugs λόγω θεμάτων αδειών.\nΣκέφτομαι τρόπους να φτιάξω τα προβλήμματα, όμως για τώρα δεν προτείνω την χρήση τους.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPause": "Ενεργοποίηση χαμηλής-προτεραιοτητας κατάσταση κατά την παύση",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"landscape": "Οριζόντιο",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCount": "{value} πλήθος τεμνοντων αξόνων πλέγματος",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appDirectory": "Κατάλογος εφαρμογής",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Προσθήκη τοποθεσίας λήψεως",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Επέλεξε κατάλογο",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Άγνωστο σφάλμα",
|
||||
"@unknownError": {},
|
||||
"directoryMustBeEmpty": "Ο κατάλογος πρέπει να είναι άδειος",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Επιτρέπει την απόσβεση της ειδοποίησης κατά την παύση. Επίσης επιτρέπει στο σύστημα να \"σκοτώνει\" την υπηρεσία κατά την παύση.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"shuffleAllSongCountSubtitle": "Πλήθος τραγουδιών να φορτώσουν όταν χρησιμοποιείται το κουμπί τυχαίας αναπαραγωγής όλων.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewTypeSubtitle": "Τρόπος προβολής για την οθόνη μουσικής",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "Λίστα",
|
||||
"@list": {},
|
||||
"pathReturnSlashErrorMessage": "Μονοπάτια που επιστρέφουν \"/\" δεν μπορούν να χρησιμοποιηθούν",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"shuffleAllSongCount": "Τυχαία αναπαραγωγή όλων των Πλήθων τραγουδιών",
|
||||
"@shuffleAllSongCount": {},
|
||||
"viewType": "Τύπος προβολής",
|
||||
"@viewType": {},
|
||||
"grid": "Πλέγμα",
|
||||
"@grid": {},
|
||||
"portrait": "Κάθετο",
|
||||
"@portrait": {},
|
||||
"showTextOnGridView": "Εμφάνιση αναγραφων στην προβολή πλέγματος",
|
||||
"@showTextOnGridView": {},
|
||||
"gridCrossAxisCountSubtitle": "Πλήθος τετραγώνων πλέγματος ανά Πλειάδες{value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"theme": "Θέμα",
|
||||
"@theme": {},
|
||||
"system": "Σύστημα",
|
||||
"@system": {},
|
||||
"light": "Φωτεινό",
|
||||
"@light": {},
|
||||
"dark": "Σκοτεινό",
|
||||
"@dark": {},
|
||||
"tabs": "Καρτέλες",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Ακύρωση χρονοδιακόπτη ύπνου;",
|
||||
"@cancelSleepTimer": {},
|
||||
"setSleepTimer": "Ρύθμιση Χρονοδιακόπτη Ύπνου",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Λεπτά",
|
||||
"@minutes": {},
|
||||
"sleepTimerTooltip": "Χρονοδιακόπτης Ύπνου",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Προσθήκη στην λίστα αναπαραγωγής",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"newPlaylist": "Νέα Λίστα Αναπαραγωγής",
|
||||
"@newPlaylist": {},
|
||||
"addToPlaylistTitle": "Προσθήκη στην Λίστα Αναπαραγωγής",
|
||||
"@addToPlaylistTitle": {},
|
||||
"playlistCreated": "Η Λίστα Αναπαραγωγής δημιουργήθηκε.",
|
||||
"@playlistCreated": {},
|
||||
"removeFromPlaylistTooltip": "Αφαίρεση από την λίστα αναπαραγωγής",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Αφαίρεση από την Λίστα Αναπαραγωγής",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"createButtonLabel": "ΔΗΜΙΟΥΡΓΙΑ",
|
||||
"@createButtonLabel": {},
|
||||
"invalidNumber": "Μη Έγκυρος Αριθμός",
|
||||
"@invalidNumber": {},
|
||||
"yesButtonLabel": "ΝΑΙ",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "ΟΧΙ",
|
||||
"@noButtonLabel": {}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
{
|
||||
"startupError": "Something went wrong during app startup. The error was: {error}\n\nPlease create an issue on github.com/UnicornsOnLSD/finamp with a screenshot of this page. If this problem persists you can clear your app data to reset the app.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverUrl": "Server URL",
|
||||
"@serverUrl": {},
|
||||
"internalExternalIpExplanation": "If you want to be able to access your Jellyfin server remotely, you need to use your external IP.\n\nIf your server is on a HTTP port (80/443), you don't have to specify a port. This will likely be the case if your server is behind a reverse proxy.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"emptyServerUrl": "Server URL cannot be empty",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL must start with http:// or https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL must not include a trailing slash",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Username",
|
||||
"@username": {},
|
||||
"password": "Password",
|
||||
"@password": {},
|
||||
"logs": "Logs",
|
||||
"@logs": {},
|
||||
"next": "Next",
|
||||
"@next": {},
|
||||
"selectMusicLibraries": "Select Music Libraries",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"couldNotFindLibraries": "Could not find any libraries.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"unknownName": "Unknown Name",
|
||||
"@unknownName": {},
|
||||
"songs": "Songs",
|
||||
"@songs": {},
|
||||
"albums": "Albums",
|
||||
"@albums": {},
|
||||
"artists": "Artists",
|
||||
"@artists": {},
|
||||
"genres": "Genres",
|
||||
"@genres": {},
|
||||
"playlists": "Playlists",
|
||||
"@playlists": {},
|
||||
"startMix": "Start Mix",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsArtist": "Long-press an artist to add or remove it from the mix builder before starting a mix",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Long-press an album to add or remove it from the mix builder before starting a mix",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"music": "Music",
|
||||
"@music": {},
|
||||
"clear": "Clear",
|
||||
"@clear": {},
|
||||
"favourites": "Favourites",
|
||||
"@favourites": {},
|
||||
"shuffleAll": "Shuffle all",
|
||||
"@shuffleAll": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"downloads": "Downloads",
|
||||
"@downloads": {},
|
||||
"settings": "Settings",
|
||||
"@settings": {},
|
||||
"offlineMode": "Offline Mode",
|
||||
"@offlineMode": {},
|
||||
"sortOrder": "Sort order",
|
||||
"@sortOrder": {},
|
||||
"sortBy": "Sort by",
|
||||
"@sortBy": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"albumArtist": "Album Artist",
|
||||
"@albumArtist": {},
|
||||
"artist": "Artist",
|
||||
"@artist": {},
|
||||
"budget": "Budget",
|
||||
"@budget": {},
|
||||
"communityRating": "Community Rating",
|
||||
"@communityRating": {},
|
||||
"criticRating": "Critic Rating",
|
||||
"@criticRating": {},
|
||||
"dateAdded": "Date Added",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Date Played",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Play Count",
|
||||
"@playCount": {},
|
||||
"premiereDate": "Premiere Date",
|
||||
"@premiereDate": {},
|
||||
"productionYear": "Production Year",
|
||||
"@productionYear": {},
|
||||
"name": "Name",
|
||||
"@name": {},
|
||||
"random": "Random",
|
||||
"@random": {},
|
||||
"revenue": "Revenue",
|
||||
"@revenue": {},
|
||||
"runtime": "Runtime",
|
||||
"@runtime": {},
|
||||
"syncDownloadedPlaylists": "Sync downloaded playlists",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"downloadMissingImages": "Download missing images",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{No missing images found} =1{Downloaded {count} missing image} other{Downloaded {count} missing images}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "Download errors",
|
||||
"@downloadErrors": {},
|
||||
"downloadCount": "{count,plural, =1{{count} download} other{{count} downloads}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} item} other{{count} items}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} image} other{{count} images}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} complete",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} failed",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} enqueued",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} running",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Download Errors",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "No errors!",
|
||||
"@noErrors": {},
|
||||
"errorScreenError": "An error occurred while getting the list of errors! At this point, you should probably just create an issue on GitHub and delete app data",
|
||||
"@errorScreenError": {},
|
||||
"failedToGetSongFromDownloadId": "Failed to get song from download ID",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"deleteDownloadsPrompt": "Are you sure you want to delete the {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' from this device?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"deleteDownloadsConfirmButtonText": "Delete",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"deleteDownloadsAbortButtonText": "Cancel",
|
||||
"error": "Error",
|
||||
"@error": {},
|
||||
"discNumber": "Disc {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "PLAY",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "SHUFFLE",
|
||||
"@shuffleButtonLabel": {},
|
||||
"songCount": "{count,plural,=1{{count} Song} other{{count} Songs}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Edit playlist name",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"editPlaylistNameTitle": "Edit Playlist Name",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"required": "Required",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "UPDATE",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Playlist name updated.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Favourite",
|
||||
"@favourite": {},
|
||||
"downloadsDeleted": "Downloads deleted.",
|
||||
"@downloadsDeleted": {},
|
||||
"addDownloads": "Add Downloads",
|
||||
"@addDownloads": {},
|
||||
"location": "Location",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Downloads added.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "ADD",
|
||||
"@addButtonLabel": {},
|
||||
"shareLogs": "Share logs",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Logs copied.",
|
||||
"@logsCopied": {},
|
||||
"message": "Message",
|
||||
"@message": {},
|
||||
"stackTrace": "Stack Trace",
|
||||
"@stackTrace": {},
|
||||
"applicationLegalese": "Licensed with the Mozilla Public License 2.0. Source code available at:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"transcoding": "Transcoding",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Download Locations",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Audio Service",
|
||||
"@audioService": {},
|
||||
"interactions": "Interactions",
|
||||
"@interactions": {},
|
||||
"layoutAndTheme": "Layout & Theme",
|
||||
"@layoutAndTheme": {},
|
||||
"notAvailableInOfflineMode": "Not available in offline mode",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Log Out",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Downloaded songs will not be deleted",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"areYouSure": "Are you sure?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin uses AAC for transcoding",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Enable Transcoding",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Transcodes music streams on the server side.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"bitrate": "Bitrate",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "A higher bitrate gives higher quality audio at the cost of higher bandwidth.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Custom Location",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "App Directory",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Add Download Location",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Select Directory",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Unknown Error",
|
||||
"@unknownError": {},
|
||||
"pathReturnSlashErrorMessage": "Paths that return \"/\" can't be used",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"directoryMustBeEmpty": "Directory must be empty",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"customLocationsBuggy": "Custom locations are extremely buggy due to issues with permissions. I'm thinking of ways to fix this, but for now I wouldn't recommend using them.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPause": "Enter Low-Priority State on Pause",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Lets the notification be swiped away when paused. Also allows Android to kill the service when paused.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"shuffleAllSongCount": "Shuffle All Song Count",
|
||||
"@shuffleAllSongCount": {},
|
||||
"shuffleAllSongCountSubtitle": "Amount of songs to load when using the shuffle all songs button.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewType": "View Type",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "View type for the music screen",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "List",
|
||||
"@list": {},
|
||||
"grid": "Grid",
|
||||
"@grid": {},
|
||||
"portrait": "Portrait",
|
||||
"@portrait": {},
|
||||
"landscape": "Landscape",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCount": "{value} Grid Cross-Axis Count",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridCrossAxisCountSubtitle": "Amount of grid tiles to use per-row when {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Show text in grid view",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Whether or not to show the text (title, artist etc) on the grid music screen.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Show blurred cover as player background",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Whether or not to use blurred cover art as background on player screen.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Hide song artists if same as album artists",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Whether to show song artists on the album screen if not differing from album artists.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"disableGesture": "Disable gestures",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Whether to disables gestures.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"showFastScroller": "Show fast scroller",
|
||||
"@showFastScroller": {},
|
||||
"theme": "Theme",
|
||||
"@theme": {},
|
||||
"system": "System",
|
||||
"@system": {},
|
||||
"light": "Light",
|
||||
"@light": {},
|
||||
"dark": "Dark",
|
||||
"@dark": {},
|
||||
"tabs": "Tabs",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Cancel Sleep Timer?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "YES",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "NO",
|
||||
"@noButtonLabel": {},
|
||||
"setSleepTimer": "Set Sleep Timer",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Minutes",
|
||||
"@minutes": {},
|
||||
"invalidNumber": "Invalid Number",
|
||||
"@invalidNumber": {},
|
||||
"sleepTimerTooltip": "Sleep timer",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Add to playlist",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Add to Playlist",
|
||||
"@addToPlaylistTitle": {},
|
||||
"removeFromPlaylistTooltip": "Remove from playlist",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Remove from Playlist",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"newPlaylist": "New Playlist",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "CREATE",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Playlist created.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "No Album",
|
||||
"@noAlbum": {},
|
||||
"noItem": "No Item",
|
||||
"@noItem": {},
|
||||
"noArtist": "No Artist",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Unknown Artist",
|
||||
"@unknownArtist": {},
|
||||
"streaming": "STREAMING",
|
||||
"@streaming": {},
|
||||
"downloaded": "DOWNLOADED",
|
||||
"@downloaded": {},
|
||||
"transcode": "TRANSCODE",
|
||||
"@transcode": {},
|
||||
"direct": "DIRECT",
|
||||
"@direct": {},
|
||||
"statusError": "STATUS ERROR",
|
||||
"@statusError": {},
|
||||
"queue": "Queue",
|
||||
"@queue": {},
|
||||
"addToQueue": "Add to Queue",
|
||||
"@addToQueue": {
|
||||
"description": "Popup menu item title for adding an item to the end of the play queue."
|
||||
},
|
||||
"playNext": "Play Next",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"replaceQueue": "Replace Queue",
|
||||
"@replaceQueue": {},
|
||||
"instantMix": "Instant Mix",
|
||||
"@instantMix": {},
|
||||
"goToAlbum": "Go to Album",
|
||||
"@goToAlbum": {},
|
||||
"removeFavourite": "Remove Favourite",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Add Favourite",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "Added to queue.",
|
||||
"@addedToQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully adds items to the end of the play queue."
|
||||
},
|
||||
"insertedIntoQueue": "Inserted into queue.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"queueReplaced": "Queue replaced.",
|
||||
"@queueReplaced": {},
|
||||
"removedFromPlaylist": "Removed from playlist.",
|
||||
"@removedFromPlaylist": {},
|
||||
"startingInstantMix": "Starting instant mix.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "An error has occured.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Status code {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError401": "{error} Status code {statusCode}. This probably means you used the wrong username/password, or your client is no longer logged in.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Remove From Mix",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Add To Mix",
|
||||
"@addToMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{No redownloads needed.} =1{Redownloaded {count} item} other{Redownloaded {count} items}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDuration": "Buffer Duration",
|
||||
"@bufferDuration": {},
|
||||
"bufferDurationSubtitle": "How much the player should buffer, in seconds. Requires a restart.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"language": "Language",
|
||||
"confirm": "Confirm",
|
||||
"showUncensoredLogMessage": "This log contains your login information. Show?",
|
||||
"resetTabs": "Reset tabs",
|
||||
"noMusicLibrariesTitle": "No Music Libraries",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"noMusicLibrariesBody": "Finamp could not find any music libraries. Please ensure that your Jellyfin server contains at least one library with the content type set to \"Music\".",
|
||||
"refresh": "REFRESH",
|
||||
"swipeInsertQueueNext": "Play Swiped Song Next",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"swipeInsertQueueNextSubtitle": "Enable to insert a song as next item in queue when swiped in song list instead of appending it to the end.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"redesignBeta": "Try the Beta",
|
||||
"@redesignBeta": {},
|
||||
"playbackOrderShuffledTooltip": "Shuffling. Tap to toggle.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Playing in order. Tap to toggle.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"loopModeAllTooltip": "Looping all. Tap to toggle.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Looping one. Tap to toggle.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"loopModeNoneTooltip": "Not looping. Tap to toggle.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"skipToPrevious": "Skip to Previous Song",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Skip to Next Song",
|
||||
"@skipToNext": {},
|
||||
"togglePlayback": "Toggle Playback",
|
||||
"@togglePlayback": {},
|
||||
"playArtist": "Play all albums by {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Shuffle all albums by {artist}",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadArtist": "Download all albums by {artist}",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFromDevice": "Delete from Device",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Download",
|
||||
"@download": {},
|
||||
"sync": "Synchronize with Server",
|
||||
"@sync": {},
|
||||
"about": "About Finamp",
|
||||
"@about": {}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
{
|
||||
"addDownloads": "Añadir descargas",
|
||||
"@addDownloads": {},
|
||||
"artists": "Artistas",
|
||||
"@artists": {},
|
||||
"album": "Álbum",
|
||||
"@album": {},
|
||||
"albumArtist": "Artista del álbum",
|
||||
"@albumArtist": {},
|
||||
"artist": "Artista",
|
||||
"@artist": {},
|
||||
"addButtonLabel": "AÑADIR",
|
||||
"@addButtonLabel": {},
|
||||
"areYouSure": "¿Estás seguro?",
|
||||
"@areYouSure": {},
|
||||
"addDownloadLocation": "Añadir ubicación de descarga",
|
||||
"@addDownloadLocation": {},
|
||||
"appDirectory": "Directorio de la aplicación",
|
||||
"@appDirectory": {},
|
||||
"addToPlaylistTitle": "Agregar a lista de reproducción",
|
||||
"@addToPlaylistTitle": {},
|
||||
"addedToQueue": "Añadido a la cola.",
|
||||
"@addedToQueue": {},
|
||||
"addToQueue": "Añadir a la cola",
|
||||
"@addToQueue": {},
|
||||
"responseError": "{error} Código de estado {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"anErrorHasOccured": "Ha ocurrido un error.",
|
||||
"@anErrorHasOccured": {},
|
||||
"addFavourite": "Añadir favorito",
|
||||
"@addFavourite": {},
|
||||
"addToPlaylistTooltip": "Añadir a lista de reproducción",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"albums": "Álbumes",
|
||||
"@albums": {},
|
||||
"songCount": "{count,plural,=1{{count} Canción} other{{count} Canciones}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"directoryMustBeEmpty": "El directorio debe estar vacío",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"couldNotFindLibraries": "No se pudo encontrar ninguna biblioteca.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"budget": "Presupuesto",
|
||||
"@budget": {},
|
||||
"communityRating": "Valoración de la comunidad",
|
||||
"@communityRating": {},
|
||||
"dateAdded": "Fecha de añadido",
|
||||
"@dateAdded": {},
|
||||
"discNumber": "Disco {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bitrate": "Tasa de bits",
|
||||
"@bitrate": {},
|
||||
"customLocation": "Ubicación personalizada",
|
||||
"@customLocation": {},
|
||||
"dark": "Oscuro",
|
||||
"@dark": {},
|
||||
"createButtonLabel": "CREAR",
|
||||
"@createButtonLabel": {},
|
||||
"direct": "DIRECTO",
|
||||
"@direct": {},
|
||||
"cancelSleepTimer": "¿Cancelar el temporizador de sueño?",
|
||||
"@cancelSleepTimer": {},
|
||||
"downloaded": "DESCARGADO",
|
||||
"@downloaded": {},
|
||||
"dlEnqueued": "{count} en cola",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} elemento} other{{count} elementos}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedSongsWillNotBeDeleted": "Las canciones descargadas no serán eliminadas",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"downloads": "Descargas",
|
||||
"@downloads": {},
|
||||
"goToAlbum": "Ir al álbum",
|
||||
"@goToAlbum": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"landscape": "Horizontal",
|
||||
"@landscape": {},
|
||||
"noAlbum": "Sin álbum",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Ningún elemento",
|
||||
"@noItem": {},
|
||||
"notAvailableInOfflineMode": "No está disponible en el modo sin conexión",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"playlistCreated": "Se creó la lista.",
|
||||
"@playlistCreated": {},
|
||||
"logs": "Registros",
|
||||
"@logs": {},
|
||||
"password": "Contraseña",
|
||||
"@password": {},
|
||||
"next": "Siguiente",
|
||||
"@next": {},
|
||||
"favourites": "Favoritos",
|
||||
"@favourites": {},
|
||||
"genres": "Géneros",
|
||||
"@genres": {},
|
||||
"downloadErrors": "Errores en la descarga",
|
||||
"@downloadErrors": {},
|
||||
"downloadMissingImages": "Descargar las imágenes que faltan",
|
||||
"@downloadMissingImages": {},
|
||||
"dlComplete": "{count} completadas",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Editar el nombre de la lista de reproducción",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"playlistNameUpdated": "Se actualizó el nombre de la lista.",
|
||||
"@playlistNameUpdated": {},
|
||||
"downloadsAdded": "Se añadieron las descargas.",
|
||||
"@downloadsAdded": {},
|
||||
"applicationLegalese": "Licenciado con la Mozilla Public License 2.0. El código fuente disponible en:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"logsCopied": "Se copiaron los registros.",
|
||||
"@logsCopied": {},
|
||||
"message": "Mensaje",
|
||||
"@message": {},
|
||||
"downloadLocations": "Ubicaciones de descarga",
|
||||
"@downloadLocations": {},
|
||||
"enableTranscoding": "Habilitar transcodificación",
|
||||
"@enableTranscoding": {},
|
||||
"layoutAndTheme": "Diseño y tema",
|
||||
"@layoutAndTheme": {},
|
||||
"light": "Claro",
|
||||
"@light": {},
|
||||
"invalidNumber": "Número inválido",
|
||||
"@invalidNumber": {},
|
||||
"instantMix": "Mezcla instantánea",
|
||||
"@instantMix": {},
|
||||
"queue": "Cola",
|
||||
"@queue": {},
|
||||
"replaceQueue": "Reemplazar la cola",
|
||||
"@replaceQueue": {},
|
||||
"downloadErrorsTitle": "Error al descargar",
|
||||
"@downloadErrorsTitle": {},
|
||||
"downloadsDeleted": "Se borraron las descargas.",
|
||||
"@downloadsDeleted": {},
|
||||
"editPlaylistNameTitle": "Modificar el nombre de la lista de reproducción",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"error": "Error",
|
||||
"@error": {},
|
||||
"favourite": "Favorito",
|
||||
"@favourite": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin usa AAC para transcodificar",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"list": "Lista",
|
||||
"@list": {},
|
||||
"location": "Ubicación",
|
||||
"@location": {},
|
||||
"name": "Nombre",
|
||||
"@name": {},
|
||||
"logOut": "Cerrar sesión",
|
||||
"@logOut": {},
|
||||
"music": "Música",
|
||||
"@music": {},
|
||||
"newPlaylist": "Nueva lista",
|
||||
"@newPlaylist": {},
|
||||
"noArtist": "Sin artista",
|
||||
"@noArtist": {},
|
||||
"noButtonLabel": "NO",
|
||||
"@noButtonLabel": {},
|
||||
"portrait": "Vertical",
|
||||
"@portrait": {},
|
||||
"queueReplaced": "Se reemplazó la cola.",
|
||||
"@queueReplaced": {},
|
||||
"noErrors": "¡Sin errores!",
|
||||
"@noErrors": {},
|
||||
"playButtonLabel": "REPRODUCIR",
|
||||
"@playButtonLabel": {},
|
||||
"removeFavourite": "Eliminar favorito",
|
||||
"@removeFavourite": {},
|
||||
"yesButtonLabel": "SÍ",
|
||||
"@yesButtonLabel": {},
|
||||
"offlineMode": "Modo sin conexión",
|
||||
"@offlineMode": {},
|
||||
"playlists": "Listas",
|
||||
"@playlists": {},
|
||||
"productionYear": "Año de producción",
|
||||
"@productionYear": {},
|
||||
"random": "Aleatorio",
|
||||
"@random": {},
|
||||
"clear": "Limpiar",
|
||||
"@clear": {},
|
||||
"urlStartWithHttps": "La URL debe empezar por http:// o https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"serverUrl": "URL del servidor",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "La URL del servidor no puede estar vacia",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlTrailingSlash": "La URL no debe incluir una barra al final",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Nombre de usuario",
|
||||
"@username": {},
|
||||
"songs": "Canciones",
|
||||
"@songs": {},
|
||||
"unknownName": "Nombre desconocido",
|
||||
"@unknownName": {},
|
||||
"selectMusicLibraries": "Seleccionar las bibliotecas de música",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"startMix": "Empezar mezcla",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsAlbum": "Mantén pulsado un álbum para añadirlo o eliminarlo del creador de mezclas antes de empezar una mezcla",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"startMixNoSongsArtist": "Mantén pulsado un artista para añadirlo o eliminarlo del creador de mezclas antes de empezar una mezcla",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"criticRating": "Valoración de los críticos",
|
||||
"@criticRating": {},
|
||||
"sortBy": "Ordenar por",
|
||||
"@sortBy": {},
|
||||
"sortOrder": "Ordenar",
|
||||
"@sortOrder": {},
|
||||
"datePlayed": "Fecha de reproducción",
|
||||
"@datePlayed": {},
|
||||
"settings": "Ajustes",
|
||||
"@settings": {},
|
||||
"playCount": "Reproducciones",
|
||||
"@playCount": {},
|
||||
"dlFailed": "{count} han fallado",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} imagen} other{{count} imágenes}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} en progreso",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"failedToGetSongFromDownloadId": "No se ha podido obtener la canción desde el identificador de descarga",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"required": "Requerido",
|
||||
"@required": {},
|
||||
"shuffleButtonLabel": "ALEATORIO",
|
||||
"@shuffleButtonLabel": {},
|
||||
"updateButtonLabel": "ACTUALIZAR",
|
||||
"@updateButtonLabel": {},
|
||||
"stackTrace": "Trazado de pila",
|
||||
"@stackTrace": {},
|
||||
"shareLogs": "Compartir registros",
|
||||
"@shareLogs": {},
|
||||
"enableTranscodingSubtitle": "Transcodifica los streams de música en el servidor.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"audioService": "Servicio de audio",
|
||||
"@audioService": {},
|
||||
"transcoding": "Transcodificar",
|
||||
"@transcoding": {},
|
||||
"bitrateSubtitle": "Una tasa de bits más alta da una mayor calidad de audio a costa de un mayor ancho de banda.",
|
||||
"@bitrateSubtitle": {},
|
||||
"selectDirectory": "Seleccionar directorio",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Error desconocido",
|
||||
"@unknownError": {},
|
||||
"enterLowPriorityStateOnPause": "Entrar en estado de baja prioridad al pausar",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"pathReturnSlashErrorMessage": "No se pueden utilizar ubicaciones que contengan \"/\"",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"grid": "Cuadrícula",
|
||||
"@grid": {},
|
||||
"gridCrossAxisCount": "Columnas en vista de cuadrícula en {value}",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewType": "Tipo de vista",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Tipo de vista para la pantalla de música",
|
||||
"@viewTypeSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Mostrar la carátula con desenfoque como fondo del reproductor",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"tabs": "Pestañas",
|
||||
"@tabs": {},
|
||||
"theme": "Tema",
|
||||
"@theme": {},
|
||||
"system": "Sistema",
|
||||
"@system": {},
|
||||
"setSleepTimer": "Ajustar temporizador de sueño",
|
||||
"@setSleepTimer": {},
|
||||
"sleepTimerTooltip": "Temporizador de sueño",
|
||||
"@sleepTimerTooltip": {},
|
||||
"unknownArtist": "Artista desconocido",
|
||||
"@unknownArtist": {},
|
||||
"transcode": "TRANSCODIFICADO",
|
||||
"@transcode": {},
|
||||
"streaming": "TRANSMITIENDO",
|
||||
"@streaming": {},
|
||||
"startingInstantMix": "Empezando mezcla instantánea.",
|
||||
"@startingInstantMix": {},
|
||||
"downloadCount": "{count,plural, =1{{count} descarga} other{{count} descargas}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internalExternalIpExplanation": "Si quieres acceder a tu servidor Jellyfin remotamente, tendrás que usar tu dirección IP externa.\n\nSi tu servidor está en un puerto HTTP (80/443), no tendrás que especificar el puerto. Este probablemente sea tu caso si tu servidor está detrás de un proxy inverso.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Ocultar los artistas de canciones si son los mismos que los artistas del álbum",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Permite que la notificación se pueda deslizar cuando la música esté pausada. También permite a Android matar el servicio cuando la música esté pausada.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"showTextOnGridView": "Mostrar texto en vista de cuadrícula",
|
||||
"@showTextOnGridView": {},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"startupError": "¡Algo ha salido mal en el arranque de la aplicación! El error es: {error}\n\nPor favor crea una incidencia en github.com/UnicornsOnLSD/finamp con una captura de pantalla de esta página. Si el problema persiste, borra los datos de la aplicación para resetearla.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleAll": "Reproducir aleatoriamente todas las canciones",
|
||||
"@shuffleAll": {},
|
||||
"premiereDate": "Fecha de lanzamiento",
|
||||
"@premiereDate": {},
|
||||
"revenue": "Ingresos",
|
||||
"@revenue": {},
|
||||
"runtime": "Duración",
|
||||
"@runtime": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{No se han encontrado imágenes que faltan} =1{Descargada {count} imagen que faltaba} other{Descargadas {count} que faltaban}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorScreenError": "¡Se ha producido un error al obtener la lista de errores! Llegados a este punto, probablemente deberías crear una incidencia en GitHub y eliminar los datos de la aplicación",
|
||||
"@errorScreenError": {},
|
||||
"customLocationsBuggy": "Las ubicaciones personalizadas son extremadamente problemáticas por problemas con los permisos. Estoy pensando en maneras de arreglarlo, pero por ahora no recomiendo usarlas.",
|
||||
"@customLocationsBuggy": {},
|
||||
"shuffleAllSongCount": "Cantidad de canciones al reproducir aleatoriamente",
|
||||
"@shuffleAllSongCount": {},
|
||||
"gridCrossAxisCountSubtitle": "Cantidad de columnas al usar por fila al estar el dispositivo en {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleAllSongCountSubtitle": "Cantidad de canciones a cargar cuando se use el botón de reproducir todo aleatoriamente.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"showTextOnGridViewSubtitle": "Muestra el texto (título, artista, etc) en la pantalla de música al usar la vista de cuadrícula.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Oculta a los artistas de las canciones en la pantalla del álbum si no son diferentes de los artistas del álbum.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"statusError": "ERROR DE ESTADO",
|
||||
"@statusError": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Usa la carátula del álbum desenfocada como fondo del reproductor.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"responseError401": "{error} Código de estado {statusCode}. Esto posiblemente significa que has usado el nombre de usuario o la contraseña incorrecta, o que tu cliente ya no está autenticado.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Eliminar de la mezcla",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Añadir a la mezcla",
|
||||
"@addToMix": {},
|
||||
"minutes": "Minutos",
|
||||
"@minutes": {},
|
||||
"redownloadedItems": "{count,plural, =0{No hace falta volver a descargar nada.} =1{Redescargado {count} elemento} other{Redescargados {count} elementos}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDuration": "Duración del búfer",
|
||||
"@bufferDuration": {},
|
||||
"bufferDurationSubtitle": "Cuánto debe almacenar en el búfer el reproductor, en segundos. Requiere un reinicio.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"disableGesture": "Deshabilitar los gestos",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Si desea desactivar los gestos.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"removeFromPlaylistTooltip": "Eliminar de la lista de reproducción",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Borrar de la lista de reproducción",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"removedFromPlaylist": "Eliminado de la lista de reproducción.",
|
||||
"@removedFromPlaylist": {},
|
||||
"language": "Idioma",
|
||||
"@language": {},
|
||||
"confirm": "Confirmar",
|
||||
"@confirm": {},
|
||||
"showUncensoredLogMessage": "Este registro contiene tu información de acceso. ¿Mostrar?",
|
||||
"@showUncensoredLogMessage": {},
|
||||
"resetTabs": "Restablecer las pestañas",
|
||||
"@resetTabs": {},
|
||||
"insertedIntoQueue": "Insertado en la cola.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"playNext": "Reproducir la siguiente",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"noMusicLibrariesTitle": "Sin bibliotecas de música",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"refresh": "REFRESCAR",
|
||||
"@refresh": {},
|
||||
"noMusicLibrariesBody": "Finamp no ha podido encontrar ninguna biblioteca de música. Asegúrate de que tu servidor Jellyfin contiene al menos una biblioteca con el tipo de \"Música\".",
|
||||
"@noMusicLibrariesBody": {},
|
||||
"deleteDownloadsConfirmButtonText": "Borrar",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"deleteDownloadsPrompt": "¿Estás seguro de que quieres eliminar la {itemType, select, album{album} playlist{playlist} artist{artist} genre{género} track{song} other{}} '{itemName}' de este dispositivo?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"deleteDownloadsAbortButtonText": "Cancelar",
|
||||
"@deleteDownloadsAbortButtonText": {},
|
||||
"showFastScroller": "Mostrar desplazamiento rápido",
|
||||
"@showFastScroller": {},
|
||||
"syncDownloadedPlaylists": "Sincroniza las listas de reproducción descargadas",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"swipeInsertQueueNextSubtitle": "Permite insertar una canción como siguiente elemento en la cola cuando se desliza en la lista de reproducción en lugar de añadirla al final.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"interactions": "Interacciones",
|
||||
"@interactions": {},
|
||||
"swipeInsertQueueNext": "Reproducir la siguiente canción al deslizarla",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"redesignBeta": "Prueba la versión Beta",
|
||||
"@redesignBeta": {},
|
||||
"playbackOrderShuffledTooltip": "Mezclando. Toca para alternar.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Reproducción en orden. Toca para alternar.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"loopModeAllTooltip": "Repitiendo todo. Toca para alternar.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "En bucle. Toca para alternar.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"loopModeNoneTooltip": "No se reproduce en bucle. Toca para alternar.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"skipToPrevious": "Saltar a la canción anterior",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Saltar a la siguiente canción",
|
||||
"@skipToNext": {},
|
||||
"togglePlayback": "Alternar reproducción",
|
||||
"@togglePlayback": {},
|
||||
"playArtist": "Reproducir todos los álbumes de {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Reproducir aleatoriamente todos los álbumes de {artist}",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadArtist": "Descargar todos los álbumes de {artist}",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFromDevice": "Eliminar del dispositivo",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Descargar",
|
||||
"@download": {},
|
||||
"sync": "Sincronizar con el servidor",
|
||||
"@sync": {},
|
||||
"about": "Acerca de Finamp",
|
||||
"@about": {}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
{
|
||||
"serverUrl": "Serveri URL",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "Serveri URL ei tohi olla tühi",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL alguses peab olema http:// või https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL ei tohi lõppeda kaldkriipsuga",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Kasutajanimi",
|
||||
"@username": {},
|
||||
"password": "Parool",
|
||||
"@password": {},
|
||||
"internalExternalIpExplanation": "Kui soovid oma Jellyfini serverile kaugjuurdepääsu saada, pead kasutama oma välist IP-d.\n\nKui server kasutab HTTP-porti (80/443), ei pea porti määrama. See on tõenäoliselt nii, kui server on pöördpuhverserveri taga.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"logs": "Logid",
|
||||
"@logs": {},
|
||||
"next": "Järgmine",
|
||||
"@next": {},
|
||||
"selectMusicLibraries": "Vali muusikakogu",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"couldNotFindLibraries": "Ühtki muusikakogu ei leitud.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"unknownName": "Tundmatu nimi",
|
||||
"@unknownName": {},
|
||||
"songs": "Lood",
|
||||
"@songs": {},
|
||||
"albums": "Albumid",
|
||||
"@albums": {},
|
||||
"artists": "Esitajad",
|
||||
"@artists": {},
|
||||
"genres": "Žanrid",
|
||||
"@genres": {},
|
||||
"playlists": "Esitusloendid",
|
||||
"@playlists": {},
|
||||
"startMix": "Käivita miks",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsArtist": "Vajuta pikalt esitajale, et lisada või eemaldada ta enne miksi koostamise alustamist koostajast",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Vajuta pikalt albumile, et lisada või eemaldada ta enne miksi koostamise alustamist koostajast",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"sortOrder": "Sortimisjärjestus",
|
||||
"@sortOrder": {},
|
||||
"clear": "Puhasta",
|
||||
"@clear": {},
|
||||
"favourites": "Lemmikud",
|
||||
"@favourites": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"downloads": "Allalaadimised",
|
||||
"@downloads": {},
|
||||
"settings": "Seaded",
|
||||
"@settings": {},
|
||||
"offlineMode": "Võrguühenduseta režiim",
|
||||
"@offlineMode": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"artist": "Esitaja",
|
||||
"@artist": {},
|
||||
"sortBy": "Sordi",
|
||||
"@sortBy": {},
|
||||
"albumArtist": "Albumi esitaja",
|
||||
"@albumArtist": {},
|
||||
"communityRating": "Kogukonna hinnang",
|
||||
"@communityRating": {},
|
||||
"criticRating": "Kriitikute hinnang",
|
||||
"@criticRating": {},
|
||||
"dateAdded": "Lisamise kuupäev",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Esituse kuupäev",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Esituste arv",
|
||||
"@playCount": {},
|
||||
"productionYear": "Tootmisaasta",
|
||||
"@productionYear": {},
|
||||
"name": "Nimi",
|
||||
"@name": {},
|
||||
"random": "Juhuslik",
|
||||
"@random": {},
|
||||
"revenue": "Tulud",
|
||||
"@revenue": {},
|
||||
"runtime": "Kestus",
|
||||
"@runtime": {},
|
||||
"downloadMissingImages": "Laadi alla puuduvad pildid",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadErrors": "Allalaadimisvead",
|
||||
"@downloadErrors": {},
|
||||
"budget": "Eelarve",
|
||||
"@budget": {},
|
||||
"premiereDate": "Avaldamise kuupäev",
|
||||
"@premiereDate": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Puuduvaid pilte ei leitud} =1{Allalaetud {count} puuduv pilt} other{Allalaetud {count} puuduvat pilti}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadCount": "{count,plural, =1{{count} allalaadimine} other{{count} allalaadimist}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} üksus} other{{count} üksust}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} tehtud",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} nurjus",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} järjekorras",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} käsil",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Allalaadimisvead",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "Pole vigu!",
|
||||
"@noErrors": {},
|
||||
"errorScreenError": "Vigade loendi saamisel tekkis viga! Siinkohal peaksid ilmselt lihtsalt looma probleemi GitHubis ja kustutama rakenduse andmed",
|
||||
"@errorScreenError": {},
|
||||
"failedToGetSongFromDownloadId": "Allalaadimise ID-st laulu hankimine nurjus",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"error": "Viga",
|
||||
"@error": {},
|
||||
"discNumber": "Plaat {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "MÄNGI",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "SEGA",
|
||||
"@shuffleButtonLabel": {},
|
||||
"songCount": "{count,plural,=1{{count} lugu} other{{count} lugu}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Muuda esitusloendi nime",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"editPlaylistNameTitle": "Muuda esitusloendi nime",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"required": "Nõutav",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "UUENDA",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Esitusloendi nimi uuendatud.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Lemmik",
|
||||
"@favourite": {},
|
||||
"downloadsDeleted": "Allalaadimised kustutati.",
|
||||
"@downloadsDeleted": {},
|
||||
"addDownloads": "Lisa allalaadimised",
|
||||
"@addDownloads": {},
|
||||
"location": "Asukoht",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Allalaadimised lisatud.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "LISA",
|
||||
"@addButtonLabel": {},
|
||||
"shareLogs": "Jaga logisid",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Logid kopeeriti.",
|
||||
"@logsCopied": {},
|
||||
"message": "Sõnum",
|
||||
"@message": {},
|
||||
"applicationLegalese": "Litsentsitud Mozilla avaliku litsentsiga 2.0. Lähtekood on saadaval aadressil:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"transcoding": "Transkoodimine",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Allalaadimiste asukohad",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Heliteenus",
|
||||
"@audioService": {},
|
||||
"layoutAndTheme": "Paigutus ja teema",
|
||||
"@layoutAndTheme": {},
|
||||
"notAvailableInOfflineMode": "Pole võrguühenduseta režiimis saadaval",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Logi välja",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Allalaaditud lugusid ei kustutata",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"areYouSure": "Kas oled kindel?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin kasutab transkodeerimiseks AAC-d",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Luba transkodeerimine",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Transkodeerib muusikavooge serveri poolel.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"customLocation": "Kohandatud asukoht",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "Rakenduse kataloog",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Lisa allalaadimise asukoht",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Vali kataloog",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Tundmatu viga",
|
||||
"@unknownError": {},
|
||||
"enterLowPriorityStateOnPause": "Kasuta pausil madala prioriteediga olekut",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Laseb pausi ajal märguande ära pühkida. Võimaldab Androidil ka teenuse pausi ajal lõpetada.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"shuffleAllSongCountSubtitle": "Laaditavate lugude hulk, kui kasutada nuppu 'Sega kõik lood'.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewType": "Vaate tüüp",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Muusikaekraani vaate tüüp",
|
||||
"@viewTypeSubtitle": {},
|
||||
"landscape": "Lai pilt",
|
||||
"@landscape": {},
|
||||
"pathReturnSlashErrorMessage": "Radu, mis tagastavad \"/\", ei saa kasutada",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"showTextOnGridView": "Kuva teksti ruudustikuvaates",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Kas kuvada teksti (pealkiri, esitaja jne) muusikaekraani ruudustikul või mitte.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Kuva hägustatud kaanepilt mängija taustana",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Kas kasutada mängija ekraanil taustaks hägustatud kaanekujundust või mitte.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Kas näidata lugude esitajaid albumiekraanil, kui need ei erine albumi esitajatest.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"theme": "Teema",
|
||||
"@theme": {},
|
||||
"system": "Süsteem",
|
||||
"@system": {},
|
||||
"dark": "Tume",
|
||||
"@dark": {},
|
||||
"tabs": "Vahekaardid",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Kas tühistada unetaimer?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "JAH",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "EI",
|
||||
"@noButtonLabel": {},
|
||||
"setSleepTimer": "Määra unetaimer",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "minutit",
|
||||
"@minutes": {},
|
||||
"invalidNumber": "Sobimatu number",
|
||||
"@invalidNumber": {},
|
||||
"sleepTimerTooltip": "Unetaimer",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Lisa esitusloendisse",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Lisa esitusloendisse",
|
||||
"@addToPlaylistTitle": {},
|
||||
"newPlaylist": "Uus esitusloend",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "LOO",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Esitusloend on loodud.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "Albumit pole",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Üksust pole",
|
||||
"@noItem": {},
|
||||
"noArtist": "Esitajat pole",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Tundmatu esitaja",
|
||||
"@unknownArtist": {},
|
||||
"streaming": "VOOGESITUS",
|
||||
"@streaming": {},
|
||||
"downloaded": "ALLALAADITUD",
|
||||
"@downloaded": {},
|
||||
"transcode": "TRANSKOODI",
|
||||
"@transcode": {},
|
||||
"direct": "OTSE",
|
||||
"@direct": {},
|
||||
"statusError": "OLEKUVIGA",
|
||||
"@statusError": {},
|
||||
"queue": "Järjekord",
|
||||
"@queue": {},
|
||||
"addToQueue": "Lisa järjekorda",
|
||||
"@addToQueue": {},
|
||||
"replaceQueue": "Asenda järjekord",
|
||||
"@replaceQueue": {},
|
||||
"removeFavourite": "Eemalda lemmik",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Lisa lemmik",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "Lisatud järjekorda.",
|
||||
"@addedToQueue": {},
|
||||
"queueReplaced": "Järjekord asendatud.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "Kiirmiksi käivitamine.",
|
||||
"@startingInstantMix": {},
|
||||
"instantMix": "Kiirmiks",
|
||||
"@instantMix": {},
|
||||
"anErrorHasOccured": "Ilmnes tõrge.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Olekukood {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError401": "{error} Olekukood {statusCode}. Tõenäoliselt tähendab see, et kasutasid vale kasutajanime/parooli või klient pole enam autenditud.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Eemalda miksist",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Lisa miksi",
|
||||
"@addToMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{Allalaadimine pole vajalik.} =1{Uuesti laeti alla {count} üksus} other{Uuesti laeti alla {count} üksust}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goToAlbum": "Ava album",
|
||||
"@goToAlbum": {},
|
||||
"music": "Muusika",
|
||||
"@music": {},
|
||||
"shuffleAll": "Sega kõik",
|
||||
"@shuffleAll": {},
|
||||
"directoryMustBeEmpty": "Kataloog peab olema tühi",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"bitrate": "Bitikiirus",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "Suurem bitikiirus annab kvaliteetsema heli, kuid see nõuab suuremat ribalaiust.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocationsBuggy": "Kohandatud asukohad on äärmiselt vigased, kuna on probleeme õigustega. Ma mõtlen, kuidas seda parandada, kuid praegu ma ei soovitaks neid kasutada.",
|
||||
"@customLocationsBuggy": {},
|
||||
"list": "Loend",
|
||||
"@list": {},
|
||||
"shuffleAllSongCount": "'Sega kõik lood' arv",
|
||||
"@shuffleAllSongCount": {},
|
||||
"portrait": "Portree",
|
||||
"@portrait": {},
|
||||
"grid": "Ruudustik",
|
||||
"@grid": {},
|
||||
"gridCrossAxisCount": "{value} Ruudustiku risttelgede arv",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridCrossAxisCountSubtitle": "Ruudustiku plaatide arv, mida kasutatakse rea kohta, kui {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Peida laulu esitajad, kui need on samad kui albumi esitajad",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"light": "Hele",
|
||||
"@light": {},
|
||||
"startupError": "Rakenduse käivitamisel läks midagi valesti. Viga oli: {error}\n\nPalun loo probleem github.com/UnicornsOnLSD/finamp koos ekraanipildiga sellest leheküljest. Kui see probleem püsib, võiks rakenduse lähtestamiseks kustutada rakenduse andmed.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} pilt} other{{count} pilti}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disableGesture": "Keela žestid",
|
||||
"@disableGesture": {},
|
||||
"bufferDuration": "Puhvri kestus",
|
||||
"@bufferDuration": {},
|
||||
"disableGestureSubtitle": "Kas keelata žestid.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"bufferDurationSubtitle": "Kui palju mängija peaks puhverdama sekundites. Nõuab taaskäivitamist.",
|
||||
"@bufferDurationSubtitle": {}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
{
|
||||
"unknownError": "Tuntematon virhe",
|
||||
"@unknownError": {},
|
||||
"removedFromPlaylist": "Poistettu soittolistalta.",
|
||||
"@removedFromPlaylist": {},
|
||||
"addFavourite": "Lisää suosikki",
|
||||
"@addFavourite": {},
|
||||
"goToAlbum": "Mene albumiin",
|
||||
"@goToAlbum": {},
|
||||
"downloads": "Lataukset",
|
||||
"@downloads": {},
|
||||
"settings": "Asetukset",
|
||||
"@settings": {},
|
||||
"shareLogs": "Jaa lokit",
|
||||
"@shareLogs": {},
|
||||
"removeFavourite": "Poista suosikki",
|
||||
"@removeFavourite": {},
|
||||
"playNext": "Toista seuraava",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"confirm": "Vahvista",
|
||||
"@confirm": {},
|
||||
"language": "Kieli",
|
||||
"@language": {},
|
||||
"refresh": "VIRKISTÄ",
|
||||
"@refresh": {},
|
||||
"serverUrl": "Palvelimen URL",
|
||||
"@serverUrl": {},
|
||||
"emptyServerUrl": "Palvelimen URL ei voi olla tyhjä",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlStartWithHttps": "URL pitää alkaa http:// tai https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"urlTrailingSlash": "URL ei saa päättyä vinoviivaan",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Käyttäjätunnus",
|
||||
"@username": {},
|
||||
"password": "Salasana",
|
||||
"@password": {},
|
||||
"logs": "Lokit",
|
||||
"@logs": {},
|
||||
"next": "Seuraava",
|
||||
"@next": {},
|
||||
"selectMusicLibraries": "Valitse musiikkikirjastot",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"couldNotFindLibraries": "Yhtään kirjastoa ei löytynyt.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"unknownName": "Tuntematon Nimi",
|
||||
"@unknownName": {},
|
||||
"songs": "Kappaleet",
|
||||
"@songs": {},
|
||||
"albums": "Albumit",
|
||||
"@albums": {},
|
||||
"artists": "Artistit",
|
||||
"@artists": {},
|
||||
"genres": "Tyylilajit",
|
||||
"@genres": {},
|
||||
"playlists": "Soittolistat",
|
||||
"@playlists": {},
|
||||
"music": "Musiikki",
|
||||
"@music": {},
|
||||
"clear": "Tyhjennä",
|
||||
"@clear": {},
|
||||
"favourites": "Suosikit",
|
||||
"@favourites": {},
|
||||
"offlineMode": "Offline tila",
|
||||
"@offlineMode": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"sortOrder": "Lajittelujärjestys",
|
||||
"@sortOrder": {},
|
||||
"album": "Albumi",
|
||||
"@album": {},
|
||||
"albumArtist": "Albumin artisti",
|
||||
"@albumArtist": {},
|
||||
"artist": "Artisti",
|
||||
"@artist": {},
|
||||
"name": "Nimi",
|
||||
"@name": {},
|
||||
"random": "Satunnainen",
|
||||
"@random": {},
|
||||
"criticRating": "Kriitikoiden arvostelu",
|
||||
"@criticRating": {},
|
||||
"downloadMissingImages": "Lataa puuttuvat kuvat",
|
||||
"@downloadMissingImages": {},
|
||||
"syncDownloadedPlaylists": "Synkronoi ladatut soittolistat",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"downloadErrors": "Latauksen virheet",
|
||||
"@downloadErrors": {},
|
||||
"dlFailed": "{count} epäonnistui",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} valmistunut",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} käynnissä",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Latauksen virheet",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "Ei virheitä!",
|
||||
"@noErrors": {},
|
||||
"deleteDownloadsConfirmButtonText": "Poista",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"deleteDownloadsAbortButtonText": "Peruuta",
|
||||
"@deleteDownloadsAbortButtonText": {},
|
||||
"error": "Virhe",
|
||||
"@error": {},
|
||||
"playButtonLabel": "TOISTA",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "SEKOITA",
|
||||
"@shuffleButtonLabel": {},
|
||||
"discNumber": "Levy {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTitle": "Muokkaa soittolistan nimeä",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"editPlaylistNameTooltip": "Muokkaa soittolistan nimeä",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"playlistNameUpdated": "Soittolistan nimi päivitetty.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Suosikki",
|
||||
"@favourite": {},
|
||||
"addDownloads": "Lisää lataukset",
|
||||
"@addDownloads": {},
|
||||
"updateButtonLabel": "PÄIVITÄ",
|
||||
"@updateButtonLabel": {},
|
||||
"downloadsDeleted": "Lataukset poistettu.",
|
||||
"@downloadsDeleted": {},
|
||||
"location": "Sijainti",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Lataukset lisätty.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "LISÄÄ",
|
||||
"@addButtonLabel": {},
|
||||
"logsCopied": "Lokit kopioitu.",
|
||||
"@logsCopied": {},
|
||||
"message": "Viesti",
|
||||
"@message": {},
|
||||
"downloadLocations": "Latauksen sijainnit",
|
||||
"@downloadLocations": {},
|
||||
"transcoding": "Transkoodaus",
|
||||
"@transcoding": {},
|
||||
"notAvailableInOfflineMode": "Ei saatavilla offline tilassa",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"areYouSure": "Oletko varma?",
|
||||
"@areYouSure": {},
|
||||
"logOut": "Kirjaudu ulos",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Ladattuja kappaleita ei poisteta",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin käyttää AAC:tä transkoodaukseen",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Ota transkoodaus käyttöön",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Transkoodaa musiikin suoratoiston palvelimen päässä.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"shuffleAll": "Sekoita kaikki",
|
||||
"@shuffleAll": {},
|
||||
"budget": "Budjetti",
|
||||
"@budget": {},
|
||||
"communityRating": "Yhteisön arvostelu",
|
||||
"@communityRating": {},
|
||||
"dateAdded": "Lisäämisen päivämäärä",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Toiston päivämäärä",
|
||||
"@datePlayed": {},
|
||||
"playCount": "Toistolaskuri",
|
||||
"@playCount": {},
|
||||
"productionYear": "Tuotantovuosi",
|
||||
"@productionYear": {},
|
||||
"revenue": "Tulot",
|
||||
"@revenue": {},
|
||||
"runtime": "Kesto",
|
||||
"@runtime": {},
|
||||
"dlEnqueued": "{count} Jonossa",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": "Pakollinen",
|
||||
"@required": {},
|
||||
"layoutAndTheme": "Asettelu ja Teema",
|
||||
"@layoutAndTheme": {},
|
||||
"viewType": "Näkymän Tyyppi",
|
||||
"@viewType": {},
|
||||
"list": "Lista",
|
||||
"@list": {},
|
||||
"grid": "Ruudukko",
|
||||
"@grid": {},
|
||||
"portrait": "Pysty",
|
||||
"@portrait": {},
|
||||
"landscape": "Vaaka",
|
||||
"@landscape": {},
|
||||
"showTextOnGridView": "Näytä teksti ruudukkonäkymässä",
|
||||
"@showTextOnGridView": {},
|
||||
"theme": "Teema",
|
||||
"@theme": {},
|
||||
"system": "Järjestelmä",
|
||||
"@system": {},
|
||||
"light": "Vaalea",
|
||||
"@light": {},
|
||||
"tabs": "Välilehdet",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Peruuta uniajastin?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "KYLLÄ",
|
||||
"@yesButtonLabel": {},
|
||||
"setSleepTimer": "Aseta uniajastin",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Minuutit",
|
||||
"@minutes": {},
|
||||
"sleepTimerTooltip": "Uniajastin",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Lisää soittolistalle",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"removeFromPlaylistTooltip": "Poista soittolistalta",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Poista soittolistalta",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"direct": "SUORA",
|
||||
"@direct": {},
|
||||
"queue": "Jono",
|
||||
"@queue": {},
|
||||
"addedToQueue": "Lisätty jonoon.",
|
||||
"@addedToQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully adds items to the end of the play queue."
|
||||
},
|
||||
"anErrorHasOccured": "Tapahtui virhe.",
|
||||
"@anErrorHasOccured": {},
|
||||
"bufferDuration": "Puskurin kesto",
|
||||
"@bufferDuration": {},
|
||||
"dark": "Tumma",
|
||||
"@dark": {},
|
||||
"noButtonLabel": "EI",
|
||||
"@noButtonLabel": {},
|
||||
"newPlaylist": "Uusi soittolista",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "LUO",
|
||||
"@createButtonLabel": {},
|
||||
"noAlbum": "Ei albumia",
|
||||
"@noAlbum": {},
|
||||
"addToPlaylistTitle": "Lisää soittolistalle",
|
||||
"@addToPlaylistTitle": {},
|
||||
"playlistCreated": "Soittolista on luotu.",
|
||||
"@playlistCreated": {},
|
||||
"noArtist": "Ei Artistia",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Tuntematon artisti",
|
||||
"@unknownArtist": {},
|
||||
"downloaded": "LADATTU",
|
||||
"@downloaded": {},
|
||||
"addToQueue": "Lisää jonoon",
|
||||
"@addToQueue": {
|
||||
"description": "Popup menu item title for adding an item to the end of the play queue."
|
||||
},
|
||||
"resetTabs": "Nollaa välilehdet",
|
||||
"@resetTabs": {},
|
||||
"noMusicLibrariesTitle": "Ei musiikkikirjastoja",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} kohde} other{{count} kohteet}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadCount": "{count,plural, =1{{count} lataus} other{{count} lataukset}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} kuva} other{{count} kuvat}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showCoverAsPlayerBackground": "Näytä sumennettu kansikuva soittimen taustakuvana",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Piilota kappaleen artistit, jos samat kuin albumin artistit",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"insertedIntoQueue": "Asetettu jonoon.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"responseError": "{error} Tilakoodi {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showUncensoredLogMessage": "Tämä loki sisältää kirjautumistietosi. Näytä?",
|
||||
"@showUncensoredLogMessage": {},
|
||||
"directoryMustBeEmpty": "Hakemiston pitää olla tyhjä",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"selectDirectory": "Valitse hakemisto",
|
||||
"@selectDirectory": {},
|
||||
"songCount": "{count,plural,=1{{count} Kappale} other{{count} Kappaleita}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sortBy": "Järjestä",
|
||||
"@sortBy": {},
|
||||
"premiereDate": "Ensiesityspäivä",
|
||||
"@premiereDate": {},
|
||||
"interactions": "Vuorovaikutukset",
|
||||
"@interactions": {},
|
||||
"audioService": "Äänipalvelu",
|
||||
"@audioService": {},
|
||||
"bitrate": "Bitrate",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "Suurempi bitrate antaa laadukkaamman äänen, mutta sen käyttämä kaistanleveys on suurempi.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Mukautettu sijainti",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "Sovelluksen hakemisto",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Lisää latauksen hakemisto",
|
||||
"@addDownloadLocation": {},
|
||||
"pathReturnSlashErrorMessage": "Polkuja jotka palauttavat \"/\" ei voi käyttää",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"disableGesture": "Poista eleet käytöstä",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Poistaa eleet käytöstä.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"invalidNumber": "Virheellinen numero",
|
||||
"@invalidNumber": {},
|
||||
"noItem": "Ei kohdetta",
|
||||
"@noItem": {},
|
||||
"streaming": "SUORATOISTAA",
|
||||
"@streaming": {},
|
||||
"transcode": "TRANSKOODI",
|
||||
"@transcode": {},
|
||||
"noMusicLibrariesBody": "Finamp ei löytänyt musiikkikirjastoja. Varmista, että Jellyfin-palvelimellasi on vähintään yksi kirjasto, jonka sisältötyypiksi on asetettu \"Musiikki\".",
|
||||
"@noMusicLibrariesBody": {},
|
||||
"instantMix": "Välitön Sekoitus",
|
||||
"@instantMix": {},
|
||||
"replaceQueue": "Korvaa Jono",
|
||||
"@replaceQueue": {},
|
||||
"queueReplaced": "Jono korvattu.",
|
||||
"@queueReplaced": {},
|
||||
"startingInstantMix": "Käynnistetään välitön sekoitus.",
|
||||
"@startingInstantMix": {},
|
||||
"addToMix": "Lisää Sekoitukseen",
|
||||
"@addToMix": {},
|
||||
"removeFromMix": "Poista Sekoituksesta",
|
||||
"@removeFromMix": {},
|
||||
"bufferDurationSubtitle": "Kuinka paljon soittimen pitäisi puskuroida, sekunteina. Vaatii uudelleenkäynnistyksen.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"redesignBeta": "Kokeile Betaa",
|
||||
"@redesignBeta": {},
|
||||
"swipeInsertQueueNext": "Toista Pyyhkäisty Kappale Seuraavaksi",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"startMix": "Aloita sekoitus",
|
||||
"@startMix": {},
|
||||
"failedToGetSongFromDownloadId": "Kappaleen nouto lataus ID:stä epäonnistui",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"stackTrace": "Pinon jäljitys",
|
||||
"@stackTrace": {},
|
||||
"applicationLegalese": "Lisensoitu Mozilla Public License 2.0 -lisenssillä. Lähdekoodi saatavilla osoitteessa:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"customLocationsBuggy": "Mukautetut sijainnit ovat erittäin bugisia käyttöoikeusongelmien vuoksi. Mietin tapoja korjata tämä, mutta toistaiseksi en suosittele niiden käyttöä.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Sallii ilmoituksen pyyhkäisemisen pois, kun toisto on pysäytetty. Antaa myös Androidin lopettaa palvelun, kun toisto on keskeytetty.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"startupError": "Jokin meni pieleen sovelluksen käynnistyksen aikana. Virhe oli: {error}\n\nOle hyvä ja luo virheilmoitus osoitteessa github.com/UnicornsOnLSD/finamp, jossa on kuvakaappaus tästä sivusta. Jos ongelma jatkuu, voit tyhjentää sovelluksen tiedot nollataksesi sovelluksen.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internalExternalIpExplanation": "Jos haluat käyttää Jellyfin-palvelintasi etänä, sinun on käytettävä ulkoista IP-osoitettasi.\n\nJos palvelimesi käyttää HTTP-porttia (80/443), sinun ei tarvitse määrittää porttia. Näin on todennäköisesti, jos palvelimesi on reverse proxyn takana.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"startMixNoSongsAlbum": "Paina albumia pitkään lisätäksesi tai poistaaksesi sen miksaukseen ennen miksauksen aloittamista",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"downloadedMissingImages": "{count,plural, =0{Puuttuvia kuvia ei löytynyt} =1{Ladattu {count} puuttuvaa kuvaa} other{ladattu {count} puuttuvia kuvia}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorScreenError": "Virhe tapahtui virheiden luettelon hakemisessa! Tässä vaiheessa sinun pitäisi luultavasti vain luoda virheilmoitus GitHubiin ja poistaa sovelluksen tiedot",
|
||||
"@errorScreenError": {},
|
||||
"deleteDownloadsPrompt": "Oletko varma, että haluat poistaa {itemType, select, album{album} playlist{playlist} artist{artist} genre{genre} track{song} other{}} '{itemName}' tästä laitteesta?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"enterLowPriorityStateOnPause": "Siirtyminen matalan prioriteetin tilaan tauon aikana",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"shuffleAllSongCount": "Kaikkien sekoitettujen kappaleiden määrä",
|
||||
"@shuffleAllSongCount": {},
|
||||
"shuffleAllSongCountSubtitle": "Ladattavien kappaleiden määrä, kun käytät sekoita kaikki kappaleet painiketta.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"gridCrossAxisCount": "{value} Ruudukon poikittaisakselien lukumäärä",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridCrossAxisCountSubtitle": "Rivikohtaisesti käytettävien ruudukkotiilien määrä, kun {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridViewSubtitle": "Näytetäänkö teksti (nimi, artisti jne.) ruudukon musiikkinäytöllä vai ei.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Näytetäänkö kappaleiden artistit albumin näytöllä, jos ne eivät poikkea albumin artisteista.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"showFastScroller": "Näytä nopea vieritin",
|
||||
"@showFastScroller": {},
|
||||
"statusError": "TILAVIRHE",
|
||||
"@statusError": {},
|
||||
"responseError401": "{error} Tilakoodi {statusCode}. Tämä tarkoittaa todennäköisesti, että olet käyttänyt väärää käyttäjätunnusta/salasanaa tai että sovellus ei ole enää kirjautuneena sisään.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redownloadedItems": "{count,plural, =0{Ei tarvitse ladata uudelleen.} =1{Uudelleenladattu {count} kohde} other{Uudelleenladatut {count} kohteet}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"swipeInsertQueueNextSubtitle": "Mahdollistaa kappaleen lisäämisen jonon seuraavaksi kohteeksi, kun sitä pyyhkäistään kappaleiden luettelossa sen sijaan, että se liitettäisiin loppuun.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"startMixNoSongsArtist": "Paina pitkään artistia lisätäksesi tai poistaaksesi sen miksaukseen ennen miksauksen aloittamista",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"viewTypeSubtitle": "Musiikkinäytön näkymätyyppi",
|
||||
"@viewTypeSubtitle": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Käytetäänkö sumeaa kansikuvitusta taustana soittimen näytöllä vai ei.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"downloadArtist": "Lataa kaikki {artist}:n albumit",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playbackOrderShuffledTooltip": "Sekoittaminen. Vaihda napauttamalla.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Toistaminen järjestyksessä. Vaihda napauttamalla.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"skipToPrevious": "Siirry edelliseen kappaleeseen",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Siirry seuraavaan kappaleeseen",
|
||||
"@skipToNext": {},
|
||||
"togglePlayback": "Toiston vaihtaminen",
|
||||
"@togglePlayback": {},
|
||||
"loopModeAllTooltip": "Toista kaikki. Vaihda napauttamalla.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Toista yksi. Vaihda napauttamalla.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"loopModeNoneTooltip": "Älä toista. Vaihda napauttamalla.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"playArtist": "Toista kaikki {artist}:n albumit",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Sekoita kaikki {artist}:n albumit",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFromDevice": "Poista laitteelta",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Lataa",
|
||||
"@download": {},
|
||||
"sync": "Synkronoi palvelimen kanssa",
|
||||
"@sync": {},
|
||||
"about": "Tietoja Finampista",
|
||||
"@about": {}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
{
|
||||
"startupError": "Une erreur est survenue lors du démarrage de l'application. L'erreur est: {error}\n\nPlease create an issue on github.com/UnicornsOnLSD/finamp with a screenshot of this page. If this problem persists you can clear your app data to reset the app.",
|
||||
"@startupError": {
|
||||
"description": "Le message d'erreur affiché lorsque le démarrage de l'application échoue.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Impossible d'accéder aux données de téléchargement"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serverUrl": "Adresse du serveur",
|
||||
"@serverUrl": {},
|
||||
"internalExternalIpExplanation": "Si vous souhaitez pouvoir accéder à votre serveur Jellyfin à distance, vous devez utiliser votre adresse IP externe.\n\nSi votre serveur utilise un port HTTP (80/443), vous n'avez pas besoin de spécifier de port. C'est probablement le cas si votre serveur est derrière un reverse proxy.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Informations supplémentaires sur l'IP à utiliser pour l'accès à distance, et sur la nécessité ou non de spécifier un port"
|
||||
},
|
||||
"emptyServerUrl": "Vous devez indiquez l'adresse du serveur.",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Cette erreur intervient lorsque l'utilisateur tente de se connecter sans indiquer l'adresse du serveur"
|
||||
},
|
||||
"urlStartWithHttps": "L'adresse doit commencer par http:// ou https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Cette erreur intervient lorsque l'utilisateur soumet une adresse qui ne commence pas par http:// ou https:// (par exemple, ftp://0.0.0.0)"
|
||||
},
|
||||
"urlTrailingSlash": "L'adresse ne doit pas inclure de slash final.",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Cette erreur intervient lorsque l'utilisateur soumet une adresse qui se termine par une barre oblique finale (par exemple, http://0.0.0.0/)"
|
||||
},
|
||||
"username": "Nom d'utilisateur",
|
||||
"@username": {},
|
||||
"password": "Mot de passe",
|
||||
"@password": {},
|
||||
"logs": "Logs",
|
||||
"@logs": {},
|
||||
"next": "Prochain",
|
||||
"@next": {},
|
||||
"selectMusicLibraries": "Selectionner des bibliothèques",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "Titre de l'écran de sélection de bibliothèque"
|
||||
},
|
||||
"couldNotFindLibraries": "Aucune bibliothèque trouvée",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Cette erreur intervient lorsque l'utilisateur n'a pas de bibliothèque"
|
||||
},
|
||||
"unknownName": "Nom inconnu",
|
||||
"@unknownName": {},
|
||||
"songs": "Titres",
|
||||
"@songs": {},
|
||||
"albums": "Albums",
|
||||
"@albums": {},
|
||||
"artists": "Artistes",
|
||||
"@artists": {},
|
||||
"genres": "Genres",
|
||||
"@genres": {},
|
||||
"playlists": "Playlists",
|
||||
"@playlists": {},
|
||||
"startMix": "Commencer un Mix",
|
||||
"@startMix": {},
|
||||
"startMixNoSongsArtist": "Appuyer longuement sur un artiste pour l'ajouter ou le retirer du mix avant de commencer",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Notification qui intervient quand l'utilisateur lance le mix instantané sans avoir sélectionné des artistes"
|
||||
},
|
||||
"startMixNoSongsAlbum": "Appuyer longuement sur un album pour l'ajouter ou le retirer du mix avant de commencer",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Notification qui intervient quand l'utilisateur lance le mix instantané sans avoir sélectionné des albums"
|
||||
},
|
||||
"music": "Musique",
|
||||
"@music": {},
|
||||
"clear": "Effacer",
|
||||
"@clear": {},
|
||||
"favourites": "Favoris",
|
||||
"@favourites": {},
|
||||
"shuffleAll": "Tout mélanger",
|
||||
"@shuffleAll": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"downloads": "Téléchargements",
|
||||
"@downloads": {},
|
||||
"settings": "Réglages",
|
||||
"@settings": {},
|
||||
"offlineMode": "Mode hors-ligne",
|
||||
"@offlineMode": {},
|
||||
"sortOrder": "Ordre de tri",
|
||||
"@sortOrder": {},
|
||||
"sortBy": "Trier par",
|
||||
"@sortBy": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"albumArtist": "Artiste de l'album",
|
||||
"@albumArtist": {},
|
||||
"artist": "Artiste",
|
||||
"@artist": {},
|
||||
"budget": "Budget",
|
||||
"@budget": {},
|
||||
"communityRating": "Note de la communauté",
|
||||
"@communityRating": {},
|
||||
"criticRating": "Note de la critique",
|
||||
"@criticRating": {},
|
||||
"dateAdded": "Date d'ajout",
|
||||
"@dateAdded": {},
|
||||
"datePlayed": "Date de lecture",
|
||||
"@datePlayed": {},
|
||||
"playCount": "nombre de lectures",
|
||||
"@playCount": {},
|
||||
"premiereDate": "Date de première",
|
||||
"@premiereDate": {},
|
||||
"productionYear": "Date de production",
|
||||
"@productionYear": {},
|
||||
"name": "Nom",
|
||||
"@name": {},
|
||||
"random": "Aléatoire",
|
||||
"@random": {},
|
||||
"revenue": "Recettes",
|
||||
"@revenue": {},
|
||||
"runtime": "Durée d'exécution",
|
||||
"@runtime": {},
|
||||
"syncDownloadedPlaylists": "Synchroniser les playlists téléchargées",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"downloadMissingImages": "Télécharger les images manquantes",
|
||||
"@downloadMissingImages": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Pas d'image manquante} =1{{count} image téléchargée} other{{count} images téléchargées}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message affiché lorsque l'utilisateur télécharge des images manquantes",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "Télécharger les erreurs",
|
||||
"@downloadErrors": {},
|
||||
"downloadCount": "{count,plural, =1{{count} téléchargement} other{{count} téléchargements}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} élément} other{{count} éléments}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} image} other{{count} images}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "Sert à fusionner downloadedItemsCount et downloadedImagesCount car les outils intl de Flutter ne supportent pas plusieurs pluriels dans une seule chaîne de caractères. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 téléchargements"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count,plural,=1{{count} complété} other{{count} complétés}}",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count,plural,=1{{count} échec} other{{count} échecs}}",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlEnqueued": "{count} en attente",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} en cours",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Erreurs de téléchargement",
|
||||
"@downloadErrorsTitle": {},
|
||||
"noErrors": "Pas d'erreur!",
|
||||
"@noErrors": {},
|
||||
"errorScreenError": "Une erreur est survenue lors de la récupération de la liste des erreurs ! À ce stade, il serait probablement préférable de créer une issue sur GitHub et de supprimer les données de l'application.",
|
||||
"@errorScreenError": {},
|
||||
"failedToGetSongFromDownloadId": "Échec de récupération de la chanson à partir de l'ID de téléchargement",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"deleteDownloadsPrompt": "Êtes vour sûr de vouloir supprimer {itemType, select, album{l'album} playlist{la playlist} artist{l'artiste} genre{le genre} track{le titre} other{}} '{itemName}' de cet appareil ?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Invite de confirmation affichée avant de supprimer les médias téléchargés du périphérique local, action destructrice, n'affecte pas les médias sur le serveur."
|
||||
},
|
||||
"deleteDownloadsConfirmButtonText": "Supprimer",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Affiché dans la boîte de dialogue de confirmation pour la suppression des médias téléchargés du périphérique local."
|
||||
},
|
||||
"deleteDownloadsAbortButtonText": "Annuler",
|
||||
"error": "Erreur",
|
||||
"@error": {},
|
||||
"discNumber": "Disque {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playButtonLabel": "LIRE",
|
||||
"@playButtonLabel": {},
|
||||
"shuffleButtonLabel": "ALÉATOIRE",
|
||||
"@shuffleButtonLabel": {},
|
||||
"songCount": "{count,plural,=1{{count} titre} other{{count} titres}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTooltip": "Modifier le titre de la playlist",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"editPlaylistNameTitle": "Edit Playlist Name",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"required": "Requis",
|
||||
"@required": {},
|
||||
"updateButtonLabel": "METTRE À JOUR",
|
||||
"@updateButtonLabel": {},
|
||||
"playlistNameUpdated": "Titre de la playlist mis à jour.",
|
||||
"@playlistNameUpdated": {},
|
||||
"favourite": "Favoris",
|
||||
"@favourite": {},
|
||||
"downloadsDeleted": "Téléchargements supprimés.",
|
||||
"@downloadsDeleted": {},
|
||||
"addDownloads": "Ajouter les téléchargements",
|
||||
"@addDownloads": {},
|
||||
"location": "Emplacement",
|
||||
"@location": {},
|
||||
"downloadsAdded": "Téléchargements ajoutés.",
|
||||
"@downloadsAdded": {},
|
||||
"addButtonLabel": "AJOUTER",
|
||||
"@addButtonLabel": {},
|
||||
"shareLogs": "Partager les journaux.",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Journaux copiés.",
|
||||
"@logsCopied": {},
|
||||
"message": "Message",
|
||||
"@message": {},
|
||||
"stackTrace": "Stack Trace",
|
||||
"@stackTrace": {},
|
||||
"applicationLegalese": "Soumis à la licence Mozilla Public License 2.0. Le code source est disponible sur :\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"transcoding": "Transcodage",
|
||||
"@transcoding": {},
|
||||
"downloadLocations": "Emplacements des téléchargements",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Service audio",
|
||||
"@audioService": {},
|
||||
"interactions": "Intéractions",
|
||||
"@interactions": {},
|
||||
"layoutAndTheme": "Disposition & Apparence",
|
||||
"@layoutAndTheme": {},
|
||||
"notAvailableInOfflineMode": "Indisponible en mode hors-ligne",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"logOut": "Déconnexion",
|
||||
"@logOut": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Les chansons téléchargées ne seront pas supprimées",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"areYouSure": "Êtes-vous sûr ?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin utilise AAC lors du transcodage",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"enableTranscoding": "Activer le transcodage",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Transcode les flux musicaux côté serveur.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"bitrate": "Débit binaire",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "Un débit binaire plus élevé offre une meilleure qualité audio au prix d'une bande passante plus importante.",
|
||||
"@bitrateSubtitle": {},
|
||||
"customLocation": "Emplacement personnalisé",
|
||||
"@customLocation": {},
|
||||
"appDirectory": "Répertoire de l'application",
|
||||
"@appDirectory": {},
|
||||
"addDownloadLocation": "Ajouter un emplacement de téléchargement",
|
||||
"@addDownloadLocation": {},
|
||||
"selectDirectory": "Sélectionner un répertoire",
|
||||
"@selectDirectory": {},
|
||||
"unknownError": "Erreur inconnue",
|
||||
"@unknownError": {},
|
||||
"pathReturnSlashErrorMessage": "Les chemins qui retournent \"/\" ne peuvent pas être utilisés",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"directoryMustBeEmpty": "Le répertoire doit être vide",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"customLocationsBuggy": "Les emplacements personnalisés sont très bogués en raison de problèmes de permissions. Je réfléchis à des solutions pour corriger cela, mais pour l'instant, je ne recommande pas de les utiliser.",
|
||||
"@customLocationsBuggy": {},
|
||||
"enterLowPriorityStateOnPause": "Faible priorité de l'application lorsque la lecture est en pause",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Permet de supprimer la notification lorsque la lecture est en pause. Permet également à Android de fermer le service lorsqu'il est en pause.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"shuffleAllSongCount": "Nombre de chansons à mélanger",
|
||||
"@shuffleAllSongCount": {},
|
||||
"shuffleAllSongCountSubtitle": "Nombre de chansons à charger lors de l'utilisation du bouton 'Tout mélanger'.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"viewType": "Disposition de l'affichage",
|
||||
"@viewType": {},
|
||||
"viewTypeSubtitle": "Règle la manière dont les musiques sont diposées",
|
||||
"@viewTypeSubtitle": {},
|
||||
"list": "Liste",
|
||||
"@list": {},
|
||||
"grid": "Grille",
|
||||
"@grid": {},
|
||||
"portrait": "Portrait",
|
||||
"@portrait": {},
|
||||
"landscape": "Paysage",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCount": "Nombre de tuiles par ligne en orientation {value}",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "Titre de l'élément de liste pour le nombre d'axes croisés de la grille. La valeur sera soit la clé 'portrait', soit la clé 'paysage'.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridCrossAxisCountSubtitle": "Le nombre de tuiles à utiliser par rangée en orientation {value} lorsque l'affichage est en mode mosaïque.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "Sous-titre de l'élément de liste pour le nombre d'axes croisés de la grille. La valeur sera soit la clé 'portrait', soit la clé 'paysage'.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Paysage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Afficher le texte dans la grille de vue",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Afficher ou non le texte (titre, artiste, etc.) sur l'écran de la grille musicale.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Afficher la couverture floutée en arrière-plan du lecteur",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Choisir d'utiliser ou non l'illustration floutée comme arrière-plan sur l'écran du lecteur.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Masquer les artistes des chansons si identiques aux artistes de l'album",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Afficher ou non les artistes des chansons sur l'écran de l'album si différents des artistes de l'album.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"disableGesture": "Désactiver les gestes",
|
||||
"@disableGesture": {},
|
||||
"disableGestureSubtitle": "Indique si les gestes doivent être désactivés.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"showFastScroller": "Afficher le défileur rapide",
|
||||
"@showFastScroller": {},
|
||||
"theme": "Thème",
|
||||
"@theme": {},
|
||||
"system": "Système",
|
||||
"@system": {},
|
||||
"light": "Claire",
|
||||
"@light": {},
|
||||
"dark": "Sombre",
|
||||
"@dark": {},
|
||||
"tabs": "Onglets",
|
||||
"@tabs": {},
|
||||
"cancelSleepTimer": "Annuler le minuteur de sommeil ?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "OUI",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "NON",
|
||||
"@noButtonLabel": {},
|
||||
"setSleepTimer": "Configurer le minuteur de sommeil",
|
||||
"@setSleepTimer": {},
|
||||
"minutes": "Minutes",
|
||||
"@minutes": {},
|
||||
"invalidNumber": "Nombre invalide",
|
||||
"@invalidNumber": {},
|
||||
"sleepTimerTooltip": "Minuteur de sommeil",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTooltip": "Ajouter à une playlist",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"addToPlaylistTitle": "Ajouter à une playlist",
|
||||
"@addToPlaylistTitle": {},
|
||||
"removeFromPlaylistTooltip": "Retirer de la playlist",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"removeFromPlaylistTitle": "Retirer de la playlist",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"newPlaylist": "Nouvelle Playlist",
|
||||
"@newPlaylist": {},
|
||||
"createButtonLabel": "CRÉER",
|
||||
"@createButtonLabel": {},
|
||||
"playlistCreated": "Playlist créée.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "Aucun album",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Aucun élément",
|
||||
"@noItem": {},
|
||||
"noArtist": "Aucun artiste",
|
||||
"@noArtist": {},
|
||||
"unknownArtist": "Artiste inconnu",
|
||||
"@unknownArtist": {},
|
||||
"streaming": "STREAMING",
|
||||
"@streaming": {},
|
||||
"downloaded": "TÉLÉCHARGÉ",
|
||||
"@downloaded": {},
|
||||
"transcode": "TRANSCODAGE",
|
||||
"@transcode": {},
|
||||
"direct": "DIRECT",
|
||||
"@direct": {},
|
||||
"statusError": "STATUS D'ERREUR",
|
||||
"@statusError": {},
|
||||
"queue": "File d'attente",
|
||||
"@queue": {},
|
||||
"addToQueue": "Ajouter à la file d'attente",
|
||||
"@addToQueue": {
|
||||
"description": "Titre de l'élément du menu contextuel pour ajouter un élément à la fin de la file d'attente."
|
||||
},
|
||||
"playNext": "Lire ensuite",
|
||||
"@playNext": {
|
||||
"description": "Titre de l'élément du menu contextuel pour insérer un élément dans la file d'attente de lecture après l'élément en cours de lecture."
|
||||
},
|
||||
"replaceQueue": "Remplacer la file d'attente",
|
||||
"@replaceQueue": {},
|
||||
"instantMix": "Mix instantané",
|
||||
"@instantMix": {},
|
||||
"goToAlbum": "Voir l'album",
|
||||
"@goToAlbum": {},
|
||||
"removeFavourite": "Retirer des favoris",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Ajouter aux favoris",
|
||||
"@addFavourite": {},
|
||||
"addedToQueue": "Ajouté à la fin de la file d'attente.",
|
||||
"@addedToQueue": {
|
||||
"description": "Notification affichée lorsque l'utilisateur ajoute avec succès des éléments à la fin de la file d'attente de lecture."
|
||||
},
|
||||
"insertedIntoQueue": "Ajouté à la file d'attente.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Notification affichée lorsque l'utilisateur insère avec succès des éléments dans la file d'attente de lecture à un endroit qui n'est pas nécessairement à la fin."
|
||||
},
|
||||
"queueReplaced": "File d'attente remplacée.",
|
||||
"@queueReplaced": {},
|
||||
"removedFromPlaylist": "Retiré de la playlist.",
|
||||
"@removedFromPlaylist": {},
|
||||
"startingInstantMix": "Mix instantané lancé.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "Une erreur s'est produite.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError": "{error} Status code {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Interdit"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responseError401": "{error} Code de statut {statusCode}. Cela signifie probablement que vous avez utilisé un mauvais nom d'utilisateur/mot de passe, ou que votre client n'est plus connecté.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Non autorisé"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Retirer du Mix",
|
||||
"@removeFromMix": {},
|
||||
"addToMix": "Ajouter au Mix",
|
||||
"@addToMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{Aucun re-téléchargement nécessaire} =1{{count} élément re-téléchargé} other{{count} éléments re-téléchargés}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bufferDuration": "Taille du tampon",
|
||||
"@bufferDuration": {},
|
||||
"bufferDurationSubtitle": "Taille du tampon du lecteur, en secondes. Changer ce paramètre nécessite un redémarrage.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"language": "Langue",
|
||||
"confirm": "Confirmé",
|
||||
"showUncensoredLogMessage": "Ce journal contient vos informations de connexion. Afficher tout de même ?",
|
||||
"resetTabs": "Réinitialiser les onglets",
|
||||
"noMusicLibrariesTitle": "Aucune bibliothèque musicale",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Titre du message affiché sur l'écran de vues lorsque aucune bibliothèque musicale n'a été trouvée."
|
||||
},
|
||||
"noMusicLibrariesBody": "Finamp n'a pas trouvé de bibliothèques musicales. Veuillez vous assurer que votre serveur Jellyfin contient au moins une bibliothèque avec le type de contenu défini sur \"Musique\".",
|
||||
"refresh": "RAFRAÎCHIR",
|
||||
"swipeInsertQueueNext": "Lire la chanson balayée en prochain",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"swipeInsertQueueNextSubtitle": "Activer l'insertion d'une chanson comme élément suivant dans la file d'attente lorsqu'elle est balayée dans la liste des chansons, au lieu de l'ajouter à la fin.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"redesignBeta": "Essayer la bêta",
|
||||
"@redesignBeta": {},
|
||||
"playbackOrderShuffledTooltip": "Lecture en aléatoire. Touchez pour activer/désactiver.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Lecture dans l'ordre. Touchez pour activer/désactiver.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"loopModeAllTooltip": "Lecture en boucle de l'ensemble. Touchez pour activer/désactiver.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Lecture en boucle d'un seul. Touchez pour activer/désactiver.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"loopModeNoneTooltip": "Ne pas lire en boucle. Touchez pour activer/désactiver.",
|
||||
"@loopModeNoneTooltip": {},
|
||||
"skipToPrevious": "Passer à la chanson précédente",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Passer à la chanson suivante",
|
||||
"@skipToNext": {},
|
||||
"togglePlayback": "Activer/désactiver la lecture",
|
||||
"@togglePlayback": {},
|
||||
"playArtist": "Lire tous les albums de {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shuffleArtist": "Lire aléatoirement tous les albums de {artist}",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadArtist": "Télécharger tous les albums de {artist}",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFromDevice": "Supprimer de l'appareil",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Télécharger",
|
||||
"@download": {},
|
||||
"sync": "Synchroniser avec le serveur",
|
||||
"@sync": {},
|
||||
"about": "À propos de Finamp",
|
||||
"@about": {}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
{
|
||||
"serverUrl": "URL servera",
|
||||
"@serverUrl": {},
|
||||
"startupError": "Dogodila se greška prilikom pokretanja aplikacije. Greška: {error}\n\nPrijavi problem na github.com/UnicornsOnLSD/finamp sa snimkom ekrana ove stranice. Ako ovaj problem ustraje, izbriši svoje podatke aplikacije i resetiraj aplikaciju.",
|
||||
"@startupError": {
|
||||
"description": "The error message that shows when startup fails.",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Failed to open download DB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sortOrder": "Redoslijed razvrstavanja",
|
||||
"@sortOrder": {},
|
||||
"selectMusicLibraries": "Odaberi fonoteke",
|
||||
"@selectMusicLibraries": {
|
||||
"description": "App bar title for library select screen"
|
||||
},
|
||||
"username": "Korisničko ime",
|
||||
"@username": {},
|
||||
"password": "Lozinka",
|
||||
"@password": {},
|
||||
"logs": "Zapisi",
|
||||
"@logs": {},
|
||||
"next": "Dalje",
|
||||
"@next": {},
|
||||
"genres": "Žanrovi",
|
||||
"@genres": {},
|
||||
"music": "Glazba",
|
||||
"@music": {},
|
||||
"name": "Ime",
|
||||
"@name": {},
|
||||
"random": "Slučajno",
|
||||
"@random": {},
|
||||
"revenue": "Prihod",
|
||||
"@revenue": {},
|
||||
"runtime": "Vrijeme trajanja",
|
||||
"@runtime": {},
|
||||
"downloadMissingImages": "Preuzmi nedostajuće slike",
|
||||
"@downloadMissingImages": {},
|
||||
"noErrors": "Nema grešaka!",
|
||||
"@noErrors": {},
|
||||
"failedToGetSongFromDownloadId": "Neuspjelo dohvaćanje pjesme s ID-a preuzimanja",
|
||||
"@failedToGetSongFromDownloadId": {},
|
||||
"error": "Greška",
|
||||
"@error": {},
|
||||
"playButtonLabel": "POKRENI",
|
||||
"@playButtonLabel": {},
|
||||
"favourite": "Omiljeno",
|
||||
"@favourite": {},
|
||||
"location": "Lokacija",
|
||||
"@location": {},
|
||||
"applicationLegalese": "Licenca: Mozilla Public License 2.0. Izvorni kod je dostupan na:\n\ngithub.com/jmshrv/finamp",
|
||||
"@applicationLegalese": {},
|
||||
"transcoding": "Transkodiranje",
|
||||
"@transcoding": {},
|
||||
"logOut": "Odjavi se",
|
||||
"@logOut": {},
|
||||
"unknownError": "Nepoznata greška",
|
||||
"@unknownError": {},
|
||||
"pathReturnSlashErrorMessage": "Staze sa znakom „/” se ne mogu koristiti",
|
||||
"@pathReturnSlashErrorMessage": {},
|
||||
"shuffleAllSongCount": "Broj pjesama za miješanje",
|
||||
"@shuffleAllSongCount": {},
|
||||
"list": "Popis",
|
||||
"@list": {},
|
||||
"portrait": "Uspravno",
|
||||
"@portrait": {},
|
||||
"landscape": "Polegnuto",
|
||||
"@landscape": {},
|
||||
"gridCrossAxisCount": "{value} broj rešetkastih linija",
|
||||
"@gridCrossAxisCount": {
|
||||
"description": "List tile title for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "Portrait"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showTextOnGridView": "Prikaži tekst u rešetkastom prikazu",
|
||||
"@showTextOnGridView": {},
|
||||
"showTextOnGridViewSubtitle": "Da li prikazati tekst (naslov, izvođač itd.) u rešetkastom ekranu glazbe.",
|
||||
"@showTextOnGridViewSubtitle": {},
|
||||
"showCoverAsPlayerBackground": "Prikaži mutnu sliku omota kao pozadinu playera",
|
||||
"@showCoverAsPlayerBackground": {},
|
||||
"tabs": "Kartice",
|
||||
"@tabs": {},
|
||||
"streaming": "PRIJENOS",
|
||||
"@streaming": {},
|
||||
"statusError": "GREŠKA STANJA",
|
||||
"@statusError": {},
|
||||
"removedFromPlaylist": "Uklonjeno iz playliste.",
|
||||
"@removedFromPlaylist": {},
|
||||
"startingInstantMix": "Pokretanje instant miksa.",
|
||||
"@startingInstantMix": {},
|
||||
"anErrorHasOccured": "Dogodila se greška.",
|
||||
"@anErrorHasOccured": {},
|
||||
"responseError401": "{error} Kȏd stanja {statusCode}. Ovo vjerojatno znači da si koristio/la pokrešno korisničko ime/lozinku ili da tvoj klijent više nije prijavljen.",
|
||||
"@responseError401": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Unauthorized"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "401"
|
||||
}
|
||||
}
|
||||
},
|
||||
"removeFromMix": "Ukloni iz miksa",
|
||||
"@removeFromMix": {},
|
||||
"bufferDuration": "Trajanje međuspremnika",
|
||||
"@bufferDuration": {},
|
||||
"bufferDurationSubtitle": "Količina koju player treba spremiti u međuspremnik, u sekundama. Zahtijeva ponovno pokretanje.",
|
||||
"@bufferDurationSubtitle": {},
|
||||
"language": "Jezik",
|
||||
"@language": {},
|
||||
"unknownName": "Nepoznato ime",
|
||||
"@unknownName": {},
|
||||
"songs": "Pjesme",
|
||||
"@songs": {},
|
||||
"startMixNoSongsArtist": "Pritisni dugo na izvođača za dodavanje ili uklanjanje izvođača iz miksera prije pokretanja miksa",
|
||||
"@startMixNoSongsArtist": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no artists selected"
|
||||
},
|
||||
"urlStartWithHttps": "URL mora početi s http:// ili https://",
|
||||
"@urlStartWithHttps": {
|
||||
"description": "Error message that shows when the user submits a server URL that doesn't start with http:// or https:// (for example, ftp://0.0.0.0"
|
||||
},
|
||||
"artists": "Izvođači",
|
||||
"@artists": {},
|
||||
"internalExternalIpExplanation": "Ako želiš pristupiti Jellyfin serveru na daljinski način moraš korisititi tvoju externu IP adresu.\n\nAko je tvoj server na HTTP priključku (80/443), ne moraš navesti priključak. To će vjerojatno slučaj ako se tvoj server nalazi iza obrnutog proxija.",
|
||||
"@internalExternalIpExplanation": {
|
||||
"description": "Extra info for which IP to use for remote access, and info on whether or not the user needs to specify a port."
|
||||
},
|
||||
"emptyServerUrl": "URL servera ne smije biti prazan",
|
||||
"@emptyServerUrl": {
|
||||
"description": "Error message that shows when the user submits a login without a server URL"
|
||||
},
|
||||
"urlTrailingSlash": "URL ne smije sadržavati kosu crtu na kraju",
|
||||
"@urlTrailingSlash": {
|
||||
"description": "Error message that shows when the user submits a server URL that ends with a trailing slash (for example, http://0.0.0.0/)"
|
||||
},
|
||||
"albums": "Albumi",
|
||||
"@albums": {},
|
||||
"couldNotFindLibraries": "Nije bilo moguće pronaći niti jednu fonoteku.",
|
||||
"@couldNotFindLibraries": {
|
||||
"description": "Error message when the user does not have any libraries"
|
||||
},
|
||||
"playlists": "Playliste",
|
||||
"@playlists": {},
|
||||
"startMix": "Pokreni miks",
|
||||
"@startMix": {},
|
||||
"playCount": "Broj reprodukcija",
|
||||
"@playCount": {},
|
||||
"datePlayed": "Datum reprodukcije",
|
||||
"@datePlayed": {},
|
||||
"startMixNoSongsAlbum": "Pritisni dugo na album za dodavanje ili uklanjanje albuma iz miksera prije pokretanja miksa",
|
||||
"@startMixNoSongsAlbum": {
|
||||
"description": "Snackbar message that shows when the user presses the instant mix button with no albums selected"
|
||||
},
|
||||
"shuffleAll": "Izmiješaj sve",
|
||||
"@shuffleAll": {},
|
||||
"album": "Album",
|
||||
"@album": {},
|
||||
"communityRating": "Ocjena zajednice",
|
||||
"@communityRating": {},
|
||||
"clear": "Izbriši",
|
||||
"@clear": {},
|
||||
"finamp": "Finamp",
|
||||
"@finamp": {},
|
||||
"favourites": "Omiljeno",
|
||||
"@favourites": {},
|
||||
"downloads": "Preuzimanja",
|
||||
"@downloads": {},
|
||||
"settings": "Postavke",
|
||||
"@settings": {},
|
||||
"offlineMode": "Neumrežen modus",
|
||||
"@offlineMode": {},
|
||||
"sortBy": "Razvrstaj po",
|
||||
"@sortBy": {},
|
||||
"productionYear": "Godina produkcije",
|
||||
"@productionYear": {},
|
||||
"albumArtist": "Izvođač albuma",
|
||||
"@albumArtist": {},
|
||||
"dateAdded": "Datum dodavanja",
|
||||
"@dateAdded": {},
|
||||
"artist": "Izvođač",
|
||||
"@artist": {},
|
||||
"budget": "Budžet",
|
||||
"@budget": {},
|
||||
"premiereDate": "Datum premijere",
|
||||
"@premiereDate": {},
|
||||
"criticRating": "Ocjena kritičara",
|
||||
"@criticRating": {},
|
||||
"downloadedMissingImages": "{count,plural, =0{Nema nedostajućih slika} =1{Preuzeta je {count} nedostajuća slika} few{Preuzete su {count} nedostajuće slike} other{Preuzeto je {count} nedostajućih slika}}",
|
||||
"@downloadedMissingImages": {
|
||||
"description": "Message that shows when the user downloads missing images",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editPlaylistNameTitle": "Uredi ime playliste",
|
||||
"@editPlaylistNameTitle": {},
|
||||
"customLocation": "Prilagođena lokacija",
|
||||
"@customLocation": {},
|
||||
"viewTypeSubtitle": "Vrsta prikaza za ekran glazbe",
|
||||
"@viewTypeSubtitle": {},
|
||||
"downloadedItemsImagesCount": "{downloadedItems}, {downloadedImages}",
|
||||
"@downloadedItemsImagesCount": {
|
||||
"description": "This is for merging downloadedItemsCount and downloadedImagesCount as Flutter's intl stuff doesn't support multiple plurals in one string. https://github.com/flutter/flutter/issues/86906",
|
||||
"placeholders": {
|
||||
"downloadedItems": {
|
||||
"type": "String",
|
||||
"example": "12 downloads"
|
||||
},
|
||||
"downloadedImages": {
|
||||
"type": "String",
|
||||
"example": "1 image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discNumber": "Disk {number}",
|
||||
"@discNumber": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadCount": "{count,plural, =1{{count} preuzimanje} few{{count} preuzimanja} other{{count} preuzimanja}}",
|
||||
"@downloadCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrors": "Greške preuzimanja",
|
||||
"@downloadErrors": {},
|
||||
"downloadedItemsCount": "{count,plural,=1{{count} stavka} few{{count} stavke} other{{count} stavki}}",
|
||||
"@downloadedItemsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedImagesCount": "{count,plural,=1{{count} slika} few{{count} slike} other{{count} slika}}",
|
||||
"@downloadedImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlFailed": "{count} neuspjelo",
|
||||
"@dlFailed": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlComplete": "{count} dovršeno",
|
||||
"@dlComplete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadErrorsTitle": "Greške preuzimanja",
|
||||
"@downloadErrorsTitle": {},
|
||||
"editPlaylistNameTooltip": "Uredi ime playliste",
|
||||
"@editPlaylistNameTooltip": {},
|
||||
"playlistNameUpdated": "Ime playliste je ažurirano.",
|
||||
"@playlistNameUpdated": {},
|
||||
"dlEnqueued": "{count} dodano u red",
|
||||
"@dlEnqueued": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dlRunning": "{count} u tijeku",
|
||||
"@dlRunning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errorScreenError": "Dogodila se greška prilikom dohvaćanja popisa grešaka! Prijavi problem na GitHubu i izbriši podatke aplikacije",
|
||||
"@errorScreenError": {},
|
||||
"shuffleButtonLabel": "IZMIJEŠAJ",
|
||||
"@shuffleButtonLabel": {},
|
||||
"addDownloads": "Dodaj preuzimanja",
|
||||
"@addDownloads": {},
|
||||
"songCount": "{count,plural,=1{{count} pjesma} few{{count} pjesme} other{{count} pjesama}}",
|
||||
"@songCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": "Obavezno",
|
||||
"@required": {},
|
||||
"downloadsAdded": "Preuzimanja dodana.",
|
||||
"@downloadsAdded": {},
|
||||
"updateButtonLabel": "AŽURIRAJ",
|
||||
"@updateButtonLabel": {},
|
||||
"downloadsDeleted": "Preuzimanja izbrisana.",
|
||||
"@downloadsDeleted": {},
|
||||
"addButtonLabel": "DODAJ",
|
||||
"@addButtonLabel": {},
|
||||
"shareLogs": "Dijeli zapise",
|
||||
"@shareLogs": {},
|
||||
"logsCopied": "Zapisi su kopirani.",
|
||||
"@logsCopied": {},
|
||||
"message": "Poruka",
|
||||
"@message": {},
|
||||
"stackTrace": "Trag Stacka",
|
||||
"@stackTrace": {},
|
||||
"layoutAndTheme": "Izgled i tema",
|
||||
"@layoutAndTheme": {},
|
||||
"customLocationsBuggy": "Prilagođene lokacije su izrazito pune grešaka zbog problema oko dozvola. Razmišljam o načinima da ovo ispravim, ali za sada ne bih preporučio korištenje.",
|
||||
"@customLocationsBuggy": {},
|
||||
"shuffleAllSongCountSubtitle": "Broj pjesama koje se učitavaju kada se koristi gumb „Izmiješaj sve pjesme”.",
|
||||
"@shuffleAllSongCountSubtitle": {},
|
||||
"noArtist": "Nema izvođača",
|
||||
"@noArtist": {},
|
||||
"downloadLocations": "Lokacija preuzimanja",
|
||||
"@downloadLocations": {},
|
||||
"audioService": "Usluga audioreprodukcije",
|
||||
"@audioService": {},
|
||||
"areYouSure": "Jeste li sigurni?",
|
||||
"@areYouSure": {},
|
||||
"jellyfinUsesAACForTranscoding": "Jellyfin koristi AAC za transkodiranje",
|
||||
"@jellyfinUsesAACForTranscoding": {},
|
||||
"notAvailableInOfflineMode": "Nije dostupno u neumreženom modusu",
|
||||
"@notAvailableInOfflineMode": {},
|
||||
"addDownloadLocation": "Dodaj lokaciju preuzimanja",
|
||||
"@addDownloadLocation": {},
|
||||
"directoryMustBeEmpty": "Direktorij mora biti prazan",
|
||||
"@directoryMustBeEmpty": {},
|
||||
"enterLowPriorityStateOnPauseSubtitle": "Omogućuje brisanje obavijesti kada je pauzirano. Također omogućuje Androidu da prekine uslugu kada je pauzirana.",
|
||||
"@enterLowPriorityStateOnPauseSubtitle": {},
|
||||
"grid": "Rešetka",
|
||||
"@grid": {},
|
||||
"showCoverAsPlayerBackgroundSubtitle": "Da li koristiti mutnu sliku omota kao pozadinu na ekranu playera.",
|
||||
"@showCoverAsPlayerBackgroundSubtitle": {},
|
||||
"disableGesture": "Deaktiviraj geste",
|
||||
"@disableGesture": {},
|
||||
"theme": "Tema",
|
||||
"@theme": {},
|
||||
"dark": "Tamna",
|
||||
"@dark": {},
|
||||
"downloadedSongsWillNotBeDeleted": "Preuzete pjesme neće biti izbrisane",
|
||||
"@downloadedSongsWillNotBeDeleted": {},
|
||||
"bitrate": "Brzina prijenosa",
|
||||
"@bitrate": {},
|
||||
"bitrateSubtitle": "Veća brzina prijenosa daje veću kvalitetu zvuka, ali troši veću količinu prometa.",
|
||||
"@bitrateSubtitle": {},
|
||||
"setSleepTimer": "Postavi odbrojavanje",
|
||||
"@setSleepTimer": {},
|
||||
"downloaded": "PREUZETO",
|
||||
"@downloaded": {},
|
||||
"selectDirectory": "Odaberi direktorij",
|
||||
"@selectDirectory": {},
|
||||
"enableTranscoding": "Omogući transkodiranje",
|
||||
"@enableTranscoding": {},
|
||||
"enableTranscodingSubtitle": "Transkodira stream glazbe na server strani.",
|
||||
"@enableTranscodingSubtitle": {},
|
||||
"addToPlaylistTooltip": "Dodaj u playlistu",
|
||||
"@addToPlaylistTooltip": {},
|
||||
"goToAlbum": "Idi na album",
|
||||
"@goToAlbum": {},
|
||||
"appDirectory": "Direktorij aplikacije",
|
||||
"@appDirectory": {},
|
||||
"enterLowPriorityStateOnPause": "Unesite stanje niskog prioriteta za vrijeme pauze",
|
||||
"@enterLowPriorityStateOnPause": {},
|
||||
"gridCrossAxisCountSubtitle": "Količina rešetkastih polja po redu kada {value}.",
|
||||
"@gridCrossAxisCountSubtitle": {
|
||||
"description": "List tile subtitle for grid cross axis count. Value will either be the portrait or landscape key.",
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String",
|
||||
"example": "landscape"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hideSongArtistsIfSameAsAlbumArtists": "Sakrij izvođače pjesama ako su isti kao izvođači albuma",
|
||||
"@hideSongArtistsIfSameAsAlbumArtists": {},
|
||||
"minutes": "Minute",
|
||||
"@minutes": {},
|
||||
"viewType": "Vrsta prikaza",
|
||||
"@viewType": {},
|
||||
"createButtonLabel": "STVORI",
|
||||
"@createButtonLabel": {},
|
||||
"sleepTimerTooltip": "Odbrojavanje",
|
||||
"@sleepTimerTooltip": {},
|
||||
"addToPlaylistTitle": "Dodaj u playlistu",
|
||||
"@addToPlaylistTitle": {},
|
||||
"removeFromPlaylistTitle": "Ukloni iz playliste",
|
||||
"@removeFromPlaylistTitle": {},
|
||||
"newPlaylist": "Nova playlista",
|
||||
"@newPlaylist": {},
|
||||
"hideSongArtistsIfSameAsAlbumArtistsSubtitle": "Da li prikazati izvođače pjesama na ekranu albuma ako se ne razlikuju od izvođača albuma.",
|
||||
"@hideSongArtistsIfSameAsAlbumArtistsSubtitle": {},
|
||||
"disableGestureSubtitle": "Da li deaktivirati geste.",
|
||||
"@disableGestureSubtitle": {},
|
||||
"system": "Sustav",
|
||||
"@system": {},
|
||||
"light": "Svijetla",
|
||||
"@light": {},
|
||||
"cancelSleepTimer": "Prekinuti odbrojavanje?",
|
||||
"@cancelSleepTimer": {},
|
||||
"yesButtonLabel": "DA",
|
||||
"@yesButtonLabel": {},
|
||||
"noButtonLabel": "NE",
|
||||
"@noButtonLabel": {},
|
||||
"invalidNumber": "Neispravan broj",
|
||||
"@invalidNumber": {},
|
||||
"removeFromPlaylistTooltip": "Ukloni iz playliste",
|
||||
"@removeFromPlaylistTooltip": {},
|
||||
"unknownArtist": "Nepoznat izvođač",
|
||||
"@unknownArtist": {},
|
||||
"transcode": "TRANSKODIRAJ",
|
||||
"@transcode": {},
|
||||
"playlistCreated": "Playlista je stvorena.",
|
||||
"@playlistCreated": {},
|
||||
"noAlbum": "Nema albuma",
|
||||
"@noAlbum": {},
|
||||
"noItem": "Nema stavki",
|
||||
"@noItem": {},
|
||||
"queue": "Red čekanja",
|
||||
"@queue": {},
|
||||
"direct": "DIREKTNO",
|
||||
"@direct": {},
|
||||
"addToQueue": "Dodaj u red čekanja",
|
||||
"@addToQueue": {},
|
||||
"instantMix": "Instant miks",
|
||||
"@instantMix": {},
|
||||
"responseError": "{error} Kȏd stanja {statusCode}.",
|
||||
"@responseError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String",
|
||||
"example": "Forbidden"
|
||||
},
|
||||
"statusCode": {
|
||||
"type": "int",
|
||||
"example": "403"
|
||||
}
|
||||
}
|
||||
},
|
||||
"replaceQueue": "Zamijeni red čekanja",
|
||||
"@replaceQueue": {},
|
||||
"addedToQueue": "Dodano u red čekanja.",
|
||||
"@addedToQueue": {},
|
||||
"removeFavourite": "Izbriši omiljene",
|
||||
"@removeFavourite": {},
|
||||
"addFavourite": "Dodaj omiljene",
|
||||
"@addFavourite": {},
|
||||
"queueReplaced": "Red čekanja je zamijenjen.",
|
||||
"@queueReplaced": {},
|
||||
"addToMix": "Dodaj u miks",
|
||||
"@addToMix": {},
|
||||
"redownloadedItems": "{count,plural, =0{Ponovna preuzimanja nisu potrebna.} =1{Ponovo je preuzeta {count} stavka} few{Ponovo su preuzete {count} stavke} other{Ponovo je preuzeto {count} stavki}}",
|
||||
"@redownloadedItems": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirm": "Potvrdi",
|
||||
"@confirm": {},
|
||||
"showUncensoredLogMessage": "Ovaj zapis sadrži tvoje podatke za prijavu. Prikazati?",
|
||||
"@showUncensoredLogMessage": {},
|
||||
"resetTabs": "Resetiraj kartice",
|
||||
"@resetTabs": {},
|
||||
"insertedIntoQueue": "Umetnuto u red čekanja.",
|
||||
"@insertedIntoQueue": {
|
||||
"description": "Snackbar message that shows when the user successfully inserts items into the play queue at a location that is not necessarily the end."
|
||||
},
|
||||
"playNext": "Reproduciraj kao sljedeću",
|
||||
"@playNext": {
|
||||
"description": "Popup menu item title for inserting an item into the play queue after the currently-playing item."
|
||||
},
|
||||
"refresh": "AKTUALIZIRAJ",
|
||||
"@refresh": {},
|
||||
"noMusicLibrariesBody": "Finamp nije mogao pronaći nijednu fonoteku. Provjeri sadrži li tvoj Jellyfin poslužitelj barem jednu biblioteku s vrstom sadržaja postavljenom na „Glazba”.",
|
||||
"@noMusicLibrariesBody": {},
|
||||
"noMusicLibrariesTitle": "Nema fonoteka",
|
||||
"@noMusicLibrariesTitle": {
|
||||
"description": "Title for message that shows on the views screen when no music libraries could be found."
|
||||
},
|
||||
"deleteDownloadsConfirmButtonText": "Izbriši",
|
||||
"@deleteDownloadsConfirmButtonText": {
|
||||
"description": "Shown in the confirmation dialog for deleting downloaded media from the local device."
|
||||
},
|
||||
"syncDownloadedPlaylists": "Sinkroniziraj preuzete playliste",
|
||||
"@syncDownloadedPlaylists": {},
|
||||
"deleteDownloadsAbortButtonText": "Odustani",
|
||||
"@deleteDownloadsAbortButtonText": {},
|
||||
"showFastScroller": "Prikaži traku za brzo listanje",
|
||||
"@showFastScroller": {},
|
||||
"deleteDownloadsPrompt": "Stvarno želiš izbrisati {itemType, select, album{album} playlist{playlistu} artist{izvođača} genre{žanr} track{pjesmu} other{}} '{itemName}' s ovog uređaja?",
|
||||
"@deleteDownloadsPrompt": {
|
||||
"placeholders": {
|
||||
"itemName": {
|
||||
"type": "String",
|
||||
"example": "Abandon Ship"
|
||||
},
|
||||
"itemType": {
|
||||
"type": "String",
|
||||
"example": "album"
|
||||
}
|
||||
},
|
||||
"description": "Confirmation prompt shown before deleting downloaded media from the local device, destructive action, doesn't affect the media on the server."
|
||||
},
|
||||
"swipeInsertQueueNextSubtitle": "Omogući umetanje pjesme kao sljedeću pjesmu u redu reprodukcije povlačenjem pjesme iz popisa pjesama umjesto dodavanja pjesme na kraj popisa.",
|
||||
"@swipeInsertQueueNextSubtitle": {},
|
||||
"interactions": "Interakcije",
|
||||
"@interactions": {},
|
||||
"swipeInsertQueueNext": "Reproduciraj pjesmu kao sljedeću povlačenjem",
|
||||
"@swipeInsertQueueNext": {},
|
||||
"redesignBeta": "Probaj beta verziju",
|
||||
"@redesignBeta": {},
|
||||
"skipToPrevious": "Prijeđi na prethodnu pjesmu",
|
||||
"@skipToPrevious": {},
|
||||
"skipToNext": "Prijeđi na sljedeću pjesmu",
|
||||
"@skipToNext": {},
|
||||
"deleteFromDevice": "Izbriši s uređaja",
|
||||
"@deleteFromDevice": {},
|
||||
"download": "Preuzmi",
|
||||
"@download": {},
|
||||
"sync": "Sinkroniziraj sa serverom",
|
||||
"@sync": {},
|
||||
"playbackOrderShuffledTooltip": "Miješanje. Uključi/isključi dodirom.",
|
||||
"@playbackOrderShuffledTooltip": {},
|
||||
"playbackOrderLinearTooltip": "Reprodukcija redom. Uključi/isključi dodirom.",
|
||||
"@playbackOrderLinearTooltip": {},
|
||||
"togglePlayback": "Uključi/isključi reprodukciju",
|
||||
"@togglePlayback": {},
|
||||
"loopModeAllTooltip": "Ponavljanje svih. Uključi/isključi dodirom.",
|
||||
"@loopModeAllTooltip": {},
|
||||
"loopModeOneTooltip": "Ponavljanje jedne. Uključi/isključi dodirom.",
|
||||
"@loopModeOneTooltip": {},
|
||||
"about": "O aplikaciji Finamp",
|
||||
"@about": {},
|
||||
"shuffleArtist": "Izmiješaj sve albume od {artist}",
|
||||
"@shuffleArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playArtist": "Reproduciraj sve albume od {artist}",
|
||||
"@playArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadArtist": "Preuzmi sve albume od {artist}",
|
||||
"@downloadArtist": {
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String",
|
||||
"example": "The Beatles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loopModeNoneTooltip": "Bez ponavljanja. Uključi/isključi dodirom.",
|
||||
"@loopModeNoneTooltip": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user