mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
Compare commits
11 Commits
1c76293559
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0bb26f84b | ||
|
|
e94fcf3128 | ||
|
|
bd6e818f36 | ||
|
|
96d0c35c31 | ||
|
|
4ef3281a0b | ||
|
|
c56e3dba0f | ||
|
|
53d284ace4 | ||
|
|
c2733482e5 | ||
|
|
67f0c926c4 | ||
|
|
889be2ff2c | ||
|
|
52b51954aa |
@@ -127,31 +127,8 @@
|
||||
],
|
||||
|
||||
"de": [
|
||||
"actionsDownloadDelete",
|
||||
"actionsOk",
|
||||
"resourcesAlbumCount",
|
||||
"resourcesArtistCount",
|
||||
"resourcesFilterAlbum",
|
||||
"resourcesFilterArtist",
|
||||
"resourcesFilterOwner",
|
||||
"resourcesFilterYear",
|
||||
"resourcesPlaylistCount",
|
||||
"resourcesSongCount",
|
||||
"resourcesSongListDeleteAllContent",
|
||||
"resourcesSongListDeleteAllTitle",
|
||||
"resourcesSortByAlbum",
|
||||
"resourcesSortByAlbumCount",
|
||||
"resourcesSortByTitle",
|
||||
"resourcesSortByUpdated",
|
||||
"settingsAboutActionsSupport",
|
||||
"settingsAboutShareLogs",
|
||||
"settingsAboutChooseLog",
|
||||
"settingsNetworkOptionsOfflineMode",
|
||||
"settingsNetworkOptionsOfflineModeOff",
|
||||
"settingsNetworkOptionsOfflineModeOn",
|
||||
"settingsNetworkOptionsStreamFormat",
|
||||
"settingsNetworkOptionsStreamFormatServerDefault",
|
||||
"settingsServersFieldsName"
|
||||
"settingsAboutChooseLog"
|
||||
],
|
||||
|
||||
"es": [
|
||||
@@ -213,11 +190,6 @@
|
||||
"settingsServersFieldsName"
|
||||
],
|
||||
|
||||
"gl": [
|
||||
"settingsAboutShareLogs",
|
||||
"settingsAboutChooseLog"
|
||||
],
|
||||
|
||||
"it": [
|
||||
"actionsCancel",
|
||||
"actionsDelete",
|
||||
@@ -444,39 +416,6 @@
|
||||
"settingsServersFieldsName"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"actionsCancel",
|
||||
"actionsDelete",
|
||||
"actionsDownload",
|
||||
"actionsDownloadCancel",
|
||||
"actionsDownloadDelete",
|
||||
"actionsOk",
|
||||
"controlsShuffle",
|
||||
"resourcesAlbumCount",
|
||||
"resourcesArtistCount",
|
||||
"resourcesFilterAlbum",
|
||||
"resourcesFilterArtist",
|
||||
"resourcesFilterOwner",
|
||||
"resourcesFilterYear",
|
||||
"resourcesPlaylistCount",
|
||||
"resourcesSongCount",
|
||||
"resourcesSongListDeleteAllContent",
|
||||
"resourcesSongListDeleteAllTitle",
|
||||
"resourcesSortByAlbum",
|
||||
"resourcesSortByAlbumCount",
|
||||
"resourcesSortByTitle",
|
||||
"resourcesSortByUpdated",
|
||||
"settingsAboutActionsSupport",
|
||||
"settingsAboutShareLogs",
|
||||
"settingsAboutChooseLog",
|
||||
"settingsNetworkOptionsOfflineMode",
|
||||
"settingsNetworkOptionsOfflineModeOff",
|
||||
"settingsNetworkOptionsOfflineModeOn",
|
||||
"settingsNetworkOptionsStreamFormat",
|
||||
"settingsNetworkOptionsStreamFormatServerDefault",
|
||||
"settingsServersFieldsName"
|
||||
],
|
||||
|
||||
"tr": [
|
||||
"actionsCancel",
|
||||
"actionsDelete",
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
import '../services/sync_service.dart';
|
||||
import 'items.dart';
|
||||
import 'snackbars.dart';
|
||||
|
||||
class PagedListQueryView<T> extends HookConsumerWidget {
|
||||
final PagingController<int, T> pagingController;
|
||||
@@ -122,7 +123,13 @@ class SyncAllRefresh extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(syncServiceProvider.notifier).syncAll(),
|
||||
onRefresh: () async {
|
||||
try {
|
||||
await ref.read(syncServiceProvider.notifier).syncAll();
|
||||
} catch (e) {
|
||||
showErrorSnackbar(context, e.toString());
|
||||
}
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../log.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../items.dart';
|
||||
import '../snackbars.dart';
|
||||
|
||||
class SourcePage extends HookConsumerWidget {
|
||||
final int? id;
|
||||
@@ -45,7 +46,7 @@ class SourcePage extends HookConsumerWidget {
|
||||
autofillHints: const [AutofillHints.url],
|
||||
required: true,
|
||||
validator: (value, label) {
|
||||
if (Uri.tryParse(value!) == null) {
|
||||
if (!value!.contains(RegExp(r'https?:\/\/'))) {
|
||||
return '$label must be a valid URL';
|
||||
}
|
||||
return null;
|
||||
@@ -163,7 +164,7 @@ class SourcePage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
// TOOD: toast the error or whatever
|
||||
showErrorSnackbar(context, e.toString());
|
||||
log.severe('Saving source', e, st);
|
||||
error = true;
|
||||
} finally {
|
||||
|
||||
14
lib/app/snackbars.dart
Normal file
14
lib/app/snackbars.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void showErrorSnackbar(BuildContext context, String message) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(message, style: TextStyle(color: colors.onErrorContainer)),
|
||||
backgroundColor: colors.errorContainer,
|
||||
showCloseIcon: true,
|
||||
closeIconColor: colors.onErrorContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 10),
|
||||
));
|
||||
}
|
||||
@@ -15,26 +15,17 @@ class SubtracksHttpClient extends BaseClient {
|
||||
@override
|
||||
Future<StreamedResponse> send(BaseRequest request) {
|
||||
request.headers.addAll(subtracksHeaders);
|
||||
log.info('${request.method} ${_redactUri(request.url)}');
|
||||
return request.send();
|
||||
log.info('${request.method} ${request.url}');
|
||||
|
||||
try {
|
||||
return request.send();
|
||||
} catch (e, st) {
|
||||
log.severe('HTTP client: ${request.method} ${request.url}', e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _redactUri(Uri uri) {
|
||||
var redacted = uri.toString();
|
||||
redacted = _redactParam(redacted, 'u');
|
||||
redacted = _redactParam(redacted, 'p');
|
||||
redacted = _redactParam(redacted, 's');
|
||||
redacted = _redactParam(redacted, 't');
|
||||
|
||||
return redacted.toString();
|
||||
}
|
||||
|
||||
RegExp _queryReplace(String key) => RegExp('$key=([^&|\\n|\\t\\s]+)');
|
||||
|
||||
String _redactParam(String url, String key) =>
|
||||
url.replaceFirst(_queryReplace(key), '$key=REDACTED');
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
BaseClient httpClient(HttpClientRef ref) {
|
||||
return SubtracksHttpClient();
|
||||
|
||||
@@ -202,5 +202,75 @@
|
||||
"controlsShuffle": "Zufall",
|
||||
"@controlsShuffle": {},
|
||||
"actionsCancel": "Abbrechen",
|
||||
"@actionsCancel": {}
|
||||
"@actionsCancel": {},
|
||||
"actionsDownloadDelete": "Heruntergeladene Inhalte löschen",
|
||||
"@actionsDownloadDelete": {},
|
||||
"actionsOk": "OK",
|
||||
"@actionsOk": {},
|
||||
"resourcesAlbumCount": "{count,plural, =1{{count} Album} other{{count} Alben}}",
|
||||
"@resourcesAlbumCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesFilterAlbum": "Album",
|
||||
"@resourcesFilterAlbum": {},
|
||||
"resourcesArtistCount": "{count,plural, =1{{count} Künstler} other{{count} Künstler}}",
|
||||
"@resourcesArtistCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesFilterArtist": "Künstler",
|
||||
"@resourcesFilterArtist": {},
|
||||
"resourcesFilterOwner": "Besitzer",
|
||||
"@resourcesFilterOwner": {},
|
||||
"resourcesFilterYear": "Jahr",
|
||||
"@resourcesFilterYear": {},
|
||||
"resourcesPlaylistCount": "{count,plural, =1{{count} Playlist} other{{count} Playlists}}",
|
||||
"@resourcesPlaylistCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSongCount": "{count,plural, =1{{count} Song} other{{count} Songs}}",
|
||||
"@resourcesSongCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSongListDeleteAllContent": "Hierdurch werden alle heruntergeladenen Inhalte entfernt.",
|
||||
"@resourcesSongListDeleteAllContent": {},
|
||||
"resourcesSortByAlbum": "Album",
|
||||
"@resourcesSortByAlbum": {},
|
||||
"resourcesSortByAlbumCount": "Albenanzahl",
|
||||
"@resourcesSortByAlbumCount": {},
|
||||
"resourcesSortByTitle": "Titel",
|
||||
"@resourcesSortByTitle": {},
|
||||
"resourcesSortByUpdated": "Kürzlich hinzugefügt",
|
||||
"@resourcesSortByUpdated": {},
|
||||
"settingsAboutActionsSupport": "Den Entwickler unterstützen",
|
||||
"@settingsAboutActionsSupport": {},
|
||||
"settingsNetworkOptionsOfflineMode": "Offline Modus",
|
||||
"@settingsNetworkOptionsOfflineMode": {},
|
||||
"settingsNetworkOptionsOfflineModeOff": "Nutze das Internet um Musik zu synchronisieren.",
|
||||
"@settingsNetworkOptionsOfflineModeOff": {},
|
||||
"settingsNetworkOptionsOfflineModeOn": "Nutze nicht das Internet um Musik zu synchronisieren.",
|
||||
"@settingsNetworkOptionsOfflineModeOn": {},
|
||||
"settingsNetworkOptionsStreamFormat": "Bevorzugtes Streaming-Format",
|
||||
"@settingsNetworkOptionsStreamFormat": {},
|
||||
"settingsServersFieldsName": "Name",
|
||||
"@settingsServersFieldsName": {},
|
||||
"resourcesSongListDeleteAllTitle": "Downloads löschen?",
|
||||
"@resourcesSongListDeleteAllTitle": {},
|
||||
"settingsNetworkOptionsStreamFormatServerDefault": "Server-Standard verwenden",
|
||||
"@settingsNetworkOptionsStreamFormatServerDefault": {}
|
||||
}
|
||||
|
||||
@@ -272,5 +272,9 @@
|
||||
"resourcesSortByTitle": "Título",
|
||||
"@resourcesSortByTitle": {},
|
||||
"resourcesSortByUpdated": "Actualizado recentemente",
|
||||
"@resourcesSortByUpdated": {}
|
||||
"@resourcesSortByUpdated": {},
|
||||
"settingsAboutShareLogs": "Compartir rexistros",
|
||||
"@settingsAboutShareLogs": {},
|
||||
"settingsAboutChooseLog": "Escolle un ficheiro de rexistro",
|
||||
"@settingsAboutChooseLog": {}
|
||||
}
|
||||
|
||||
@@ -1,196 +1,280 @@
|
||||
{
|
||||
"actionsStar": "Избранное",
|
||||
"@actionsStar": {},
|
||||
"actionsUnstar": "Убрать из избранного",
|
||||
"@actionsUnstar": {},
|
||||
"messagesNothingHere": "Здесь ничего нет…",
|
||||
"@messagesNothingHere": {},
|
||||
"navigationTabsHome": "Главная",
|
||||
"@navigationTabsHome": {},
|
||||
"navigationTabsLibrary": "Библиотека",
|
||||
"@navigationTabsLibrary": {},
|
||||
"navigationTabsSearch": "Поиск",
|
||||
"@navigationTabsSearch": {},
|
||||
"navigationTabsSettings": "Настройки",
|
||||
"@navigationTabsSettings": {},
|
||||
"resourcesAlbumActionsPlay": "Воспроизвести альбом",
|
||||
"@resourcesAlbumActionsPlay": {},
|
||||
"resourcesAlbumActionsView": "Посмотреть альбом",
|
||||
"@resourcesAlbumActionsView": {},
|
||||
"resourcesAlbumListsSort": "Сортировка альбомов",
|
||||
"@resourcesAlbumListsSort": {},
|
||||
"resourcesAlbumName": "{count,plural, =1{Альбом} few{Альбомы} many{Альбомов} other{Альбомов}}",
|
||||
"@resourcesAlbumName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesArtistActionsView": "Посмотреть исполнителя",
|
||||
"@resourcesArtistActionsView": {},
|
||||
"resourcesArtistListsSort": "Сортировать исполнителей",
|
||||
"@resourcesArtistListsSort": {},
|
||||
"resourcesArtistName": "{count,plural, =1{Исполнитель} few{Исполнители} many{Исполнителей} other{Исполнителей}}",
|
||||
"@resourcesArtistName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesFilterGenre": "По жанру",
|
||||
"@resourcesFilterGenre": {},
|
||||
"resourcesFilterStarred": "Избранные",
|
||||
"@resourcesFilterStarred": {},
|
||||
"resourcesPlaylistActionsPlay": "Воспроизвести плейлист",
|
||||
"@resourcesPlaylistActionsPlay": {},
|
||||
"resourcesPlaylistName": "{count,plural, =1{Плейлист} few{Плейлисты} many{Плейлистов} other{Плейлистов}}",
|
||||
"@resourcesPlaylistName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesQueueName": "{count,plural, =1{Очередь} few{Очереди} many{Очередей} other{Очередей}}",
|
||||
"@resourcesQueueName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSongListsArtistTopSongs": "Лучшие треки",
|
||||
"@resourcesSongListsArtistTopSongs": {},
|
||||
"resourcesSongName": "{count,plural, =1{Трек} few{Трека} many{Треков} other{Треков}}",
|
||||
"@resourcesSongName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSortByAdded": "Недавно добавленные",
|
||||
"@resourcesSortByAdded": {},
|
||||
"resourcesSortByArtist": "По исполнителю",
|
||||
"@resourcesSortByArtist": {},
|
||||
"resourcesSortByFrequentlyPlayed": "Часто проигрываемые",
|
||||
"@resourcesSortByFrequentlyPlayed": {},
|
||||
"resourcesSortByName": "По имени",
|
||||
"@resourcesSortByName": {},
|
||||
"resourcesSortByRandom": "Случайно",
|
||||
"@resourcesSortByRandom": {},
|
||||
"resourcesSortByRecentlyPlayed": "Недавно проигранные",
|
||||
"@resourcesSortByRecentlyPlayed": {},
|
||||
"resourcesSortByYear": "По году",
|
||||
"@resourcesSortByYear": {},
|
||||
"searchHeaderTitle": "Поиск: {query}",
|
||||
"@searchHeaderTitle": {
|
||||
"placeholders": {
|
||||
"query": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"searchInputPlaceholder": "Поиск",
|
||||
"@searchInputPlaceholder": {},
|
||||
"searchMoreResults": "Больше…",
|
||||
"@searchMoreResults": {},
|
||||
"searchNowPlayingContext": "Результаты поиска",
|
||||
"@searchNowPlayingContext": {},
|
||||
"settingsAboutActionsLicenses": "Лицензии",
|
||||
"@settingsAboutActionsLicenses": {},
|
||||
"settingsAboutActionsProjectHomepage": "Сайт проекта",
|
||||
"@settingsAboutActionsProjectHomepage": {},
|
||||
"settingsAboutName": "О Subtracks",
|
||||
"@settingsAboutName": {},
|
||||
"settingsAboutVersion": "версия {version}",
|
||||
"@settingsAboutVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsMusicName": "Музыка",
|
||||
"@settingsMusicName": {},
|
||||
"settingsMusicOptionsScrobbleDescriptionOff": "Не отправлять историю воспроизведений",
|
||||
"@settingsMusicOptionsScrobbleDescriptionOff": {},
|
||||
"settingsMusicOptionsScrobbleDescriptionOn": "Скробблинг истории воспроизведения",
|
||||
"@settingsMusicOptionsScrobbleDescriptionOn": {},
|
||||
"settingsMusicOptionsScrobbleTitle": "Скробблинг",
|
||||
"@settingsMusicOptionsScrobbleTitle": {},
|
||||
"settingsNetworkName": "Сеть",
|
||||
"@settingsNetworkName": {},
|
||||
"settingsNetworkOptionsMaxBitrateMobileTitle": "Максимальный битрейт (мобильный интернет)",
|
||||
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
|
||||
"settingsNetworkOptionsMaxBitrateWifiTitle": "Максимальный битрейт (Wi-Fi)",
|
||||
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
|
||||
"settingsNetworkOptionsMaxBufferTitle": "Максимальное время буферизации",
|
||||
"@settingsNetworkOptionsMaxBufferTitle": {},
|
||||
"settingsNetworkOptionsMinBufferTitle": "Минимальное время буферизации",
|
||||
"@settingsNetworkOptionsMinBufferTitle": {},
|
||||
"settingsNetworkValuesKbps": "{value} кбит/с",
|
||||
"@settingsNetworkValuesKbps": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsNetworkValuesSeconds": "{value} секунд",
|
||||
"@settingsNetworkValuesSeconds": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsNetworkValuesUnlimitedKbps": "Без ограничений",
|
||||
"@settingsNetworkValuesUnlimitedKbps": {},
|
||||
"settingsResetActionsClearImageCache": "Очистить кэш изображения",
|
||||
"@settingsResetActionsClearImageCache": {},
|
||||
"settingsResetName": "Сброс",
|
||||
"@settingsResetName": {},
|
||||
"settingsServersActionsAdd": "Добавить сервер",
|
||||
"@settingsServersActionsAdd": {},
|
||||
"settingsServersActionsDelete": "Удалить",
|
||||
"@settingsServersActionsDelete": {},
|
||||
"settingsServersActionsEdit": "Редактировать сервер",
|
||||
"@settingsServersActionsEdit": {},
|
||||
"settingsServersActionsSave": "Сохранить",
|
||||
"@settingsServersActionsSave": {},
|
||||
"settingsServersActionsTestConnection": "Проверить подключение",
|
||||
"@settingsServersActionsTestConnection": {},
|
||||
"settingsServersFieldsAddress": "Адрес",
|
||||
"@settingsServersFieldsAddress": {},
|
||||
"settingsServersFieldsPassword": "Пароль",
|
||||
"@settingsServersFieldsPassword": {},
|
||||
"settingsServersFieldsUsername": "Имя пользователя",
|
||||
"@settingsServersFieldsUsername": {},
|
||||
"settingsServersMessagesConnectionFailed": "Не удалось подключиться к {address}, проверьте настройки или сервер",
|
||||
"@settingsServersMessagesConnectionFailed": {
|
||||
"placeholders": {
|
||||
"address": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsServersMessagesConnectionOk": "Подключение к {address} установлено!",
|
||||
"@settingsServersMessagesConnectionOk": {
|
||||
"placeholders": {
|
||||
"address": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsServersName": "Серверы",
|
||||
"@settingsServersName": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Отправить пароль в виде токена",
|
||||
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Отправить пароль в виде текста (устарело, убедитесь, что ваше соединение безопасно!)",
|
||||
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordTitle": "Принудительно использовать текстовой пароль",
|
||||
"@settingsServersOptionsForcePlaintextPasswordTitle": {}
|
||||
}
|
||||
"actionsStar": "Избранное",
|
||||
"@actionsStar": {},
|
||||
"actionsUnstar": "Убрать из избранного",
|
||||
"@actionsUnstar": {},
|
||||
"messagesNothingHere": "Здесь ничего нет…",
|
||||
"@messagesNothingHere": {},
|
||||
"navigationTabsHome": "Главная",
|
||||
"@navigationTabsHome": {},
|
||||
"navigationTabsLibrary": "Библиотека",
|
||||
"@navigationTabsLibrary": {},
|
||||
"navigationTabsSearch": "Поиск",
|
||||
"@navigationTabsSearch": {},
|
||||
"navigationTabsSettings": "Настройки",
|
||||
"@navigationTabsSettings": {},
|
||||
"resourcesAlbumActionsPlay": "Воспроизвести альбом",
|
||||
"@resourcesAlbumActionsPlay": {},
|
||||
"resourcesAlbumActionsView": "Посмотреть альбом",
|
||||
"@resourcesAlbumActionsView": {},
|
||||
"resourcesAlbumListsSort": "Сортировка альбомов",
|
||||
"@resourcesAlbumListsSort": {},
|
||||
"resourcesAlbumName": "{count,plural, =1{Альбом} few{Альбомы} many{Альбомов} other{Альбомов}}",
|
||||
"@resourcesAlbumName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesArtistActionsView": "Посмотреть исполнителя",
|
||||
"@resourcesArtistActionsView": {},
|
||||
"resourcesArtistListsSort": "Сортировать исполнителей",
|
||||
"@resourcesArtistListsSort": {},
|
||||
"resourcesArtistName": "{count,plural, =1{Исполнитель} few{Исполнители} many{Исполнителей} other{Исполнителей}}",
|
||||
"@resourcesArtistName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesFilterGenre": "По жанру",
|
||||
"@resourcesFilterGenre": {},
|
||||
"resourcesFilterStarred": "Избранные",
|
||||
"@resourcesFilterStarred": {},
|
||||
"resourcesPlaylistActionsPlay": "Воспроизвести плейлист",
|
||||
"@resourcesPlaylistActionsPlay": {},
|
||||
"resourcesPlaylistName": "{count,plural, =1{Плейлист} few{Плейлисты} many{Плейлистов} other{Плейлистов}}",
|
||||
"@resourcesPlaylistName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesQueueName": "{count,plural, =1{Очередь} few{Очереди} many{Очередей} other{Очередей}}",
|
||||
"@resourcesQueueName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSongListsArtistTopSongs": "Лучшие треки",
|
||||
"@resourcesSongListsArtistTopSongs": {},
|
||||
"resourcesSongName": "{count,plural, =1{Трек} few{Трека} many{Треков} other{Треков}}",
|
||||
"@resourcesSongName": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSortByAdded": "Недавно добавленные",
|
||||
"@resourcesSortByAdded": {},
|
||||
"resourcesSortByArtist": "По исполнителям",
|
||||
"@resourcesSortByArtist": {},
|
||||
"resourcesSortByFrequentlyPlayed": "Часто проигрываемые",
|
||||
"@resourcesSortByFrequentlyPlayed": {},
|
||||
"resourcesSortByName": "По имени",
|
||||
"@resourcesSortByName": {},
|
||||
"resourcesSortByRandom": "Случайно",
|
||||
"@resourcesSortByRandom": {},
|
||||
"resourcesSortByRecentlyPlayed": "Недавно проигранные",
|
||||
"@resourcesSortByRecentlyPlayed": {},
|
||||
"resourcesSortByYear": "По году",
|
||||
"@resourcesSortByYear": {},
|
||||
"searchHeaderTitle": "Поиск: {query}",
|
||||
"@searchHeaderTitle": {
|
||||
"placeholders": {
|
||||
"query": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"searchInputPlaceholder": "Поиск",
|
||||
"@searchInputPlaceholder": {},
|
||||
"searchMoreResults": "Больше…",
|
||||
"@searchMoreResults": {},
|
||||
"searchNowPlayingContext": "Результаты поиска",
|
||||
"@searchNowPlayingContext": {},
|
||||
"settingsAboutActionsLicenses": "Лицензии",
|
||||
"@settingsAboutActionsLicenses": {},
|
||||
"settingsAboutActionsProjectHomepage": "Сайт проекта",
|
||||
"@settingsAboutActionsProjectHomepage": {},
|
||||
"settingsAboutName": "О Subtracks",
|
||||
"@settingsAboutName": {},
|
||||
"settingsAboutVersion": "версия {version}",
|
||||
"@settingsAboutVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsMusicName": "Музыка",
|
||||
"@settingsMusicName": {},
|
||||
"settingsMusicOptionsScrobbleDescriptionOff": "Не отправлять историю воспроизведений",
|
||||
"@settingsMusicOptionsScrobbleDescriptionOff": {},
|
||||
"settingsMusicOptionsScrobbleDescriptionOn": "Скробблинг истории воспроизведения",
|
||||
"@settingsMusicOptionsScrobbleDescriptionOn": {},
|
||||
"settingsMusicOptionsScrobbleTitle": "Скробблинг",
|
||||
"@settingsMusicOptionsScrobbleTitle": {},
|
||||
"settingsNetworkName": "Сеть",
|
||||
"@settingsNetworkName": {},
|
||||
"settingsNetworkOptionsMaxBitrateMobileTitle": "Максимальный битрейт (мобильный интернет)",
|
||||
"@settingsNetworkOptionsMaxBitrateMobileTitle": {},
|
||||
"settingsNetworkOptionsMaxBitrateWifiTitle": "Максимальный битрейт (Wi-Fi)",
|
||||
"@settingsNetworkOptionsMaxBitrateWifiTitle": {},
|
||||
"settingsNetworkOptionsMaxBufferTitle": "Максимальное время буферизации",
|
||||
"@settingsNetworkOptionsMaxBufferTitle": {},
|
||||
"settingsNetworkOptionsMinBufferTitle": "Минимальное время буферизации",
|
||||
"@settingsNetworkOptionsMinBufferTitle": {},
|
||||
"settingsNetworkValuesKbps": "{value} кбит/с",
|
||||
"@settingsNetworkValuesKbps": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsNetworkValuesSeconds": "{value} секунд",
|
||||
"@settingsNetworkValuesSeconds": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsNetworkValuesUnlimitedKbps": "Без ограничений",
|
||||
"@settingsNetworkValuesUnlimitedKbps": {},
|
||||
"settingsResetActionsClearImageCache": "Очистить кэш изображения",
|
||||
"@settingsResetActionsClearImageCache": {},
|
||||
"settingsResetName": "Сброс",
|
||||
"@settingsResetName": {},
|
||||
"settingsServersActionsAdd": "Добавить сервер",
|
||||
"@settingsServersActionsAdd": {},
|
||||
"settingsServersActionsDelete": "Удалить",
|
||||
"@settingsServersActionsDelete": {},
|
||||
"settingsServersActionsEdit": "Редактировать сервер",
|
||||
"@settingsServersActionsEdit": {},
|
||||
"settingsServersActionsSave": "Сохранить",
|
||||
"@settingsServersActionsSave": {},
|
||||
"settingsServersActionsTestConnection": "Проверить подключение",
|
||||
"@settingsServersActionsTestConnection": {},
|
||||
"settingsServersFieldsAddress": "Адрес",
|
||||
"@settingsServersFieldsAddress": {},
|
||||
"settingsServersFieldsPassword": "Пароль",
|
||||
"@settingsServersFieldsPassword": {},
|
||||
"settingsServersFieldsUsername": "Имя пользователя",
|
||||
"@settingsServersFieldsUsername": {},
|
||||
"settingsServersMessagesConnectionFailed": "Не удалось подключиться к {address}, проверьте настройки или сервер",
|
||||
"@settingsServersMessagesConnectionFailed": {
|
||||
"placeholders": {
|
||||
"address": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsServersMessagesConnectionOk": "Подключение к {address} установлено!",
|
||||
"@settingsServersMessagesConnectionOk": {
|
||||
"placeholders": {
|
||||
"address": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsServersName": "Серверы",
|
||||
"@settingsServersName": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordDescriptionOff": "Отправить пароль в виде токена",
|
||||
"@settingsServersOptionsForcePlaintextPasswordDescriptionOff": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordDescriptionOn": "Отправить пароль в виде текста (устарело, убедитесь, что ваше соединение безопасно!)",
|
||||
"@settingsServersOptionsForcePlaintextPasswordDescriptionOn": {},
|
||||
"settingsServersOptionsForcePlaintextPasswordTitle": "Принудительно использовать текстовой пароль",
|
||||
"@settingsServersOptionsForcePlaintextPasswordTitle": {},
|
||||
"settingsAboutShareLogs": "Поделиться журналами",
|
||||
"@settingsAboutShareLogs": {},
|
||||
"settingsAboutChooseLog": "Выбрать файл журнала",
|
||||
"@settingsAboutChooseLog": {},
|
||||
"settingsNetworkOptionsStreamFormatServerDefault": "Использовать сервер по умолчанию",
|
||||
"@settingsNetworkOptionsStreamFormatServerDefault": {},
|
||||
"actionsDownload": "Скачать",
|
||||
"@actionsDownload": {},
|
||||
"actionsDownloadCancel": "Отменить загрузку",
|
||||
"@actionsDownloadCancel": {},
|
||||
"actionsCancel": "Отменить",
|
||||
"@actionsCancel": {},
|
||||
"resourcesSongCount": "{count,plural, =1{{count} трек} other{{count} треки}}",
|
||||
"@resourcesSongCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesSortByAlbum": "По альбомам",
|
||||
"@resourcesSortByAlbum": {},
|
||||
"resourcesSortByTitle": "По заголовку",
|
||||
"@resourcesSortByTitle": {},
|
||||
"resourcesSortByUpdated": "По недавно обновленному",
|
||||
"@resourcesSortByUpdated": {},
|
||||
"resourcesSortByAlbumCount": "По количеству альбомов",
|
||||
"@resourcesSortByAlbumCount": {},
|
||||
"settingsNetworkOptionsOfflineMode": "Автономный режим",
|
||||
"@settingsNetworkOptionsOfflineMode": {},
|
||||
"settingsNetworkOptionsOfflineModeOff": "Использовать интернет для синхронизации музыки.",
|
||||
"@settingsNetworkOptionsOfflineModeOff": {},
|
||||
"settingsServersFieldsName": "Имя",
|
||||
"@settingsServersFieldsName": {},
|
||||
"actionsDelete": "Удалить",
|
||||
"@actionsDelete": {},
|
||||
"actionsDownloadDelete": "Удалить загруженное",
|
||||
"@actionsDownloadDelete": {},
|
||||
"actionsOk": "ОК",
|
||||
"@actionsOk": {},
|
||||
"controlsShuffle": "Перемешать",
|
||||
"@controlsShuffle": {},
|
||||
"resourcesFilterArtist": "По исполнителю",
|
||||
"@resourcesFilterArtist": {},
|
||||
"resourcesFilterAlbum": "По альбомам",
|
||||
"@resourcesFilterAlbum": {},
|
||||
"resourcesFilterYear": "По годам",
|
||||
"@resourcesFilterYear": {},
|
||||
"resourcesFilterOwner": "По владельцу",
|
||||
"@resourcesFilterOwner": {},
|
||||
"resourcesSongListDeleteAllContent": "Это удалит все загруженные файлы песен.",
|
||||
"@resourcesSongListDeleteAllContent": {},
|
||||
"settingsNetworkOptionsStreamFormat": "Предпочтительный формат потока",
|
||||
"@settingsNetworkOptionsStreamFormat": {},
|
||||
"resourcesSongListDeleteAllTitle": "Удалить загрузки?",
|
||||
"@resourcesSongListDeleteAllTitle": {},
|
||||
"settingsNetworkOptionsOfflineModeOn": "Не использовать интернет для синхронизации или воспроизведения музыки.",
|
||||
"@settingsNetworkOptionsOfflineModeOn": {},
|
||||
"settingsAboutActionsSupport": "Поддержать разработчика",
|
||||
"@settingsAboutActionsSupport": {},
|
||||
"resourcesArtistCount": "{count,plural, =1{{count} исполнитель} other{{count} исполнители}}",
|
||||
"@resourcesArtistCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesPlaylistCount": "{count,plural, =1{{count} плейлист} other{{count} плейлисты}}",
|
||||
"@resourcesPlaylistCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resourcesAlbumCount": "{count,plural, =1{{count} альбом} other{{count} альбомы}}",
|
||||
"@resourcesAlbumCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
lib/log.dart
68
lib/log.dart
@@ -88,6 +88,7 @@ String _format(
|
||||
bool color = false,
|
||||
bool time = true,
|
||||
bool level = true,
|
||||
bool redact = true,
|
||||
}) {
|
||||
var message = '';
|
||||
if (time) message += '${event.time.toIso8601String()} ';
|
||||
@@ -107,6 +108,11 @@ String _format(
|
||||
if (event.error != null) {
|
||||
message += '\n${event.error}';
|
||||
}
|
||||
|
||||
if (redact) {
|
||||
message = _redactUrl(message);
|
||||
}
|
||||
|
||||
if (event.stackTrace != null) {
|
||||
message += '\n${event.stackTrace}';
|
||||
}
|
||||
@@ -116,17 +122,24 @@ String _format(
|
||||
: message;
|
||||
}
|
||||
|
||||
Future<void> _printFile(String event, String dir) async {
|
||||
final now = DateTime.now();
|
||||
final file = File(p.join(dir, '${now.year}-${now.month}-${now.day}.txt'));
|
||||
|
||||
if (!event.endsWith('\n')) {
|
||||
event += '\n';
|
||||
String _redactUrl(String message) {
|
||||
if (!_queryReplace('u').hasMatch(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
await file.writeAsString(event, mode: FileMode.writeOnlyAppend, flush: true);
|
||||
message = _redactParam(message, 'u');
|
||||
message = _redactParam(message, 'p');
|
||||
message = _redactParam(message, 's');
|
||||
message = _redactParam(message, 't');
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
RegExp _queryReplace(String key) => RegExp('$key=([^&|\\n|\\t\\s]+)');
|
||||
|
||||
String _redactParam(String url, String key) =>
|
||||
url.replaceAll(_queryReplace(key), '$key=REDACTED');
|
||||
|
||||
Future<Directory> logDirectory() async {
|
||||
return Directory(
|
||||
p.join((await getApplicationDocumentsDirectory()).path, 'logs'),
|
||||
@@ -141,11 +154,43 @@ Future<List<File>> logFiles() async {
|
||||
);
|
||||
}
|
||||
|
||||
File _currentLogFile(String logDir) {
|
||||
final now = DateTime.now();
|
||||
return File(p.join(logDir, '${now.year}-${now.month}-${now.day}.txt'));
|
||||
}
|
||||
|
||||
Future<void> _printFile(String event, String logDir) async {
|
||||
final file = _currentLogFile(logDir);
|
||||
|
||||
if (!event.endsWith('\n')) {
|
||||
event += '\n';
|
||||
}
|
||||
|
||||
await file.writeAsString(event, mode: FileMode.writeOnlyAppend, flush: true);
|
||||
}
|
||||
|
||||
void _printDebug(LogRecord event) {
|
||||
// ignore: avoid_print
|
||||
print(_format(event, color: true, time: false, level: false, redact: false));
|
||||
}
|
||||
|
||||
Future<void> _printRelease(LogRecord event, String logDir) async {
|
||||
await _printFile(
|
||||
_format(event, color: false, time: true, level: true, redact: true),
|
||||
logDir,
|
||||
);
|
||||
}
|
||||
|
||||
final log = Logger('default');
|
||||
|
||||
Future<void> initLogging() async {
|
||||
final dir = (await logDirectory())..create();
|
||||
|
||||
final file = _currentLogFile(dir.path);
|
||||
if (!(await file.exists())) {
|
||||
await file.create();
|
||||
}
|
||||
|
||||
final files = await logFiles();
|
||||
if (files.length > 7) {
|
||||
for (var file in files.slice(7)) {
|
||||
@@ -156,14 +201,9 @@ Future<void> initLogging() async {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
Logger.root.onRecord.asyncMap((event) async {
|
||||
if (kDebugMode) {
|
||||
print(_format(event, color: true, time: false, level: false));
|
||||
_printDebug(event);
|
||||
} else {
|
||||
await _printFile(
|
||||
_format(event, color: false, time: true, level: true),
|
||||
dir.path,
|
||||
);
|
||||
await _printRelease(event, dir.path);
|
||||
}
|
||||
}).listen((_) {}, cancelOnError: false);
|
||||
|
||||
log.info('start');
|
||||
}
|
||||
|
||||
@@ -95,36 +95,36 @@ class AudioControl extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||
int get _sourceId => _ref.read(sourceIdProvider);
|
||||
|
||||
AudioControl(this._player, this._ref) {
|
||||
_player.playbackEventStream.listen(
|
||||
(PlaybackEvent event) {
|
||||
final playing = _player.playing;
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
},
|
||||
androidCompactActionIndices: const [0, 1, 3],
|
||||
processingState: const {
|
||||
ProcessingState.idle: AudioProcessingState.idle,
|
||||
ProcessingState.loading: AudioProcessingState.loading,
|
||||
ProcessingState.buffering: AudioProcessingState.buffering,
|
||||
ProcessingState.ready: AudioProcessingState.ready,
|
||||
ProcessingState.completed: AudioProcessingState.completed,
|
||||
}[_player.processingState]!,
|
||||
playing: playing,
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
queueIndex: event.currentIndex,
|
||||
));
|
||||
},
|
||||
onError: (e, st) => log.warning('Audio playback error', e, st),
|
||||
cancelOnError: false,
|
||||
);
|
||||
_player.playbackEventStream.listen((PlaybackEvent event) {
|
||||
final playing = _player.playing;
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
MediaControl.skipToNext,
|
||||
],
|
||||
systemActions: const {
|
||||
MediaAction.seek,
|
||||
},
|
||||
androidCompactActionIndices: const [0, 1, 3],
|
||||
processingState: const {
|
||||
ProcessingState.idle: AudioProcessingState.idle,
|
||||
ProcessingState.loading: AudioProcessingState.loading,
|
||||
ProcessingState.buffering: AudioProcessingState.buffering,
|
||||
ProcessingState.ready: AudioProcessingState.ready,
|
||||
ProcessingState.completed: AudioProcessingState.completed,
|
||||
}[_player.processingState]!,
|
||||
playing: playing,
|
||||
updatePosition: _player.position,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
queueIndex: event.currentIndex,
|
||||
));
|
||||
});
|
||||
|
||||
_player.playbackEventStream.doOnError((e, st) async {
|
||||
log.warning('playbackEventStream', e, st);
|
||||
});
|
||||
|
||||
shuffleIndicies.listen((value) {
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
|
||||
@@ -46,13 +46,15 @@ class SettingsService extends _$SettingsService {
|
||||
features: IList(),
|
||||
username: subsonic.username.value,
|
||||
password: subsonic.password.value,
|
||||
useTokenAuth: true,
|
||||
useTokenAuth: subsonic.useTokenAuth.value,
|
||||
isActive: true,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
ref.read(httpClientProvider),
|
||||
);
|
||||
|
||||
await client.test();
|
||||
|
||||
final features = IList([
|
||||
if (await client.testFeature(SubsonicFeature.emptyQuerySearch))
|
||||
SubsonicFeature.emptyQuerySearch,
|
||||
@@ -66,6 +68,10 @@ class SettingsService extends _$SettingsService {
|
||||
}
|
||||
|
||||
Future<void> updateSource(SubsonicSettings source) async {
|
||||
final client = SubsonicClient(source, ref.read(httpClientProvider));
|
||||
|
||||
await client.test();
|
||||
|
||||
await _db.updateSource(source);
|
||||
await init();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../database/database.dart';
|
||||
import '../log.dart';
|
||||
import '../state/settings.dart';
|
||||
|
||||
abstract class BaseMusicSource {
|
||||
@@ -40,25 +42,33 @@ class MusicSource implements BaseMusicSource {
|
||||
@override
|
||||
Stream<Iterable<AlbumsCompanion>> allAlbums() {
|
||||
_testOnline();
|
||||
return _source.allAlbums();
|
||||
return _source
|
||||
.allAlbums()
|
||||
.doOnError((e, st) => log.severe('allAlbums', e, st));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Iterable<ArtistsCompanion>> allArtists() {
|
||||
_testOnline();
|
||||
return _source.allArtists();
|
||||
return _source
|
||||
.allArtists()
|
||||
.doOnError((e, st) => log.severe('allArtists', e, st));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Iterable<PlaylistWithSongsCompanion>> allPlaylists() {
|
||||
_testOnline();
|
||||
return _source.allPlaylists();
|
||||
return _source
|
||||
.allPlaylists()
|
||||
.doOnError((e, st) => log.severe('allPlaylists', e, st));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Iterable<SongsCompanion>> allSongs() {
|
||||
_testOnline();
|
||||
return _source.allSongs();
|
||||
return _source
|
||||
.allSongs()
|
||||
.doOnError((e, st) => log.severe('allSongs', e, st));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -98,6 +98,8 @@ class SubsonicClient {
|
||||
return subsonicResponse;
|
||||
}
|
||||
|
||||
Future<void> test() => get('ping');
|
||||
|
||||
Future<bool> testFeature(SubsonicFeature feature) async {
|
||||
switch (feature) {
|
||||
case SubsonicFeature.emptyQuerySearch:
|
||||
|
||||
@@ -4,7 +4,7 @@ homepage: https://github.com/austinried/subtracks
|
||||
repository: https://github.com/austinried/subtracks
|
||||
issue_tracker: https://github.com/austinried/subtracks/issues
|
||||
publish_to: 'none'
|
||||
version: 2.0.0-alpha.2+11
|
||||
version: 2.0.0-alpha.3+12
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.2 <3.0.0'
|
||||
|
||||
Reference in New Issue
Block a user