Merge branch 'dev' into master

This commit is contained in:
Kingkor Roy Tirtho 2023-08-27 00:13:07 +06:00 committed by GitHub
commit 44ebada3bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 656 additions and 237 deletions

View File

@ -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",

View File

@ -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(

View File

@ -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;
}

View File

@ -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);

View File

@ -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),
)
],
),
),
),
),

View File

@ -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,
);
},
);
},

View File

@ -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)),
],

View File

@ -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) {

View File

@ -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();
}

View File

@ -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());

View File

@ -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
View 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"
}

View File

@ -20,5 +20,6 @@ class L10n {
const Locale('zh', 'CN'),
const Locale('pl', 'PL'),
const Locale('ru', 'RU'),
const Locale('pt', 'PT'),
];
}

View File

@ -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);

View File

@ -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);

View File

@ -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,
);
}),
),
),
),

View File

@ -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)

View File

@ -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) {

View File

@ -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;
})

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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,
),
);
});

View File

@ -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);

View File

@ -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)

View File

@ -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

View File

@ -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';

View File

@ -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,
);
}
}

View File

@ -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,
);
}

View File

@ -57,4 +57,8 @@ abstract class PrimitiveUtils {
}),
);
}
static String toSafeFileName(String str) {
return str.replaceAll(RegExp(r'[^\w\s\.\-_]'), "_");
}
}

View File

@ -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;

View File

@ -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]

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"
]
}