mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-09 08:47:31 +00:00
Merge branch 'dev' into master
This commit is contained in:
commit
44ebada3bf
@ -520,10 +520,10 @@ abstract class LanguageLocals {
|
||||
// name: "Pashto, Pushto",
|
||||
// nativeName: "پښتو",
|
||||
// ),
|
||||
// "pt": const ISOLanguageName(
|
||||
// name: "Portuguese",
|
||||
// nativeName: "Português",
|
||||
// ),
|
||||
"pt": const ISOLanguageName(
|
||||
name: "Portuguese",
|
||||
nativeName: "Português",
|
||||
),
|
||||
// "qu": const ISOLanguageName(
|
||||
// name: "Quechua",
|
||||
// nativeName: "Runa Simi, Kichwa",
|
||||
|
||||
@ -128,8 +128,8 @@ final router = GoRouter(
|
||||
GoRoute(
|
||||
path: "/mini-player",
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: MiniLyricsPage(),
|
||||
pageBuilder: (context, state) => SpotubePage(
|
||||
child: MiniLyricsPage(prevSize: state.extra as Size),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
@ -93,4 +93,5 @@ abstract class SpotubeIcons {
|
||||
static const skip = FeatherIcons.fastForward;
|
||||
static const noWifi = FeatherIcons.wifiOff;
|
||||
static const wifi = FeatherIcons.wifi;
|
||||
static const window = Icons.window_rounded;
|
||||
}
|
||||
|
||||
@ -50,8 +50,13 @@ class AlbumCard extends HookConsumerWidget {
|
||||
() => playlist.containsCollection(album.id!),
|
||||
[playlist, album.id],
|
||||
);
|
||||
final int marginH =
|
||||
useBreakpointValue(xs: 10, sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||
|
||||
final marginH = useBreakpointValue<int>(
|
||||
xs: 10,
|
||||
sm: 10,
|
||||
md: 15,
|
||||
others: 20,
|
||||
);
|
||||
|
||||
final updating = useState(false);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
@ -3,13 +3,14 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/album/album_card.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
@ -23,76 +24,86 @@ class UserAlbums extends HookConsumerWidget {
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final albumsQuery = useQueries.album.ofMine(ref);
|
||||
|
||||
final spacing = useBreakpointValue<double>(
|
||||
xs: 0,
|
||||
sm: 0,
|
||||
others: 20,
|
||||
);
|
||||
final controller = useScrollController();
|
||||
|
||||
final searchText = useState('');
|
||||
|
||||
final allAlbums = useMemoized(
|
||||
() => albumsQuery.pages
|
||||
.expand((element) => element.items ?? <AlbumSimple>[]),
|
||||
[albumsQuery.pages],
|
||||
);
|
||||
|
||||
final albums = useMemoized(() {
|
||||
if (searchText.value.isEmpty) {
|
||||
return albumsQuery.data?.toList() ?? [];
|
||||
return allAlbums;
|
||||
}
|
||||
return albumsQuery.data
|
||||
?.map((e) => (
|
||||
weightedRatio(e.name!, searchText.value),
|
||||
e,
|
||||
))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList() ??
|
||||
[];
|
||||
}, [albumsQuery.data, searchText.value]);
|
||||
return allAlbums
|
||||
.map((e) => (
|
||||
weightedRatio(e.name!, searchText.value),
|
||||
e,
|
||||
))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
}, [allAlbums, searchText.value]);
|
||||
|
||||
if (auth == null) {
|
||||
return const AnonymousFallback();
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await albumsQuery.refresh();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SearchBar(
|
||||
child: SafeArea(
|
||||
child: Scaffold(
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(50),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: ColoredBox(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: SearchBar(
|
||||
onChanged: (value) => searchText.value = value,
|
||||
leading: const Icon(SpotubeIcons.filter),
|
||||
hintText: context.l10n.filter_albums,
|
||||
hintText: context.l10n.filter_artist,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
firstChild: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const ShimmerPlaybuttonCard(count: 7),
|
||||
),
|
||||
secondChild: Wrap(
|
||||
spacing: spacing, // gap between adjacent chips
|
||||
runSpacing: 20, // gap between lines
|
||||
alignment: WrapAlignment.center,
|
||||
children: albums
|
||||
.map((album) => AlbumCard(
|
||||
TypeConversionUtils.simpleAlbum_X_Album(album),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
crossFadeState: albumsQuery.isLoading ||
|
||||
!albumsQuery.hasData ||
|
||||
searchText.value.isNotEmpty
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SizedBox.expand(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
controller: controller,
|
||||
child: Wrap(
|
||||
runSpacing: 20,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (albums.isEmpty)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: const ShimmerPlaybuttonCard(count: 4),
|
||||
),
|
||||
for (final album in albums)
|
||||
AlbumCard(
|
||||
TypeConversionUtils.simpleAlbum_X_Album(album),
|
||||
),
|
||||
if (albumsQuery.hasNextPage)
|
||||
Waypoint(
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: albumsQuery.fetchNext,
|
||||
child: const ShimmerPlaybuttonCard(count: 1),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -92,6 +92,8 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
tooltip: context.l10n.mini_player,
|
||||
icon: const Icon(SpotubeIcons.miniPlayer),
|
||||
onPressed: () async {
|
||||
final prevSize =
|
||||
await DesktopTools.window.getSize();
|
||||
await DesktopTools.window.setMinimumSize(
|
||||
const Size(300, 300),
|
||||
);
|
||||
@ -106,7 +108,10 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() async {
|
||||
GoRouter.of(context).go('/mini-player');
|
||||
GoRouter.of(context).go(
|
||||
'/mini-player',
|
||||
extra: prevSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
|
||||
class ConfirmDownloadDialog extends StatelessWidget {
|
||||
@ -24,8 +25,9 @@ class ConfirmDownloadDialog extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
content: Padding(
|
||||
content: Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
constraints: BoxConstraints(maxWidth: Breakpoints.sm),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -87,7 +89,7 @@ class BulletPoint extends StatelessWidget {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("●"),
|
||||
const Text("\u2022"),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(child: Text(text)),
|
||||
],
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@ -188,6 +189,7 @@ class AlbumHeartButton extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final client = useQueryClient();
|
||||
final me = useQueries.user.me(ref);
|
||||
|
||||
final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
|
||||
@ -196,10 +198,10 @@ class AlbumHeartButton extends HookConsumerWidget {
|
||||
final toggleAlbumLike = useMutations.album.toggleFavorite(
|
||||
ref,
|
||||
album.id!,
|
||||
refreshQueries: [
|
||||
albumIsSaved.key,
|
||||
"current-user-albums",
|
||||
],
|
||||
refreshQueries: [albumIsSaved.key],
|
||||
onData: (_, __) async {
|
||||
await client.refreshInfiniteQueryAllPages("current-user-albums");
|
||||
},
|
||||
);
|
||||
|
||||
if (me.isLoading || !me.hasData) {
|
||||
|
||||
@ -21,7 +21,7 @@ final closeNotification = DesktopTools.createNotification(
|
||||
windowManager.close();
|
||||
};
|
||||
|
||||
class PageWindowTitleBar extends StatefulHookWidget
|
||||
class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
@ -60,23 +60,23 @@ class PageWindowTitleBar extends StatefulHookWidget
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
|
||||
@override
|
||||
State<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
|
||||
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
|
||||
}
|
||||
|
||||
class _PageWindowTitleBarState extends State<PageWindowTitleBar> {
|
||||
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
void onDrag(details) {
|
||||
final systemTitleBar =
|
||||
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
|
||||
if (kIsDesktop && !systemTitleBar) {
|
||||
DesktopTools.window.startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onHorizontalDragStart: (details) {
|
||||
if (kIsDesktop) {
|
||||
windowManager.startDragging();
|
||||
}
|
||||
},
|
||||
onVerticalDragStart: (details) {
|
||||
if (kIsDesktop) {
|
||||
windowManager.startDragging();
|
||||
}
|
||||
},
|
||||
onHorizontalDragStart: onDrag,
|
||||
onVerticalDragStart: onDrag,
|
||||
child: AppBar(
|
||||
leading: widget.leading,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
@ -108,13 +108,12 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final closeBehavior =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.closeBehavior));
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
final isMaximized = useState<bool?>(null);
|
||||
const type = ThemeType.auto;
|
||||
|
||||
Future<void> onClose() async {
|
||||
if (closeBehavior == CloseBehavior.close) {
|
||||
if (preferences.closeBehavior == CloseBehavior.close) {
|
||||
await windowManager.close();
|
||||
} else {
|
||||
await windowManager.hide();
|
||||
@ -131,7 +130,7 @@ class WindowTitleBarButtons extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
if (!kIsDesktop || kIsMacOS) {
|
||||
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
final trackCollectionSortState =
|
||||
@ -55,7 +56,9 @@ class TracksTableView extends HookConsumerWidget {
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
ref.watch(downloadManagerProvider);
|
||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||
TextStyle tableHeadStyle =
|
||||
final apiType =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType));
|
||||
final tableHeadStyle =
|
||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
||||
|
||||
final selected = useState<List<String>>([]);
|
||||
@ -188,12 +191,13 @@ class TracksTableView extends HookConsumerWidget {
|
||||
switch (action) {
|
||||
case "download":
|
||||
{
|
||||
final confirmed = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const ConfirmDownloadDialog();
|
||||
},
|
||||
);
|
||||
final confirmed = apiType == YoutubeApiType.piped ||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return const ConfirmDownloadDialog();
|
||||
},
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await downloader
|
||||
.batchAddToQueue(selectedTracks.toList());
|
||||
|
||||
@ -258,5 +258,6 @@
|
||||
"piped_api_down": "Piped API is down",
|
||||
"piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change",
|
||||
"you_are_offline": "You are currently offline",
|
||||
"connection_restored": "Your internet connection was restored"
|
||||
"connection_restored": "Your internet connection was restored",
|
||||
"use_system_title_bar": "Use system title bar"
|
||||
}
|
||||
260
lib/l10n/app_pt.arb
Normal file
260
lib/l10n/app_pt.arb
Normal file
@ -0,0 +1,260 @@
|
||||
{
|
||||
"guest": "Visitante",
|
||||
"browse": "Explorar",
|
||||
"search": "Buscar",
|
||||
"library": "Biblioteca",
|
||||
"lyrics": "Letras",
|
||||
"settings": "Configurações",
|
||||
"genre_categories_filter": "Filtrar categorias ou gêneros...",
|
||||
"genre": "Gênero",
|
||||
"personalized": "Personalizado",
|
||||
"featured": "Destaque",
|
||||
"new_releases": "Novos Lançamentos",
|
||||
"songs": "Músicas",
|
||||
"playing_track": "Tocando {track}",
|
||||
"queue_clear_alert": "Isso irá limpar a fila atual. {track_length} músicas serão removidas.\nDeseja continuar?",
|
||||
"load_more": "Carregar mais",
|
||||
"playlists": "Playlists",
|
||||
"artists": "Artistas",
|
||||
"albums": "Álbuns",
|
||||
"tracks": "Faixas",
|
||||
"downloads": "Downloads",
|
||||
"filter_playlists": "Filtrar suas playlists...",
|
||||
"liked_tracks": "Músicas Curtidas",
|
||||
"liked_tracks_description": "Todas as suas músicas curtidas",
|
||||
"create_playlist": "Criar Playlist",
|
||||
"create_a_playlist": "Criar uma playlist",
|
||||
"create": "Criar",
|
||||
"cancel": "Cancelar",
|
||||
"playlist_name": "Nome da Playlist",
|
||||
"name_of_playlist": "Nome da playlist",
|
||||
"description": "Descrição",
|
||||
"public": "Pública",
|
||||
"collaborative": "Colaborativa",
|
||||
"search_local_tracks": "Buscar músicas locais...",
|
||||
"play": "Reproduzir",
|
||||
"delete": "Excluir",
|
||||
"none": "Nenhum",
|
||||
"sort_a_z": "Ordenar de A-Z",
|
||||
"sort_z_a": "Ordenar de Z-A",
|
||||
"sort_artist": "Ordenar por Artista",
|
||||
"sort_album": "Ordenar por Álbum",
|
||||
"sort_tracks": "Ordenar Faixas",
|
||||
"currently_downloading": "Baixando no momento ({tracks_length})",
|
||||
"cancel_all": "Cancelar Tudo",
|
||||
"filter_artist": "Filtrar artistas...",
|
||||
"followers": "{followers} Seguidores",
|
||||
"add_artist_to_blacklist": "Adicionar artista à lista negra",
|
||||
"top_tracks": "Principais Músicas",
|
||||
"fans_also_like": "Fãs também curtiram",
|
||||
"loading": "Carregando...",
|
||||
"artist": "Artista",
|
||||
"blacklisted": "Na Lista Negra",
|
||||
"following": "Seguindo",
|
||||
"follow": "Seguir",
|
||||
"artist_url_copied": "URL do artista copiada para a área de transferência",
|
||||
"added_to_queue": "Adicionadas {tracks} músicas à fila",
|
||||
"filter_albums": "Filtrar álbuns...",
|
||||
"synced": "Sincronizado",
|
||||
"plain": "Simples",
|
||||
"shuffle": "Aleatório",
|
||||
"search_tracks": "Buscar músicas...",
|
||||
"released": "Lançado",
|
||||
"error": "Erro {error}",
|
||||
"title": "Título",
|
||||
"time": "Tempo",
|
||||
"more_actions": "Mais ações",
|
||||
"download_count": "Baixar ({count})",
|
||||
"add_count_to_playlist": "Adicionar ({count}) à Playlist",
|
||||
"add_count_to_queue": "Adicionar ({count}) à Fila",
|
||||
"play_count_next": "Reproduzir ({count}) em seguida",
|
||||
"album": "Álbum",
|
||||
"copied_to_clipboard": "{data} copiado para a área de transferência",
|
||||
"add_to_following_playlists": "Adicionar {track} às Playlists Seguintes",
|
||||
"add": "Adicionar",
|
||||
"added_track_to_queue": "Adicionada {track} à fila",
|
||||
"add_to_queue": "Adicionar à fila",
|
||||
"track_will_play_next": "{track} será reproduzida em seguida",
|
||||
"play_next": "Reproduzir em seguida",
|
||||
"removed_track_from_queue": "{track} removida da fila",
|
||||
"remove_from_queue": "Remover da fila",
|
||||
"remove_from_favorites": "Remover dos favoritos",
|
||||
"save_as_favorite": "Salvar como favorita",
|
||||
"add_to_playlist": "Adicionar à playlist",
|
||||
"remove_from_playlist": "Remover da playlist",
|
||||
"add_to_blacklist": "Adicionar à lista negra",
|
||||
"remove_from_blacklist": "Remover da lista negra",
|
||||
"share": "Compartilhar",
|
||||
"mini_player": "Mini Player",
|
||||
"slide_to_seek": "Arraste para avançar ou retroceder",
|
||||
"shuffle_playlist": "Embaralhar playlist",
|
||||
"unshuffle_playlist": "Desembaralhar playlist",
|
||||
"previous_track": "Faixa anterior",
|
||||
"next_track": "Próxima faixa",
|
||||
"pause_playback": "Pausar Reprodução",
|
||||
"resume_playback": "Continuar Reprodução",
|
||||
"loop_track": "Repetir faixa",
|
||||
"repeat_playlist": "Repetir playlist",
|
||||
"queue": "Fila",
|
||||
"alternative_track_sources": "Fontes alternativas de faixas",
|
||||
"download_track": "Baixar faixa",
|
||||
"tracks_in_queue": "{tracks} músicas na fila",
|
||||
"clear_all": "Limpar tudo",
|
||||
"show_hide_ui_on_hover": "Mostrar/Ocultar UI ao passar o mouse",
|
||||
"always_on_top": "Sempre no topo",
|
||||
"exit_mini_player": "Sair do Mini player",
|
||||
"download_location": "Local de download",
|
||||
"account": "Conta",
|
||||
"login_with_spotify": "Fazer login com sua conta do Spotify",
|
||||
"connect_with_spotify": "Conectar ao Spotify",
|
||||
"logout": "Sair",
|
||||
"logout_of_this_account": "Sair desta conta",
|
||||
"language_region": "Idioma e Região",
|
||||
"language": "Idioma",
|
||||
"system_default": "Padrão do Sistema",
|
||||
"market_place_region": "Região da Loja",
|
||||
"recommendation_country": "País de Recomendação",
|
||||
"appearance": "Aparência",
|
||||
"layout_mode": "Modo de Layout",
|
||||
"override_layout_settings": "Substituir configurações do modo de layout responsivo",
|
||||
"adaptive": "Adaptável",
|
||||
"compact": "Compacto",
|
||||
"extended": "Estendido",
|
||||
"theme": "Tema",
|
||||
"dark": "Escuro",
|
||||
"light": "Claro",
|
||||
"system": "Sistema",
|
||||
"accent_color": "Cor de Destaque",
|
||||
"sync_album_color": "Sincronizar cor do álbum",
|
||||
"sync_album_color_description": "Usa a cor predominante da capa do álbum como cor de destaque",
|
||||
"playback": "Reprodução",
|
||||
"audio_quality": "Qualidade do Áudio",
|
||||
"high": "Alta",
|
||||
"low": "Baixa",
|
||||
"pre_download_play": "Pré-download e reprodução",
|
||||
"pre_download_play_description": "Em vez de transmitir áudio, baixar bytes e reproduzir (recomendado para usuários com maior largura de banda)",
|
||||
"skip_non_music": "Pular segmentos não musicais (SponsorBlock)",
|
||||
"blacklist_description": "Faixas e artistas na lista negra",
|
||||
"wait_for_download_to_finish": "Aguarde o download atual ser concluído",
|
||||
"download_lyrics": "Baixar letras junto com as faixas",
|
||||
"desktop": "Desktop",
|
||||
"close_behavior": "Comportamento de Fechamento",
|
||||
"close": "Fechar",
|
||||
"minimize_to_tray": "Minimizar para a bandeja",
|
||||
"show_tray_icon": "Mostrar ícone na bandeja do sistema",
|
||||
"about": "Sobre",
|
||||
"u_love_spotube": "Sabemos que você adora o Spotube",
|
||||
"check_for_updates": "Verificar atualizações",
|
||||
"about_spotube": "Sobre o Spotube",
|
||||
"blacklist": "Lista Negra",
|
||||
"please_sponsor": "Por favor, patrocine/doe",
|
||||
"spotube_description": "Spotube, um cliente leve, multiplataforma e gratuito para o Spotify",
|
||||
"version": "Versão",
|
||||
"build_number": "Número de Build",
|
||||
"founder": "Fundador",
|
||||
"repository": "Repositório",
|
||||
"bug_issues": "Bugs/Problemas",
|
||||
"made_with": "Feito com ❤️ em Bangladesh🇧🇩",
|
||||
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
|
||||
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
|
||||
"license": "Licença",
|
||||
"add_spotify_credentials": "Adicione suas credenciais do Spotify para começar",
|
||||
"credentials_will_not_be_shared_disclaimer": "Não se preocupe, suas credenciais não serão coletadas nem compartilhadas com ninguém",
|
||||
"know_how_to_login": "Não sabe como fazer isso?",
|
||||
"follow_step_by_step_guide": "Siga o guia passo a passo",
|
||||
"spotify_cookie": "Cookie do Spotify {name}",
|
||||
"cookie_name_cookie": "Cookie {name}",
|
||||
"fill_in_all_fields": "Preencha todos os campos, por favor",
|
||||
"submit": "Enviar",
|
||||
"exit": "Sair",
|
||||
"previous": "Anterior",
|
||||
"next": "Próximo",
|
||||
"done": "Concluído",
|
||||
"step_1": "Passo 1",
|
||||
"first_go_to": "Primeiro, vá para",
|
||||
"login_if_not_logged_in": "e faça login/cadastro se ainda não estiver logado",
|
||||
"step_2": "Passo 2",
|
||||
"step_2_steps": "1. Uma vez logado, pressione F12 ou clique com o botão direito do mouse > Inspecionar para abrir as ferramentas de desenvolvimento do navegador.\n2. Em seguida, vá para a guia \"Aplicativo\" (Chrome, Edge, Brave, etc.) ou \"Armazenamento\" (Firefox, Palemoon, etc.)\n3. Acesse a seção \"Cookies\" e depois a subseção \"https://accounts.spotify.com\"",
|
||||
"step_3": "Passo 3",
|
||||
"step_3_steps": "Copie os valores dos Cookies \"sp_dc\" e \"sp_key\" (ou sp_gaid)",
|
||||
"success_emoji": "Sucesso🥳",
|
||||
"success_message": "Agora você está logado com sucesso em sua conta do Spotify. Bom trabalho!",
|
||||
"step_4": "Passo 4",
|
||||
"step_4_steps": "Cole os valores copiados \"sp_dc\" e \"sp_key\" (ou sp_gaid) nos campos correspondentes",
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
"piped_instance": "Instância do Servidor Piped",
|
||||
"piped_description": "A instância do servidor Piped a ser usada para correspondência de faixas",
|
||||
"piped_warning": "Algumas delas podem não funcionar bem. Use por sua conta e risco",
|
||||
"generate_playlist": "Gerar Playlist",
|
||||
"track_exists": "A faixa {track} já existe",
|
||||
"replace_downloaded_tracks": "Substituir todas as faixas baixadas",
|
||||
"skip_download_tracks": "Pular o download de todas as faixas baixadas",
|
||||
"do_you_want_to_replace": "Deseja substituir a faixa existente?",
|
||||
"replace": "Substituir",
|
||||
"skip": "Pular",
|
||||
"select_up_to_count_type": "Selecione até {count} {type}",
|
||||
"select_genres": "Selecionar Gêneros",
|
||||
"add_genres": "Adicionar Gêneros",
|
||||
"country": "País",
|
||||
"number_of_tracks_generate": "Número de faixas a gerar",
|
||||
"acousticness": "Acústica",
|
||||
"danceability": "Dançabilidade",
|
||||
"energy": "Energia",
|
||||
"instrumentalness": "Instrumentalidade",
|
||||
"liveness": "Vivacidade",
|
||||
"loudness": "Volume",
|
||||
"speechiness": "Discurso",
|
||||
"valence": "Valência",
|
||||
"popularity": "Popularidade",
|
||||
"key": "Tonalidade",
|
||||
"duration": "Duração (s)",
|
||||
"tempo": "Tempo (BPM)",
|
||||
"mode": "Modo",
|
||||
"time_signature": "Assinatura de tempo",
|
||||
"short": "Curto",
|
||||
"medium": "Médio",
|
||||
"long": "Longo",
|
||||
"min": "Min",
|
||||
"max": "Máx",
|
||||
"target": "Alvo",
|
||||
"moderate": "Moderado",
|
||||
"deselect_all": "Desmarcar Todos",
|
||||
"select_all": "Selecionar Todos",
|
||||
"are_you_sure": "Tem certeza?",
|
||||
"generating_playlist": "Gerando sua playlist personalizada...",
|
||||
"selected_count_tracks": "{count} faixas selecionadas",
|
||||
"download_warning": "Se você baixar todas as faixas em massa, estará claramente pirateando música e causando danos à sociedade criativa da música. Espero que você esteja ciente disso. Sempre tente respeitar e apoiar o trabalho árduo dos artistas",
|
||||
"download_ip_ban_warning": "Além disso, seu IP pode ser bloqueado no YouTube devido a solicitações de download excessivas. O bloqueio de IP significa que você não poderá usar o YouTube (mesmo se estiver conectado) por pelo menos 2-3 meses a partir do dispositivo IP. E o Spotube não se responsabiliza se isso acontecer",
|
||||
"by_clicking_accept_terms": "Ao clicar em 'aceitar', você concorda com os seguintes termos:",
|
||||
"download_agreement_1": "Eu sei que estou pirateando música. Sou mau",
|
||||
"download_agreement_2": "Vou apoiar o artista onde puder e estou fazendo isso porque não tenho dinheiro para comprar sua arte",
|
||||
"download_agreement_3": "Estou completamente ciente de que meu IP pode ser bloqueado no YouTube e não responsabilizo o Spotube ou seus proprietários/colaboradores por quaisquer acidentes causados pela minha ação atual",
|
||||
"decline": "Recusar",
|
||||
"accept": "Aceitar",
|
||||
"details": "Detalhes",
|
||||
"youtube": "YouTube",
|
||||
"channel": "Canal",
|
||||
"likes": "Curtidas",
|
||||
"dislikes": "Descurtidas",
|
||||
"views": "Visualizações",
|
||||
"streamUrl": "URL do Stream",
|
||||
"stop": "Parar",
|
||||
"sort_newest": "Ordenar por mais recente adicionado",
|
||||
"sort_oldest": "Ordenar por mais antigo adicionado",
|
||||
"sleep_timer": "Temporizador de Sono",
|
||||
"mins": "{minutes} Minutos",
|
||||
"hours": "{hours} Horas",
|
||||
"hour": "{hours} Hora",
|
||||
"custom_hours": "Horas Personalizadas",
|
||||
"logs": "Registros",
|
||||
"developers": "Desenvolvedores",
|
||||
"not_logged_in": "Você não está logado",
|
||||
"search_mode": "Modo de Busca",
|
||||
"youtube_api_type": "Tipo de API",
|
||||
"ok": "Ok",
|
||||
"failed_to_encrypt": "Falha ao criptografar",
|
||||
"encryption_failed_warning": "O Spotube usa criptografia para armazenar seus dados com segurança, mas falhou em fazê-lo. Portanto, ele voltará para o armazenamento não seguro.\nSe você estiver usando o Linux, certifique-se de ter algum serviço secreto (gnome-keyring, kde-wallet, keepassxc, etc.) instalado",
|
||||
"querying_info": "Consultando informações...",
|
||||
"piped_api_down": "A API do Piped está fora do ar",
|
||||
"piped_down_error_instructions": "A instância do Piped {pipedInstance} está fora do ar no momento\n\nMude a instância ou altere o 'Tipo de API' para a API oficial do YouTube\n\nCertifique-se de reiniciar o aplicativo após a alteração"
|
||||
}
|
||||
@ -20,5 +20,6 @@ class L10n {
|
||||
const Locale('zh', 'CN'),
|
||||
const Locale('pl', 'PL'),
|
||||
const Locale('ru', 'RU'),
|
||||
const Locale('pt', 'PT'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -14,6 +14,12 @@ final officialMusicRegex = RegExp(
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
class TrackNotFoundException implements Exception {
|
||||
factory TrackNotFoundException(Track track) {
|
||||
throw Exception("Failed to find any results for ${track.name}");
|
||||
}
|
||||
}
|
||||
|
||||
class SpotubeTrack extends Track {
|
||||
final YoutubeVideoInfo ytTrack;
|
||||
final String ytUri;
|
||||
@ -157,7 +163,7 @@ class SpotubeTrack extends Track {
|
||||
} else {
|
||||
siblings = await fetchSiblings(track, client);
|
||||
if (siblings.isEmpty) {
|
||||
throw Exception("Failed to find any results for ${track.name}");
|
||||
throw TrackNotFoundException(track);
|
||||
}
|
||||
(ytVideo, ytStreamUrl) =
|
||||
await client.video(siblings.first.id, siblings.first.searchMode);
|
||||
|
||||
@ -19,13 +19,13 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class MiniLyricsPage extends HookConsumerWidget {
|
||||
const MiniLyricsPage({Key? key}) : super(key: key);
|
||||
final Size prevSize;
|
||||
const MiniLyricsPage({Key? key, required this.prevSize}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final update = useForceUpdate();
|
||||
final prevSize = useRef<Size?>(null);
|
||||
final wasMaximized = useRef<bool>(false);
|
||||
|
||||
final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
@ -35,7 +35,6 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
prevSize.value = await DesktopTools.window.getSize();
|
||||
wasMaximized.value = await DesktopTools.window.isMaximized();
|
||||
});
|
||||
return null;
|
||||
@ -213,7 +212,7 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
if (wasMaximized.value) {
|
||||
await DesktopTools.window.maximize();
|
||||
} else {
|
||||
await DesktopTools.window.setSize(prevSize.value!);
|
||||
await DesktopTools.window.setSize(prevSize);
|
||||
}
|
||||
await DesktopTools.window
|
||||
.setAlignment(Alignment.center);
|
||||
|
||||
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotify/spotify.dart' hide Offset;
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/lyrics/zoom_controls.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
|
||||
@ -11,9 +11,11 @@ import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/hooks/use_synced_lyrics.dart';
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
import 'package:stroke_text/stroke_text.dart';
|
||||
|
||||
final _delay = StateProvider<int>((ref) => 0);
|
||||
|
||||
@ -114,9 +116,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
? Container(
|
||||
padding: index == lyricValue.lyrics.length - 1
|
||||
? EdgeInsets.only(
|
||||
bottom:
|
||||
MediaQuery.of(context).size.height /
|
||||
2,
|
||||
bottom: mediaQuery.size.height / 2,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
@ -130,19 +130,40 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: palette.bodyTextColor,
|
||||
fontWeight: isActive
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
fontSize: (isActive ? 28 : 26) *
|
||||
(textZoomLevel.value / 100),
|
||||
shadows: kElevationToShadow[9],
|
||||
),
|
||||
child: Text(
|
||||
lyricSlice.text,
|
||||
textAlign: TextAlign.center,
|
||||
textAlign: TextAlign.center,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final duration =
|
||||
await audioPlayer.duration ??
|
||||
Duration.zero;
|
||||
final time = Duration(
|
||||
seconds:
|
||||
lyricSlice.time.inSeconds - delay,
|
||||
);
|
||||
if (time > duration || time.isNegative) {
|
||||
return;
|
||||
}
|
||||
audioPlayer.seek(time);
|
||||
},
|
||||
child: Builder(builder: (context) {
|
||||
return StrokeText(
|
||||
text: lyricSlice.text,
|
||||
textStyle:
|
||||
DefaultTextStyle.of(context).style,
|
||||
textColor: isActive
|
||||
? Colors.white
|
||||
: palette.bodyTextColor,
|
||||
strokeColor: isActive
|
||||
? Colors.black
|
||||
: Colors.transparent,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -336,15 +336,17 @@ class SettingsPage extends HookConsumerWidget {
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: context
|
||||
.l10n.piped_description),
|
||||
text: context
|
||||
.l10n.piped_description,
|
||||
style:
|
||||
theme.textTheme.bodyMedium,
|
||||
),
|
||||
const TextSpan(text: "\n"),
|
||||
TextSpan(
|
||||
text:
|
||||
context.l10n.piped_warning,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelMedium,
|
||||
style:
|
||||
theme.textTheme.labelMedium,
|
||||
)
|
||||
],
|
||||
),
|
||||
@ -496,6 +498,12 @@ class SettingsPage extends HookConsumerWidget {
|
||||
value: preferences.showSystemTrayIcon,
|
||||
onChanged: preferences.setShowSystemTrayIcon,
|
||||
),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(SpotubeIcons.window),
|
||||
title: Text(context.l10n.use_system_title_bar),
|
||||
value: preferences.systemTitleBar,
|
||||
onChanged: preferences.setSystemTitleBar,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!kIsWeb)
|
||||
|
||||
@ -14,6 +14,7 @@ import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/youtube_provider.dart';
|
||||
import 'package:spotube/services/download_manager/download_manager.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class DownloadManagerProvider extends ChangeNotifier {
|
||||
@ -58,7 +59,7 @@ class DownloadManagerProvider extends ChangeNotifier {
|
||||
album: track.album?.name,
|
||||
albumArtist: track.artists?.map((a) => a.name).join(", "),
|
||||
year: track.album?.releaseDate != null
|
||||
? int.tryParse(track.album!.releaseDate!) ?? 1969
|
||||
? int.tryParse(track.album!.releaseDate!.split("-").first) ?? 1969
|
||||
: 1969,
|
||||
trackNumber: track.trackNumber,
|
||||
discNumber: track.discNumber,
|
||||
@ -85,7 +86,7 @@ class DownloadManagerProvider extends ChangeNotifier {
|
||||
|
||||
final Ref<DownloadManagerProvider> ref;
|
||||
|
||||
YoutubeEndpoints get yt => ref.read(downloadYoutubeProvider);
|
||||
YoutubeEndpoints get yt => ref.read(youtubeProvider);
|
||||
String get downloadDirectory =>
|
||||
ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
|
||||
|
||||
@ -130,7 +131,7 @@ class DownloadManagerProvider extends ChangeNotifier {
|
||||
String getTrackFileUrl(Track track) {
|
||||
final name =
|
||||
"${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.m4a";
|
||||
return join(downloadDirectory, name);
|
||||
return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name));
|
||||
}
|
||||
|
||||
bool isActive(Track track) {
|
||||
|
||||
@ -86,21 +86,22 @@ mixin NextFetcher on StateNotifier<ProxyPlaylist> {
|
||||
} else if (track is LocalTrack) {
|
||||
return track.path;
|
||||
} else {
|
||||
return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${track.name?.replaceAll(
|
||||
RegExp(r'\s+', caseSensitive: false),
|
||||
'-',
|
||||
)}";
|
||||
return trackToUnplayableSource(track);
|
||||
}
|
||||
}
|
||||
|
||||
String trackToUnplayableSource(Track track) {
|
||||
return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${Uri.encodeComponent(track.name!)}";
|
||||
}
|
||||
|
||||
List<Track> mapSourcesToTracks(List<String> sources) {
|
||||
return sources
|
||||
.map((source) {
|
||||
final track = state.tracks.firstWhereOrNull(
|
||||
(track) {
|
||||
final newSource = makeAppropriateSource(track);
|
||||
return newSource == source;
|
||||
},
|
||||
(track) =>
|
||||
trackToUnplayableSource(track) == source ||
|
||||
(track is SpotubeTrack && track.ytUri == source) ||
|
||||
(track is LocalTrack && track.path == source),
|
||||
);
|
||||
return track;
|
||||
})
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:catcher/catcher.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
@ -68,7 +70,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
() async {
|
||||
notificationService = await AudioServices.create(ref, this);
|
||||
|
||||
({String source, List<SkipSegment> segments})? currentSegments;
|
||||
// listeners state
|
||||
final currentSegments =
|
||||
// using source as unique id because alternative track source support
|
||||
ObjectRef<({String source, List<SkipSegment> segments})?>(null);
|
||||
final isPreSearching = ObjectRef(false);
|
||||
final isFetchingSegments = ObjectRef(false);
|
||||
|
||||
audioPlayer.activeSourceChangedStream.listen((newActiveSource) async {
|
||||
try {
|
||||
@ -112,19 +119,18 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
}
|
||||
});
|
||||
|
||||
bool isPreSearching = false;
|
||||
|
||||
listenTo2Percent(int percent) async {
|
||||
if (isPreSearching ||
|
||||
if (isPreSearching.value ||
|
||||
audioPlayer.currentSource == null ||
|
||||
audioPlayer.nextSource == null ||
|
||||
isPlayable(audioPlayer.nextSource!)) return;
|
||||
|
||||
try {
|
||||
isPreSearching = true;
|
||||
isPreSearching.value = true;
|
||||
|
||||
final oldTrack =
|
||||
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
|
||||
|
||||
final track = await ensureSourcePlayable(audioPlayer.nextSource!);
|
||||
|
||||
if (track != null) {
|
||||
@ -138,51 +144,64 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
// Removing tracks that were not found to avoid queue interruption
|
||||
// TODO: Add a flag to enable/disable skip not found tracks
|
||||
if (e is TrackNotFoundException) {
|
||||
final oldTrack =
|
||||
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
|
||||
await removeTrack(oldTrack!.id!);
|
||||
}
|
||||
Catcher.reportCheckedError(e, stackTrace);
|
||||
} finally {
|
||||
isPreSearching = false;
|
||||
isPreSearching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
audioPlayer.percentCompletedStream(2).listen(listenTo2Percent);
|
||||
|
||||
bool isFetchingSegments = false;
|
||||
|
||||
audioPlayer.positionStream.listen((position) async {
|
||||
if (state.activeTrack == null || state.activeTrack is LocalTrack) {
|
||||
isFetchingSegments.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (state.activeTrack == null || state.activeTrack is LocalTrack) {
|
||||
isFetchingSegments = false;
|
||||
return;
|
||||
}
|
||||
// skipping in very first second breaks stream
|
||||
if ((preferences.youtubeApiType == YoutubeApiType.piped &&
|
||||
preferences.searchMode == SearchMode.youtubeMusic) ||
|
||||
!preferences.skipNonMusic) return;
|
||||
final isYTMusicMode =
|
||||
preferences.youtubeApiType == YoutubeApiType.piped &&
|
||||
preferences.searchMode == SearchMode.youtubeMusic;
|
||||
|
||||
final notSameSegmentId =
|
||||
currentSegments?.source != audioPlayer.currentSource;
|
||||
if (isYTMusicMode || !preferences.skipNonMusic) return;
|
||||
|
||||
if (currentSegments == null ||
|
||||
(notSameSegmentId && !isFetchingSegments)) {
|
||||
isFetchingSegments = true;
|
||||
final isNotSameSegmentId =
|
||||
currentSegments.value?.source != audioPlayer.currentSource;
|
||||
|
||||
if (currentSegments.value == null ||
|
||||
(isNotSameSegmentId && !isFetchingSegments.value)) {
|
||||
isFetchingSegments.value = true;
|
||||
try {
|
||||
currentSegments = (
|
||||
currentSegments.value = (
|
||||
source: audioPlayer.currentSource!,
|
||||
segments: await getAndCacheSkipSegments(
|
||||
(state.activeTrack as SpotubeTrack).ytTrack.id,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
currentSegments.value = (
|
||||
source: audioPlayer.currentSource!,
|
||||
segments: [],
|
||||
);
|
||||
} finally {
|
||||
isFetchingSegments = false;
|
||||
isFetchingSegments.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final (source: _, :segments) = currentSegments!;
|
||||
final (source: _, :segments) = currentSegments.value!;
|
||||
|
||||
// skipping in first 2 second breaks stream
|
||||
if (segments.isEmpty || position < const Duration(seconds: 3)) return;
|
||||
|
||||
for (final segment in segments) {
|
||||
if ((position.inSeconds >= segment.start &&
|
||||
position.inSeconds < segment.end)) {
|
||||
if (position.inSeconds >= segment.start &&
|
||||
position.inSeconds < segment.end) {
|
||||
await audioPlayer.seek(Duration(seconds: segment.end));
|
||||
}
|
||||
}
|
||||
@ -319,15 +338,18 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
Future<void> jumpTo(int index) async {
|
||||
final oldTrack =
|
||||
mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull;
|
||||
|
||||
state = state.copyWith(active: index);
|
||||
await audioPlayer.pause();
|
||||
final track = await ensureSourcePlayable(audioPlayer.sources[index]);
|
||||
|
||||
if (track != null) {
|
||||
state = state.copyWith(
|
||||
tracks: mergeTracks([track], state.tracks),
|
||||
active: index,
|
||||
);
|
||||
}
|
||||
|
||||
await audioPlayer.jumpTo(index);
|
||||
|
||||
if (oldTrack != null || track != null) {
|
||||
@ -419,13 +441,16 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
Future<void> next() async {
|
||||
if (audioPlayer.nextSource == null) return;
|
||||
final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
|
||||
|
||||
state = state.copyWith(
|
||||
active: state.tracks
|
||||
.toList()
|
||||
.indexWhere((element) => element.id == oldTrack?.id),
|
||||
);
|
||||
|
||||
await audioPlayer.pause();
|
||||
final track = await ensureSourcePlayable(audioPlayer.nextSource!);
|
||||
|
||||
if (track != null) {
|
||||
state = state.copyWith(
|
||||
tracks: mergeTracks([track], state.tracks),
|
||||
@ -515,7 +540,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
final cached = await SkipSegment.box.get(id);
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
return List.castFrom<dynamic, SkipSegment>(
|
||||
(cached as List).map((json) => SkipSegment.fromJson(json)).toList(),
|
||||
(cached as List)
|
||||
.map(
|
||||
(json) => SkipSegment.fromJson(
|
||||
Map.castFrom<dynamic, dynamic, String, dynamic>(json),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -587,7 +618,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
final oldCollections = state.collections;
|
||||
await load(
|
||||
state.tracks,
|
||||
initialIndex: state.active ?? 0,
|
||||
initialIndex: max(state.active ?? 0, 0),
|
||||
autoPlay: false,
|
||||
);
|
||||
state = state.copyWith(collections: oldCollections);
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
|
||||
@ -65,6 +66,8 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
|
||||
YoutubeApiType youtubeApiType;
|
||||
|
||||
bool systemTitleBar;
|
||||
|
||||
final Ref ref;
|
||||
|
||||
UserPreferences(
|
||||
@ -85,6 +88,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
this.searchMode = SearchMode.youtube,
|
||||
this.skipNonMusic = true,
|
||||
this.youtubeApiType = YoutubeApiType.youtube,
|
||||
this.systemTitleBar = false,
|
||||
}) : super() {
|
||||
if (downloadLocation.isEmpty && !kIsWeb) {
|
||||
_getDefaultDownloadDirectory().then(
|
||||
@ -197,6 +201,15 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
updatePersistence();
|
||||
}
|
||||
|
||||
void setSystemTitleBar(bool isSystemTitleBar) {
|
||||
systemTitleBar = isSystemTitleBar;
|
||||
DesktopTools.window.setTitleBarStyle(
|
||||
systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden,
|
||||
);
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
}
|
||||
|
||||
Future<String> _getDefaultDownloadDirectory() async {
|
||||
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
|
||||
|
||||
@ -257,6 +270,10 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
(type) => type.name == map["youtubeApiType"],
|
||||
orElse: () => YoutubeApiType.youtube,
|
||||
);
|
||||
|
||||
systemTitleBar = map["systemTitleBar"] ?? systemTitleBar;
|
||||
// updates the title bar
|
||||
setSystemTitleBar(systemTitleBar);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -279,6 +296,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
||||
"searchMode": searchMode.name,
|
||||
"skipNonMusic": skipNonMusic,
|
||||
"youtubeApiType": youtubeApiType.name,
|
||||
'systemTitleBar': systemTitleBar,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,3 @@ final youtubeProvider = Provider<YoutubeEndpoints>((ref) {
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
return YoutubeEndpoints(preferences);
|
||||
});
|
||||
|
||||
// this provider overrides the API provider to use piped.video for downloading
|
||||
final downloadYoutubeProvider = Provider<YoutubeEndpoints>((ref) {
|
||||
final preferences = ref.watch(userPreferencesProvider);
|
||||
return YoutubeEndpoints(
|
||||
preferences.copyWith(
|
||||
youtubeApiType: YoutubeApiType.piped,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@ -111,7 +111,7 @@ abstract class AudioPlayerInterface {
|
||||
// }
|
||||
}
|
||||
|
||||
Future<PlaybackLoopMode> get loopMode async {
|
||||
PlaybackLoopMode get loopMode {
|
||||
return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode);
|
||||
// if (mkSupportedPlatform) {
|
||||
// return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode);
|
||||
|
||||
@ -156,6 +156,12 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
|
||||
String? get nextSource {
|
||||
// if (mkSupportedPlatform) {
|
||||
|
||||
if (loopMode == PlaybackLoopMode.all &&
|
||||
_mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) {
|
||||
return sources.first;
|
||||
}
|
||||
|
||||
return _mkPlayer.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.playlist.index + 1)
|
||||
?.uri;
|
||||
@ -169,6 +175,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
}
|
||||
|
||||
String? get previousSource {
|
||||
if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) {
|
||||
return sources.last;
|
||||
}
|
||||
|
||||
// if (mkSupportedPlatform) {
|
||||
return _mkPlayer.playlist.medias
|
||||
.elementAtOrNull(_mkPlayer.playlist.index - 1)
|
||||
|
||||
@ -159,7 +159,7 @@ class MkPlayerWithState extends Player {
|
||||
|
||||
@override
|
||||
Future<void> next() async {
|
||||
if (_playlist == null || _playlist!.index + 1 >= _playlist!.medias.length) {
|
||||
if (_playlist == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -170,6 +170,7 @@ class MkPlayerWithState extends Player {
|
||||
return super.open(_playlist!.medias[_playlist!.index], play: true);
|
||||
} else if (!isLast) {
|
||||
playlist = _playlist!.copyWith(index: _playlist!.index + 1);
|
||||
|
||||
return super.open(_playlist!.medias[_playlist!.index], play: true);
|
||||
}
|
||||
}
|
||||
@ -190,7 +191,7 @@ class MkPlayerWithState extends Player {
|
||||
@override
|
||||
Future<void> jump(int index) async {
|
||||
if (_playlist == null || index < 0 || index >= _playlist!.medias.length) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
playlist = _playlist!.copyWith(index: index);
|
||||
@ -233,30 +234,30 @@ class MkPlayerWithState extends Player {
|
||||
|
||||
final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl;
|
||||
|
||||
for (var i = 0; i < _playlist!.medias.length - 1; i++) {
|
||||
final media = _playlist!.medias[i];
|
||||
if (media.uri == oldUrl) {
|
||||
if (isOldUrlPlaying) {
|
||||
pause();
|
||||
}
|
||||
final newMedias = _playlist!.medias.toList();
|
||||
newMedias[i] = Media(newUrl, extras: media.extras);
|
||||
playlist = _playlist!.copyWith(medias: newMedias);
|
||||
if (isOldUrlPlaying) {
|
||||
super.open(
|
||||
newMedias[i],
|
||||
play: true,
|
||||
);
|
||||
}
|
||||
|
||||
// replace in the _tempMedias if it's not null
|
||||
if (shuffled && _tempMedias != null) {
|
||||
final tempIndex = _tempMedias!.indexOf(media);
|
||||
_tempMedias![tempIndex] = Media(newUrl, extras: media.extras);
|
||||
}
|
||||
break;
|
||||
// ends the loop where match is found
|
||||
// tends to be a bit more efficient than forEach
|
||||
_playlist!.medias.firstWhereIndexedOrNull((i, media) {
|
||||
if (media.uri != oldUrl) return false;
|
||||
if (isOldUrlPlaying) {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
final copyMedias = [..._playlist!.medias];
|
||||
copyMedias[i] = Media(newUrl, extras: media.extras);
|
||||
playlist = _playlist!.copyWith(medias: copyMedias);
|
||||
if (isOldUrlPlaying) {
|
||||
super.open(
|
||||
copyMedias[i],
|
||||
play: true,
|
||||
);
|
||||
}
|
||||
|
||||
// replace in the _tempMedias if it's not null
|
||||
if (shuffled && _tempMedias != null) {
|
||||
final tempIndex = _tempMedias!.indexOf(media);
|
||||
_tempMedias![tempIndex] = Media(newUrl, extras: media.extras);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:smtc_windows/smtc_windows.dart'
|
||||
if (dart.library.html) 'package:spotube/services/audio_services/smtc_windows_web.dart';
|
||||
import 'package:smtc_windows/smtc_windows.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
||||
@ -9,6 +9,8 @@ class AlbumMutations {
|
||||
WidgetRef ref,
|
||||
String albumId, {
|
||||
List<String>? refreshQueries,
|
||||
List<String>? refreshInfiniteQueries,
|
||||
MutationOnDataFn<bool, dynamic>? onData,
|
||||
}) {
|
||||
return useSpotifyMutation<bool, dynamic, bool, dynamic>(
|
||||
"toggle-album-like/$albumId",
|
||||
@ -22,6 +24,8 @@ class AlbumMutations {
|
||||
},
|
||||
ref: ref,
|
||||
refreshQueries: refreshQueries,
|
||||
refreshInfiniteQueries: refreshInfiniteQueries,
|
||||
onData: onData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,12 +9,20 @@ import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
class AlbumQueries {
|
||||
const AlbumQueries();
|
||||
|
||||
Query<Iterable<AlbumSimple>, dynamic> ofMine(WidgetRef ref) {
|
||||
return useSpotifyQuery<Iterable<AlbumSimple>, dynamic>(
|
||||
InfiniteQuery<Page<AlbumSimple>, dynamic, int> ofMine(WidgetRef ref) {
|
||||
return useSpotifyInfiniteQuery<Page<AlbumSimple>, dynamic, int>(
|
||||
"current-user-albums",
|
||||
(spotify) {
|
||||
return spotify.me.savedAlbums().all();
|
||||
(page, spotify) {
|
||||
return spotify.me.savedAlbums().getPage(
|
||||
20,
|
||||
page * 20,
|
||||
);
|
||||
},
|
||||
initialPage: 0,
|
||||
nextPage: (lastPage, lastPageData) =>
|
||||
(lastPageData.items?.length ?? 0) < 20 || lastPageData.isLast
|
||||
? null
|
||||
: lastPage + 1,
|
||||
ref: ref,
|
||||
);
|
||||
}
|
||||
|
||||
@ -57,4 +57,8 @@ abstract class PrimitiveUtils {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static String toSafeFileName(String str) {
|
||||
return str.replaceAll(RegExp(r'[^\w\s\.\-_]'), "_");
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,11 +257,34 @@ abstract class ServiceUtils {
|
||||
}
|
||||
|
||||
static void navigate(BuildContext context, String location, {Object? extra}) {
|
||||
if (GoRouterState.of(context).matchedLocation == location) return;
|
||||
GoRouter.of(context).go(location, extra: extra);
|
||||
}
|
||||
|
||||
static void push(BuildContext context, String location, {Object? extra}) {
|
||||
GoRouter.of(context).push(location, extra: extra);
|
||||
final router = GoRouter.of(context);
|
||||
final routerState = GoRouterState.of(context);
|
||||
final routerStack = router.routerDelegate.currentConfiguration.matches
|
||||
.map((e) => e.matchedLocation);
|
||||
|
||||
if (routerState.matchedLocation == location ||
|
||||
routerStack.contains(location)) return;
|
||||
router.push(location, extra: extra);
|
||||
}
|
||||
|
||||
static DateTime parseSpotifyAlbumDate(AlbumSimple? album) {
|
||||
if (album == null || album.releaseDate == null) {
|
||||
return DateTime.parse("1975-01-01");
|
||||
}
|
||||
|
||||
switch (album.releaseDatePrecision ?? DatePrecision.year) {
|
||||
case DatePrecision.day:
|
||||
return DateTime.parse(album.releaseDate!);
|
||||
case DatePrecision.month:
|
||||
return DateTime.parse("${album.releaseDate}-01");
|
||||
case DatePrecision.year:
|
||||
return DateTime.parse("${album.releaseDate}-01-01");
|
||||
}
|
||||
}
|
||||
|
||||
static List<T> sortTracks<T extends Track>(List<T> tracks, SortBy sortBy) {
|
||||
@ -278,12 +301,12 @@ abstract class ServiceUtils {
|
||||
case SortBy.ascending:
|
||||
return a.name?.compareTo(b.name ?? "") ?? 0;
|
||||
case SortBy.oldest:
|
||||
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
|
||||
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
|
||||
final aDate = parseSpotifyAlbumDate(a.album);
|
||||
final bDate = parseSpotifyAlbumDate(b.album);
|
||||
return aDate.compareTo(bDate);
|
||||
case SortBy.newest:
|
||||
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
|
||||
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
|
||||
final aDate = parseSpotifyAlbumDate(a.album);
|
||||
final bDate = parseSpotifyAlbumDate(b.album);
|
||||
return bDate.compareTo(aDate);
|
||||
case SortBy.descending:
|
||||
return b.name?.compareTo(a.name ?? "") ?? 0;
|
||||
|
||||
@ -8,9 +8,6 @@ import 'package:path/path.dart';
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/components/shared/links/anchor_button.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/models/matched_track.dart';
|
||||
import 'package:spotube/models/spotube_track.dart';
|
||||
import 'package:spotube/services/youtube/youtube.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
@ -67,7 +64,7 @@ abstract class TypeConversionUtils {
|
||||
if (onRouteChange != null) {
|
||||
onRouteChange("/artist/${artist.value.id}");
|
||||
} else {
|
||||
ServiceUtils.navigate(
|
||||
ServiceUtils.push(
|
||||
context,
|
||||
"/artist/${artist.value.id}",
|
||||
);
|
||||
@ -122,29 +119,12 @@ abstract class TypeConversionUtils {
|
||||
return track;
|
||||
}
|
||||
|
||||
static SpotubeTrack localTrack_X_Track(
|
||||
static Track localTrack_X_Track(
|
||||
File file, {
|
||||
Metadata? metadata,
|
||||
String? art,
|
||||
}) {
|
||||
final track = SpotubeTrack(
|
||||
YoutubeVideoInfo(
|
||||
searchMode: SearchMode.youtube,
|
||||
id: "dQw4w9WgXcQ",
|
||||
title: basenameWithoutExtension(file.path),
|
||||
duration: Duration(milliseconds: metadata?.durationMs?.toInt() ?? 0),
|
||||
dislikes: 0,
|
||||
likes: 0,
|
||||
thumbnailUrl: art ?? "",
|
||||
views: 0,
|
||||
channelName: metadata?.albumArtist ?? "Spotube",
|
||||
channelId: metadata?.albumArtist ?? "Spotube",
|
||||
publishedAt:
|
||||
metadata?.year != null ? DateTime(metadata!.year!) : DateTime(2003),
|
||||
),
|
||||
file.path,
|
||||
[],
|
||||
);
|
||||
final track = Track();
|
||||
track.album = Album()
|
||||
..name = metadata?.album ?? "Spotube"
|
||||
..images = [if (art != null) Image()..url = art]
|
||||
|
||||
@ -11,8 +11,8 @@ installed_size: 24400
|
||||
|
||||
dependencies:
|
||||
- mpv
|
||||
- libappindicator3-1
|
||||
- gir1.2-appindicator3-0.1
|
||||
- libappindicator3-1 | libayatana-appindicator3-1
|
||||
- gir1.2-appindicator3-0.1 | gir1.2-ayatanaappindicator3-0.1
|
||||
- libsecret-1-0
|
||||
- libnotify-bin
|
||||
- libjsoncpp25
|
||||
|
||||
12
pubspec.lock
12
pubspec.lock
@ -1074,10 +1074,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: media_kit_libs_android_audio
|
||||
sha256: "767a93c44da73b7103a1fcbe2346f7211b7c44fa727f359410e690a156f630c5"
|
||||
sha256: f16e67d4c5a85cb603290da253456bc8ea3d85d932c778e3afd11195db2dc26d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.3.2"
|
||||
media_kit_libs_ios_audio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1715,6 +1715,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
stroke_text:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: stroke_text
|
||||
sha256: "0ec0e526c0eae7d21ce628d78eb9ae9be634259f26b0f1735f9ed540890d8cf6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
supabase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@ -69,7 +69,7 @@ dependencies:
|
||||
logger: ^1.1.0
|
||||
media_kit: ^1.1.3
|
||||
media_kit_native_event_loop: ^1.0.7
|
||||
media_kit_libs_android_audio: ^1.3.1
|
||||
media_kit_libs_android_audio: ^1.3.2
|
||||
media_kit_libs_ios_audio: ^1.1.2
|
||||
media_kit_libs_macos_audio: ^1.1.2
|
||||
media_kit_libs_windows_audio: ^1.0.6
|
||||
@ -103,6 +103,7 @@ dependencies:
|
||||
ref: a738913c8ce2c9f47515382d40827e794a334274
|
||||
path: plugins/window_size
|
||||
youtube_explode_dart: ^2.0.1
|
||||
stroke_text: ^0.0.2
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"ca": [
|
||||
@ -11,53 +12,67 @@
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"de": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"hi": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"ja": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"pl": [
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"you_are_offline",
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
],
|
||||
|
||||
"zh": [
|
||||
"piped_api_down",
|
||||
"piped_down_error_instructions",
|
||||
"you_are_offline",
|
||||
"connection_restored"
|
||||
"connection_restored",
|
||||
"use_system_title_bar"
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user