Compare commits

..

1 Commits

Author SHA1 Message Date
neonItem
5d04eaa3a7
Merge 21afee3cfb into 2bb91feb34 2025-08-10 22:52:42 +02:00
144 changed files with 5108 additions and 7760 deletions

View File

@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "d7b523b356d15fb81e7d340bbe52b47f93937323"
revision: "300451adae589accbece3490f4396f10bdf15e6e"
channel: "stable"
project_type: app
@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
- platform: windows
create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323
create_revision: 300451adae589accbece3490f4396f10bdf15e6e
base_revision: 300451adae589accbece3490f4396f10bdf15e6e
# User provided section

View File

@ -1,96 +0,0 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View File

@ -108,10 +108,6 @@ class AppRouter extends RootStackRouter {
path: "settings/about",
page: AboutSpotubeRoute.page,
),
AutoRoute(
path: "settings/scrobbling",
page: SettingsScrobblingRoute.page,
),
AutoRoute(
path: "album/:id",
page: AlbumRoute.page,

View File

@ -8,10 +8,10 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i41;
import 'package:flutter/material.dart' as _i42;
import 'package:shadcn_flutter/shadcn_flutter.dart' as _i44;
import 'package:spotube/models/metadata/metadata.dart' as _i43;
import 'package:auto_route/auto_route.dart' as _i40;
import 'package:flutter/material.dart' as _i41;
import 'package:shadcn_flutter/shadcn_flutter.dart' as _i43;
import 'package:spotube/models/metadata/metadata.dart' as _i42;
import 'package:spotube/pages/album/album.dart' as _i2;
import 'package:spotube/pages/artist/artist.dart' as _i3;
import 'package:spotube/pages/connect/connect.dart' as _i6;
@ -21,14 +21,14 @@ import 'package:spotube/pages/home/home.dart' as _i9;
import 'package:spotube/pages/home/sections/section_items.dart' as _i8;
import 'package:spotube/pages/lastfm_login/lastfm_login.dart' as _i10;
import 'package:spotube/pages/library/library.dart' as _i11;
import 'package:spotube/pages/library/user_albums.dart' as _i36;
import 'package:spotube/pages/library/user_artists.dart' as _i37;
import 'package:spotube/pages/library/user_downloads.dart' as _i38;
import 'package:spotube/pages/library/user_albums.dart' as _i35;
import 'package:spotube/pages/library/user_artists.dart' as _i36;
import 'package:spotube/pages/library/user_downloads.dart' as _i37;
import 'package:spotube/pages/library/user_local_tracks/local_folder.dart'
as _i13;
import 'package:spotube/pages/library/user_local_tracks/user_local_tracks.dart'
as _i39;
import 'package:spotube/pages/library/user_playlists.dart' as _i40;
as _i38;
import 'package:spotube/pages/library/user_playlists.dart' as _i39;
import 'package:spotube/pages/lyrics/lyrics.dart' as _i15;
import 'package:spotube/pages/lyrics/mini_lyrics.dart' as _i16;
import 'package:spotube/pages/player/lyrics.dart' as _i17;
@ -44,21 +44,20 @@ import 'package:spotube/pages/settings/blacklist.dart' as _i4;
import 'package:spotube/pages/settings/logs.dart' as _i14;
import 'package:spotube/pages/settings/metadata/metadata_form.dart' as _i24;
import 'package:spotube/pages/settings/metadata_plugins.dart' as _i25;
import 'package:spotube/pages/settings/scrobbling/scrobbling.dart' as _i27;
import 'package:spotube/pages/settings/settings.dart' as _i26;
import 'package:spotube/pages/stats/albums/albums.dart' as _i28;
import 'package:spotube/pages/stats/artists/artists.dart' as _i29;
import 'package:spotube/pages/stats/fees/fees.dart' as _i33;
import 'package:spotube/pages/stats/minutes/minutes.dart' as _i30;
import 'package:spotube/pages/stats/playlists/playlists.dart' as _i32;
import 'package:spotube/pages/stats/stats.dart' as _i31;
import 'package:spotube/pages/stats/streams/streams.dart' as _i34;
import 'package:spotube/pages/track/track.dart' as _i35;
import 'package:spotube/pages/stats/albums/albums.dart' as _i27;
import 'package:spotube/pages/stats/artists/artists.dart' as _i28;
import 'package:spotube/pages/stats/fees/fees.dart' as _i32;
import 'package:spotube/pages/stats/minutes/minutes.dart' as _i29;
import 'package:spotube/pages/stats/playlists/playlists.dart' as _i31;
import 'package:spotube/pages/stats/stats.dart' as _i30;
import 'package:spotube/pages/stats/streams/streams.dart' as _i33;
import 'package:spotube/pages/track/track.dart' as _i34;
/// generated route for
/// [_i1.AboutSpotubePage]
class AboutSpotubeRoute extends _i41.PageRouteInfo<void> {
const AboutSpotubeRoute({List<_i41.PageRouteInfo>? children})
class AboutSpotubeRoute extends _i40.PageRouteInfo<void> {
const AboutSpotubeRoute({List<_i40.PageRouteInfo>? children})
: super(
AboutSpotubeRoute.name,
initialChildren: children,
@ -66,7 +65,7 @@ class AboutSpotubeRoute extends _i41.PageRouteInfo<void> {
static const String name = 'AboutSpotubeRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i1.AboutSpotubePage();
@ -76,12 +75,12 @@ class AboutSpotubeRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i2.AlbumPage]
class AlbumRoute extends _i41.PageRouteInfo<AlbumRouteArgs> {
class AlbumRoute extends _i40.PageRouteInfo<AlbumRouteArgs> {
AlbumRoute({
_i42.Key? key,
_i41.Key? key,
required String id,
required _i43.SpotubeSimpleAlbumObject album,
List<_i41.PageRouteInfo>? children,
required _i42.SpotubeSimpleAlbumObject album,
List<_i40.PageRouteInfo>? children,
}) : super(
AlbumRoute.name,
args: AlbumRouteArgs(
@ -95,7 +94,7 @@ class AlbumRoute extends _i41.PageRouteInfo<AlbumRouteArgs> {
static const String name = 'AlbumRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<AlbumRouteArgs>();
@ -115,11 +114,11 @@ class AlbumRouteArgs {
required this.album,
});
final _i42.Key? key;
final _i41.Key? key;
final String id;
final _i43.SpotubeSimpleAlbumObject album;
final _i42.SpotubeSimpleAlbumObject album;
@override
String toString() {
@ -129,11 +128,11 @@ class AlbumRouteArgs {
/// generated route for
/// [_i3.ArtistPage]
class ArtistRoute extends _i41.PageRouteInfo<ArtistRouteArgs> {
class ArtistRoute extends _i40.PageRouteInfo<ArtistRouteArgs> {
ArtistRoute({
required String artistId,
_i42.Key? key,
List<_i41.PageRouteInfo>? children,
_i41.Key? key,
List<_i40.PageRouteInfo>? children,
}) : super(
ArtistRoute.name,
args: ArtistRouteArgs(
@ -146,7 +145,7 @@ class ArtistRoute extends _i41.PageRouteInfo<ArtistRouteArgs> {
static const String name = 'ArtistRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final pathParams = data.inheritedPathParams;
@ -168,7 +167,7 @@ class ArtistRouteArgs {
final String artistId;
final _i42.Key? key;
final _i41.Key? key;
@override
String toString() {
@ -178,8 +177,8 @@ class ArtistRouteArgs {
/// generated route for
/// [_i4.BlackListPage]
class BlackListRoute extends _i41.PageRouteInfo<void> {
const BlackListRoute({List<_i41.PageRouteInfo>? children})
class BlackListRoute extends _i40.PageRouteInfo<void> {
const BlackListRoute({List<_i40.PageRouteInfo>? children})
: super(
BlackListRoute.name,
initialChildren: children,
@ -187,7 +186,7 @@ class BlackListRoute extends _i41.PageRouteInfo<void> {
static const String name = 'BlackListRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i4.BlackListPage();
@ -197,8 +196,8 @@ class BlackListRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i5.ConnectControlPage]
class ConnectControlRoute extends _i41.PageRouteInfo<void> {
const ConnectControlRoute({List<_i41.PageRouteInfo>? children})
class ConnectControlRoute extends _i40.PageRouteInfo<void> {
const ConnectControlRoute({List<_i40.PageRouteInfo>? children})
: super(
ConnectControlRoute.name,
initialChildren: children,
@ -206,7 +205,7 @@ class ConnectControlRoute extends _i41.PageRouteInfo<void> {
static const String name = 'ConnectControlRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i5.ConnectControlPage();
@ -216,8 +215,8 @@ class ConnectControlRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i6.ConnectPage]
class ConnectRoute extends _i41.PageRouteInfo<void> {
const ConnectRoute({List<_i41.PageRouteInfo>? children})
class ConnectRoute extends _i40.PageRouteInfo<void> {
const ConnectRoute({List<_i40.PageRouteInfo>? children})
: super(
ConnectRoute.name,
initialChildren: children,
@ -225,7 +224,7 @@ class ConnectRoute extends _i41.PageRouteInfo<void> {
static const String name = 'ConnectRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i6.ConnectPage();
@ -235,8 +234,8 @@ class ConnectRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i7.GettingStartedPage]
class GettingStartedRoute extends _i41.PageRouteInfo<void> {
const GettingStartedRoute({List<_i41.PageRouteInfo>? children})
class GettingStartedRoute extends _i40.PageRouteInfo<void> {
const GettingStartedRoute({List<_i40.PageRouteInfo>? children})
: super(
GettingStartedRoute.name,
initialChildren: children,
@ -244,7 +243,7 @@ class GettingStartedRoute extends _i41.PageRouteInfo<void> {
static const String name = 'GettingStartedRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i7.GettingStartedPage();
@ -255,12 +254,12 @@ class GettingStartedRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i8.HomeBrowseSectionItemsPage]
class HomeBrowseSectionItemsRoute
extends _i41.PageRouteInfo<HomeBrowseSectionItemsRouteArgs> {
extends _i40.PageRouteInfo<HomeBrowseSectionItemsRouteArgs> {
HomeBrowseSectionItemsRoute({
_i44.Key? key,
_i43.Key? key,
required String sectionId,
required _i43.SpotubeBrowseSectionObject<Object> section,
List<_i41.PageRouteInfo>? children,
required _i42.SpotubeBrowseSectionObject<Object> section,
List<_i40.PageRouteInfo>? children,
}) : super(
HomeBrowseSectionItemsRoute.name,
args: HomeBrowseSectionItemsRouteArgs(
@ -274,7 +273,7 @@ class HomeBrowseSectionItemsRoute
static const String name = 'HomeBrowseSectionItemsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<HomeBrowseSectionItemsRouteArgs>();
@ -294,11 +293,11 @@ class HomeBrowseSectionItemsRouteArgs {
required this.section,
});
final _i44.Key? key;
final _i43.Key? key;
final String sectionId;
final _i43.SpotubeBrowseSectionObject<Object> section;
final _i42.SpotubeBrowseSectionObject<Object> section;
@override
String toString() {
@ -308,8 +307,8 @@ class HomeBrowseSectionItemsRouteArgs {
/// generated route for
/// [_i9.HomePage]
class HomeRoute extends _i41.PageRouteInfo<void> {
const HomeRoute({List<_i41.PageRouteInfo>? children})
class HomeRoute extends _i40.PageRouteInfo<void> {
const HomeRoute({List<_i40.PageRouteInfo>? children})
: super(
HomeRoute.name,
initialChildren: children,
@ -317,7 +316,7 @@ class HomeRoute extends _i41.PageRouteInfo<void> {
static const String name = 'HomeRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i9.HomePage();
@ -327,8 +326,8 @@ class HomeRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i10.LastFMLoginPage]
class LastFMLoginRoute extends _i41.PageRouteInfo<void> {
const LastFMLoginRoute({List<_i41.PageRouteInfo>? children})
class LastFMLoginRoute extends _i40.PageRouteInfo<void> {
const LastFMLoginRoute({List<_i40.PageRouteInfo>? children})
: super(
LastFMLoginRoute.name,
initialChildren: children,
@ -336,7 +335,7 @@ class LastFMLoginRoute extends _i41.PageRouteInfo<void> {
static const String name = 'LastFMLoginRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i10.LastFMLoginPage();
@ -346,8 +345,8 @@ class LastFMLoginRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i11.LibraryPage]
class LibraryRoute extends _i41.PageRouteInfo<void> {
const LibraryRoute({List<_i41.PageRouteInfo>? children})
class LibraryRoute extends _i40.PageRouteInfo<void> {
const LibraryRoute({List<_i40.PageRouteInfo>? children})
: super(
LibraryRoute.name,
initialChildren: children,
@ -355,7 +354,7 @@ class LibraryRoute extends _i41.PageRouteInfo<void> {
static const String name = 'LibraryRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i11.LibraryPage();
@ -365,11 +364,11 @@ class LibraryRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i12.LikedPlaylistPage]
class LikedPlaylistRoute extends _i41.PageRouteInfo<LikedPlaylistRouteArgs> {
class LikedPlaylistRoute extends _i40.PageRouteInfo<LikedPlaylistRouteArgs> {
LikedPlaylistRoute({
_i42.Key? key,
required _i43.SpotubeSimplePlaylistObject playlist,
List<_i41.PageRouteInfo>? children,
_i41.Key? key,
required _i42.SpotubeSimplePlaylistObject playlist,
List<_i40.PageRouteInfo>? children,
}) : super(
LikedPlaylistRoute.name,
args: LikedPlaylistRouteArgs(
@ -381,7 +380,7 @@ class LikedPlaylistRoute extends _i41.PageRouteInfo<LikedPlaylistRouteArgs> {
static const String name = 'LikedPlaylistRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<LikedPlaylistRouteArgs>();
@ -399,9 +398,9 @@ class LikedPlaylistRouteArgs {
required this.playlist,
});
final _i42.Key? key;
final _i41.Key? key;
final _i43.SpotubeSimplePlaylistObject playlist;
final _i42.SpotubeSimplePlaylistObject playlist;
@override
String toString() {
@ -411,13 +410,13 @@ class LikedPlaylistRouteArgs {
/// generated route for
/// [_i13.LocalLibraryPage]
class LocalLibraryRoute extends _i41.PageRouteInfo<LocalLibraryRouteArgs> {
class LocalLibraryRoute extends _i40.PageRouteInfo<LocalLibraryRouteArgs> {
LocalLibraryRoute({
required String location,
_i42.Key? key,
_i41.Key? key,
bool isDownloads = false,
bool isCache = false,
List<_i41.PageRouteInfo>? children,
List<_i40.PageRouteInfo>? children,
}) : super(
LocalLibraryRoute.name,
args: LocalLibraryRouteArgs(
@ -431,7 +430,7 @@ class LocalLibraryRoute extends _i41.PageRouteInfo<LocalLibraryRouteArgs> {
static const String name = 'LocalLibraryRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<LocalLibraryRouteArgs>();
@ -455,7 +454,7 @@ class LocalLibraryRouteArgs {
final String location;
final _i42.Key? key;
final _i41.Key? key;
final bool isDownloads;
@ -469,8 +468,8 @@ class LocalLibraryRouteArgs {
/// generated route for
/// [_i14.LogsPage]
class LogsRoute extends _i41.PageRouteInfo<void> {
const LogsRoute({List<_i41.PageRouteInfo>? children})
class LogsRoute extends _i40.PageRouteInfo<void> {
const LogsRoute({List<_i40.PageRouteInfo>? children})
: super(
LogsRoute.name,
initialChildren: children,
@ -478,7 +477,7 @@ class LogsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'LogsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i14.LogsPage();
@ -488,8 +487,8 @@ class LogsRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i15.LyricsPage]
class LyricsRoute extends _i41.PageRouteInfo<void> {
const LyricsRoute({List<_i41.PageRouteInfo>? children})
class LyricsRoute extends _i40.PageRouteInfo<void> {
const LyricsRoute({List<_i40.PageRouteInfo>? children})
: super(
LyricsRoute.name,
initialChildren: children,
@ -497,7 +496,7 @@ class LyricsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'LyricsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i15.LyricsPage();
@ -507,11 +506,11 @@ class LyricsRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i16.MiniLyricsPage]
class MiniLyricsRoute extends _i41.PageRouteInfo<MiniLyricsRouteArgs> {
class MiniLyricsRoute extends _i40.PageRouteInfo<MiniLyricsRouteArgs> {
MiniLyricsRoute({
_i44.Key? key,
required _i44.Size prevSize,
List<_i41.PageRouteInfo>? children,
_i43.Key? key,
required _i43.Size prevSize,
List<_i40.PageRouteInfo>? children,
}) : super(
MiniLyricsRoute.name,
args: MiniLyricsRouteArgs(
@ -523,7 +522,7 @@ class MiniLyricsRoute extends _i41.PageRouteInfo<MiniLyricsRouteArgs> {
static const String name = 'MiniLyricsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<MiniLyricsRouteArgs>();
@ -541,9 +540,9 @@ class MiniLyricsRouteArgs {
required this.prevSize,
});
final _i44.Key? key;
final _i43.Key? key;
final _i44.Size prevSize;
final _i43.Size prevSize;
@override
String toString() {
@ -553,8 +552,8 @@ class MiniLyricsRouteArgs {
/// generated route for
/// [_i17.PlayerLyricsPage]
class PlayerLyricsRoute extends _i41.PageRouteInfo<void> {
const PlayerLyricsRoute({List<_i41.PageRouteInfo>? children})
class PlayerLyricsRoute extends _i40.PageRouteInfo<void> {
const PlayerLyricsRoute({List<_i40.PageRouteInfo>? children})
: super(
PlayerLyricsRoute.name,
initialChildren: children,
@ -562,7 +561,7 @@ class PlayerLyricsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'PlayerLyricsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i17.PlayerLyricsPage();
@ -572,8 +571,8 @@ class PlayerLyricsRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i18.PlayerQueuePage]
class PlayerQueueRoute extends _i41.PageRouteInfo<void> {
const PlayerQueueRoute({List<_i41.PageRouteInfo>? children})
class PlayerQueueRoute extends _i40.PageRouteInfo<void> {
const PlayerQueueRoute({List<_i40.PageRouteInfo>? children})
: super(
PlayerQueueRoute.name,
initialChildren: children,
@ -581,7 +580,7 @@ class PlayerQueueRoute extends _i41.PageRouteInfo<void> {
static const String name = 'PlayerQueueRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i18.PlayerQueuePage();
@ -591,8 +590,8 @@ class PlayerQueueRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i19.PlayerTrackSourcesPage]
class PlayerTrackSourcesRoute extends _i41.PageRouteInfo<void> {
const PlayerTrackSourcesRoute({List<_i41.PageRouteInfo>? children})
class PlayerTrackSourcesRoute extends _i40.PageRouteInfo<void> {
const PlayerTrackSourcesRoute({List<_i40.PageRouteInfo>? children})
: super(
PlayerTrackSourcesRoute.name,
initialChildren: children,
@ -600,7 +599,7 @@ class PlayerTrackSourcesRoute extends _i41.PageRouteInfo<void> {
static const String name = 'PlayerTrackSourcesRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i19.PlayerTrackSourcesPage();
@ -610,12 +609,12 @@ class PlayerTrackSourcesRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i20.PlaylistPage]
class PlaylistRoute extends _i41.PageRouteInfo<PlaylistRouteArgs> {
class PlaylistRoute extends _i40.PageRouteInfo<PlaylistRouteArgs> {
PlaylistRoute({
_i42.Key? key,
_i41.Key? key,
required String id,
required _i43.SpotubeSimplePlaylistObject playlist,
List<_i41.PageRouteInfo>? children,
required _i42.SpotubeSimplePlaylistObject playlist,
List<_i40.PageRouteInfo>? children,
}) : super(
PlaylistRoute.name,
args: PlaylistRouteArgs(
@ -629,7 +628,7 @@ class PlaylistRoute extends _i41.PageRouteInfo<PlaylistRouteArgs> {
static const String name = 'PlaylistRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<PlaylistRouteArgs>();
@ -649,11 +648,11 @@ class PlaylistRouteArgs {
required this.playlist,
});
final _i42.Key? key;
final _i41.Key? key;
final String id;
final _i43.SpotubeSimplePlaylistObject playlist;
final _i42.SpotubeSimplePlaylistObject playlist;
@override
String toString() {
@ -663,8 +662,8 @@ class PlaylistRouteArgs {
/// generated route for
/// [_i21.ProfilePage]
class ProfileRoute extends _i41.PageRouteInfo<void> {
const ProfileRoute({List<_i41.PageRouteInfo>? children})
class ProfileRoute extends _i40.PageRouteInfo<void> {
const ProfileRoute({List<_i40.PageRouteInfo>? children})
: super(
ProfileRoute.name,
initialChildren: children,
@ -672,7 +671,7 @@ class ProfileRoute extends _i41.PageRouteInfo<void> {
static const String name = 'ProfileRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i21.ProfilePage();
@ -682,8 +681,8 @@ class ProfileRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i22.RootAppPage]
class RootAppRoute extends _i41.PageRouteInfo<void> {
const RootAppRoute({List<_i41.PageRouteInfo>? children})
class RootAppRoute extends _i40.PageRouteInfo<void> {
const RootAppRoute({List<_i40.PageRouteInfo>? children})
: super(
RootAppRoute.name,
initialChildren: children,
@ -691,7 +690,7 @@ class RootAppRoute extends _i41.PageRouteInfo<void> {
static const String name = 'RootAppRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i22.RootAppPage();
@ -701,8 +700,8 @@ class RootAppRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i23.SearchPage]
class SearchRoute extends _i41.PageRouteInfo<void> {
const SearchRoute({List<_i41.PageRouteInfo>? children})
class SearchRoute extends _i40.PageRouteInfo<void> {
const SearchRoute({List<_i40.PageRouteInfo>? children})
: super(
SearchRoute.name,
initialChildren: children,
@ -710,7 +709,7 @@ class SearchRoute extends _i41.PageRouteInfo<void> {
static const String name = 'SearchRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i23.SearchPage();
@ -721,12 +720,12 @@ class SearchRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i24.SettingsMetadataProviderFormPage]
class SettingsMetadataProviderFormRoute
extends _i41.PageRouteInfo<SettingsMetadataProviderFormRouteArgs> {
extends _i40.PageRouteInfo<SettingsMetadataProviderFormRouteArgs> {
SettingsMetadataProviderFormRoute({
_i44.Key? key,
_i43.Key? key,
required String title,
required List<_i43.MetadataFormFieldObject> fields,
List<_i41.PageRouteInfo>? children,
required List<_i42.MetadataFormFieldObject> fields,
List<_i40.PageRouteInfo>? children,
}) : super(
SettingsMetadataProviderFormRoute.name,
args: SettingsMetadataProviderFormRouteArgs(
@ -739,7 +738,7 @@ class SettingsMetadataProviderFormRoute
static const String name = 'SettingsMetadataProviderFormRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final args = data.argsAs<SettingsMetadataProviderFormRouteArgs>();
@ -759,11 +758,11 @@ class SettingsMetadataProviderFormRouteArgs {
required this.fields,
});
final _i44.Key? key;
final _i43.Key? key;
final String title;
final List<_i43.MetadataFormFieldObject> fields;
final List<_i42.MetadataFormFieldObject> fields;
@override
String toString() {
@ -773,8 +772,8 @@ class SettingsMetadataProviderFormRouteArgs {
/// generated route for
/// [_i25.SettingsMetadataProviderPage]
class SettingsMetadataProviderRoute extends _i41.PageRouteInfo<void> {
const SettingsMetadataProviderRoute({List<_i41.PageRouteInfo>? children})
class SettingsMetadataProviderRoute extends _i40.PageRouteInfo<void> {
const SettingsMetadataProviderRoute({List<_i40.PageRouteInfo>? children})
: super(
SettingsMetadataProviderRoute.name,
initialChildren: children,
@ -782,7 +781,7 @@ class SettingsMetadataProviderRoute extends _i41.PageRouteInfo<void> {
static const String name = 'SettingsMetadataProviderRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i25.SettingsMetadataProviderPage();
@ -792,8 +791,8 @@ class SettingsMetadataProviderRoute extends _i41.PageRouteInfo<void> {
/// generated route for
/// [_i26.SettingsPage]
class SettingsRoute extends _i41.PageRouteInfo<void> {
const SettingsRoute({List<_i41.PageRouteInfo>? children})
class SettingsRoute extends _i40.PageRouteInfo<void> {
const SettingsRoute({List<_i40.PageRouteInfo>? children})
: super(
SettingsRoute.name,
initialChildren: children,
@ -801,7 +800,7 @@ class SettingsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'SettingsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i26.SettingsPage();
@ -810,28 +809,9 @@ class SettingsRoute extends _i41.PageRouteInfo<void> {
}
/// generated route for
/// [_i27.SettingsScrobblingPage]
class SettingsScrobblingRoute extends _i41.PageRouteInfo<void> {
const SettingsScrobblingRoute({List<_i41.PageRouteInfo>? children})
: super(
SettingsScrobblingRoute.name,
initialChildren: children,
);
static const String name = 'SettingsScrobblingRoute';
static _i41.PageInfo page = _i41.PageInfo(
name,
builder: (data) {
return const _i27.SettingsScrobblingPage();
},
);
}
/// generated route for
/// [_i28.StatsAlbumsPage]
class StatsAlbumsRoute extends _i41.PageRouteInfo<void> {
const StatsAlbumsRoute({List<_i41.PageRouteInfo>? children})
/// [_i27.StatsAlbumsPage]
class StatsAlbumsRoute extends _i40.PageRouteInfo<void> {
const StatsAlbumsRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsAlbumsRoute.name,
initialChildren: children,
@ -839,18 +819,18 @@ class StatsAlbumsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsAlbumsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i28.StatsAlbumsPage();
return const _i27.StatsAlbumsPage();
},
);
}
/// generated route for
/// [_i29.StatsArtistsPage]
class StatsArtistsRoute extends _i41.PageRouteInfo<void> {
const StatsArtistsRoute({List<_i41.PageRouteInfo>? children})
/// [_i28.StatsArtistsPage]
class StatsArtistsRoute extends _i40.PageRouteInfo<void> {
const StatsArtistsRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsArtistsRoute.name,
initialChildren: children,
@ -858,18 +838,18 @@ class StatsArtistsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsArtistsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i29.StatsArtistsPage();
return const _i28.StatsArtistsPage();
},
);
}
/// generated route for
/// [_i30.StatsMinutesPage]
class StatsMinutesRoute extends _i41.PageRouteInfo<void> {
const StatsMinutesRoute({List<_i41.PageRouteInfo>? children})
/// [_i29.StatsMinutesPage]
class StatsMinutesRoute extends _i40.PageRouteInfo<void> {
const StatsMinutesRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsMinutesRoute.name,
initialChildren: children,
@ -877,18 +857,18 @@ class StatsMinutesRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsMinutesRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i30.StatsMinutesPage();
return const _i29.StatsMinutesPage();
},
);
}
/// generated route for
/// [_i31.StatsPage]
class StatsRoute extends _i41.PageRouteInfo<void> {
const StatsRoute({List<_i41.PageRouteInfo>? children})
/// [_i30.StatsPage]
class StatsRoute extends _i40.PageRouteInfo<void> {
const StatsRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsRoute.name,
initialChildren: children,
@ -896,18 +876,18 @@ class StatsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i31.StatsPage();
return const _i30.StatsPage();
},
);
}
/// generated route for
/// [_i32.StatsPlaylistsPage]
class StatsPlaylistsRoute extends _i41.PageRouteInfo<void> {
const StatsPlaylistsRoute({List<_i41.PageRouteInfo>? children})
/// [_i31.StatsPlaylistsPage]
class StatsPlaylistsRoute extends _i40.PageRouteInfo<void> {
const StatsPlaylistsRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsPlaylistsRoute.name,
initialChildren: children,
@ -915,18 +895,18 @@ class StatsPlaylistsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsPlaylistsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i32.StatsPlaylistsPage();
return const _i31.StatsPlaylistsPage();
},
);
}
/// generated route for
/// [_i33.StatsStreamFeesPage]
class StatsStreamFeesRoute extends _i41.PageRouteInfo<void> {
const StatsStreamFeesRoute({List<_i41.PageRouteInfo>? children})
/// [_i32.StatsStreamFeesPage]
class StatsStreamFeesRoute extends _i40.PageRouteInfo<void> {
const StatsStreamFeesRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsStreamFeesRoute.name,
initialChildren: children,
@ -934,18 +914,18 @@ class StatsStreamFeesRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsStreamFeesRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i33.StatsStreamFeesPage();
return const _i32.StatsStreamFeesPage();
},
);
}
/// generated route for
/// [_i34.StatsStreamsPage]
class StatsStreamsRoute extends _i41.PageRouteInfo<void> {
const StatsStreamsRoute({List<_i41.PageRouteInfo>? children})
/// [_i33.StatsStreamsPage]
class StatsStreamsRoute extends _i40.PageRouteInfo<void> {
const StatsStreamsRoute({List<_i40.PageRouteInfo>? children})
: super(
StatsStreamsRoute.name,
initialChildren: children,
@ -953,21 +933,21 @@ class StatsStreamsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'StatsStreamsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i34.StatsStreamsPage();
return const _i33.StatsStreamsPage();
},
);
}
/// generated route for
/// [_i35.TrackPage]
class TrackRoute extends _i41.PageRouteInfo<TrackRouteArgs> {
/// [_i34.TrackPage]
class TrackRoute extends _i40.PageRouteInfo<TrackRouteArgs> {
TrackRoute({
_i44.Key? key,
_i43.Key? key,
required String trackId,
List<_i41.PageRouteInfo>? children,
List<_i40.PageRouteInfo>? children,
}) : super(
TrackRoute.name,
args: TrackRouteArgs(
@ -980,13 +960,13 @@ class TrackRoute extends _i41.PageRouteInfo<TrackRouteArgs> {
static const String name = 'TrackRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<TrackRouteArgs>(
orElse: () => TrackRouteArgs(trackId: pathParams.getString('id')));
return _i35.TrackPage(
return _i34.TrackPage(
key: args.key,
trackId: args.trackId,
);
@ -1000,7 +980,7 @@ class TrackRouteArgs {
required this.trackId,
});
final _i44.Key? key;
final _i43.Key? key;
final String trackId;
@ -1011,9 +991,9 @@ class TrackRouteArgs {
}
/// generated route for
/// [_i36.UserAlbumsPage]
class UserAlbumsRoute extends _i41.PageRouteInfo<void> {
const UserAlbumsRoute({List<_i41.PageRouteInfo>? children})
/// [_i35.UserAlbumsPage]
class UserAlbumsRoute extends _i40.PageRouteInfo<void> {
const UserAlbumsRoute({List<_i40.PageRouteInfo>? children})
: super(
UserAlbumsRoute.name,
initialChildren: children,
@ -1021,18 +1001,18 @@ class UserAlbumsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'UserAlbumsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i36.UserAlbumsPage();
return const _i35.UserAlbumsPage();
},
);
}
/// generated route for
/// [_i37.UserArtistsPage]
class UserArtistsRoute extends _i41.PageRouteInfo<void> {
const UserArtistsRoute({List<_i41.PageRouteInfo>? children})
/// [_i36.UserArtistsPage]
class UserArtistsRoute extends _i40.PageRouteInfo<void> {
const UserArtistsRoute({List<_i40.PageRouteInfo>? children})
: super(
UserArtistsRoute.name,
initialChildren: children,
@ -1040,18 +1020,18 @@ class UserArtistsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'UserArtistsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i37.UserArtistsPage();
return const _i36.UserArtistsPage();
},
);
}
/// generated route for
/// [_i38.UserDownloadsPage]
class UserDownloadsRoute extends _i41.PageRouteInfo<void> {
const UserDownloadsRoute({List<_i41.PageRouteInfo>? children})
/// [_i37.UserDownloadsPage]
class UserDownloadsRoute extends _i40.PageRouteInfo<void> {
const UserDownloadsRoute({List<_i40.PageRouteInfo>? children})
: super(
UserDownloadsRoute.name,
initialChildren: children,
@ -1059,18 +1039,18 @@ class UserDownloadsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'UserDownloadsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i38.UserDownloadsPage();
return const _i37.UserDownloadsPage();
},
);
}
/// generated route for
/// [_i39.UserLocalLibraryPage]
class UserLocalLibraryRoute extends _i41.PageRouteInfo<void> {
const UserLocalLibraryRoute({List<_i41.PageRouteInfo>? children})
/// [_i38.UserLocalLibraryPage]
class UserLocalLibraryRoute extends _i40.PageRouteInfo<void> {
const UserLocalLibraryRoute({List<_i40.PageRouteInfo>? children})
: super(
UserLocalLibraryRoute.name,
initialChildren: children,
@ -1078,18 +1058,18 @@ class UserLocalLibraryRoute extends _i41.PageRouteInfo<void> {
static const String name = 'UserLocalLibraryRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i39.UserLocalLibraryPage();
return const _i38.UserLocalLibraryPage();
},
);
}
/// generated route for
/// [_i40.UserPlaylistsPage]
class UserPlaylistsRoute extends _i41.PageRouteInfo<void> {
const UserPlaylistsRoute({List<_i41.PageRouteInfo>? children})
/// [_i39.UserPlaylistsPage]
class UserPlaylistsRoute extends _i40.PageRouteInfo<void> {
const UserPlaylistsRoute({List<_i40.PageRouteInfo>? children})
: super(
UserPlaylistsRoute.name,
initialChildren: children,
@ -1097,10 +1077,10 @@ class UserPlaylistsRoute extends _i41.PageRouteInfo<void> {
static const String name = 'UserPlaylistsRoute';
static _i41.PageInfo page = _i41.PageInfo(
static _i40.PageInfo page = _i40.PageInfo(
name,
builder: (data) {
return const _i40.UserPlaylistsPage();
return const _i39.UserPlaylistsPage();
},
);
}

View File

@ -1,137 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
class ErrorBox extends StatelessWidget {
final Object error;
final VoidCallback? onRetry;
const ErrorBox({
super.key,
required this.error,
this.onRetry,
});
@override
Widget build(BuildContext context) {
// Make a monospace error log view. Make sure it's only 4 lines
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
const Basic(
leading: Icon(SpotubeIcons.error),
contentSpacing: 8,
title: Text("An error occurred"),
),
Card(
padding: const EdgeInsets.all(8.0),
filled: true,
fillColor: context.theme.colorScheme.muted,
child: Text(
error.toString(),
style: TextStyle(
// Use monospace
fontFamily: 'Ubuntu Mono',
color: context.theme.colorScheme.mutedForeground,
fontSize: 14,
),
maxLines: 6,
overflow: TextOverflow.ellipsis,
),
),
// Show a dialog with full log and a retry button as well
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Button.text(
leading: const Icon(SpotubeIcons.logs),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 480,
maxHeight:
MediaQuery.of(context).size.height * 0.8,
),
child: AlertDialog(
padding: const EdgeInsets.all(12),
title: Row(
spacing: 8,
children: [
const Icon(SpotubeIcons.logs),
const Text("Logs"),
const Spacer(),
IconButton.ghost(
icon: const Icon(SpotubeIcons.close),
onPressed: () => context.maybePop(),
)
],
),
actions: [
HookBuilder(builder: (context) {
final copied = useState(false);
return Button.ghost(
leading: copied.value
? const Icon(SpotubeIcons.done)
: const Icon(SpotubeIcons.clipboard),
child: const Text("Copy to clipboard"),
onPressed: () {
Clipboard.setData(
ClipboardData(text: error.toString()),
);
copied.value = true;
},
);
})
],
content: SingleChildScrollView(
child: Card(
padding: const EdgeInsets.all(8.0),
filled: true,
fillColor: context.theme.colorScheme.muted,
child: SelectableText(
error.toString(),
style: TextStyle(
// Use monospace
fontFamily: 'Ubuntu Mono',
color: context
.theme.colorScheme.mutedForeground,
fontSize: 16,
),
),
),
),
),
);
},
);
},
child: const Text("View logs"),
),
if (onRetry != null)
Button.text(
leading: const Icon(SpotubeIcons.refresh),
onPressed: onRetry,
child: const Text("Retry"),
),
],
),
],
),
),
),
);
}
}

View File

@ -1,41 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart';
class NoDefaultMetadataPlugin extends StatelessWidget {
const NoDefaultMetadataPlugin({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 10,
children: [
Undraw(
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.stars,
color: context.theme.colorScheme.primary,
),
AutoSizeText(
"You've no default metadata provider set",
style: context.theme.typography.h4,
maxLines: 1,
),
Button.primary(
leading: const Icon(SpotubeIcons.extensions),
child: const Text("Manage metadata providers"),
onPressed: () {
context.pushRoute(const SettingsMetadataProviderRoute());
},
),
],
),
);
}
}

View File

@ -60,14 +60,6 @@ class SpotubeTrackObject with _$SpotubeTrackObject {
],
releaseDate:
metadata?.year != null ? "${metadata!.year}-01-01" : "1970-01-01",
images: [
if (art != null)
SpotubeImageObject(
url: art,
width: 300,
height: 300,
),
],
),
durationMs: metadata?.durationMs?.toInt() ?? 0,
path: file.path,

View File

@ -39,9 +39,8 @@ class TrackSourceQuery with _$TrackSourceQuery {
/// Parses [SpotubeMedia]'s [uri] property to create a [TrackSourceQuery].
factory TrackSourceQuery.parseUri(String url) {
final uri = Uri.parse(url);
final isLocal = uri.queryParameters.isEmpty;
return TrackSourceQuery(
id: isLocal ? uri.path : uri.pathSegments.last,
id: uri.pathSegments.last,
title: uri.queryParameters['title'] ?? '',
artists: uri.queryParameters['artists']?.split(',') ?? [],
album: uri.queryParameters['album'] ?? '',

View File

@ -2,13 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/metadata_plugin/browse/sections.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
@ -47,29 +44,6 @@ class HomePageBrowseSection extends HookConsumerWidget {
);
}
if (browseSections.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const SliverFillRemaining(
child: Center(child: NoDefaultMetadataPlugin()),
);
}
if (browseSections.hasError) {
return SliverFillRemaining(
child: Center(
child: ErrorBox(
error: browseSections.error!,
onRetry: () {
ref.invalidate(metadataPluginBrowseSectionsProvider);
},
),
),
);
}
return SliverInfiniteList(
hasReachedMax: browseSections.asData?.value.hasMore == false,
isLoading: !browseSections.isLoading && browseSections.isLoadingNextPage,

View File

@ -80,9 +80,6 @@ class Sidebar extends HookConsumerWidget {
),
for (final tile in sidebarTileList)
NavigationButton(
style: router.currentPath.startsWith(tile.pathPrefix)
? const ButtonStyle.secondary()
: null,
label: mediaQuery.lgAndUp ? Text(tile.title) : null,
child: Tooltip(
tooltip: TooltipContainer(child: Text(tile.title)).call,
@ -97,9 +94,6 @@ class Sidebar extends HookConsumerWidget {
NavigationLabel(child: Text(context.l10n.library)),
for (final tile in sidebarLibraryTileList)
NavigationButton(
style: router.currentPath.startsWith(tile.pathPrefix)
? const ButtonStyle.secondary()
: null,
label: mediaQuery.lgAndUp ? Text(tile.title) : null,
onPressed: () {
context.navigateTo(tile.route);

View File

@ -104,15 +104,15 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Button.primary(
leading: const Icon(SpotubeIcons.extensions),
Button.secondary(
leading: const Icon(SpotubeIcons.anonymous),
onPressed: () async {
await KVStoreService.setDoneGettingStarted(true);
if (context.mounted) {
context.pushRoute(const SettingsMetadataProviderRoute());
context.navigateTo(const HomeRoute());
}
},
child: const Text("Install a Metadata Provider"),
child: Text(context.l10n.browse_anonymously),
),
],
),

View File

@ -9,8 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
@ -19,7 +17,6 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/albums.dart';
import 'package:auto_route/auto_route.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
@RoutePage()
class UserAlbumsPage extends HookConsumerWidget {
@ -53,27 +50,10 @@ class UserAlbumsPage extends HookConsumerWidget {
[];
}, [albumsQuery.asData?.value, searchText.value]);
if (albumsQuery.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const Center(child: NoDefaultMetadataPlugin());
}
if (authenticated.asData?.value != true) {
return const AnonymousFallback();
}
if (albumsQuery.hasError) {
return ErrorBox(
error: albumsQuery.error!,
onRetry: () {
ref.invalidate(metadataPluginSavedAlbumsProvider);
},
);
}
return SafeArea(
bottom: false,
child: Scaffold(

View File

@ -12,8 +12,6 @@ import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/waypoint.dart';
@ -22,7 +20,6 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/artists.dart';
import 'package:auto_route/auto_route.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
@RoutePage()
class UserArtistsPage extends HookConsumerWidget {
@ -58,27 +55,10 @@ class UserArtistsPage extends HookConsumerWidget {
final controller = useScrollController();
if (artistQuery.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const Center(child: NoDefaultMetadataPlugin());
}
if (authenticated.asData?.value != true) {
return const AnonymousFallback();
}
if (artistQuery.hasError) {
return ErrorBox(
error: artistQuery.error!,
onRetry: () {
ref.invalidate(metadataPluginSavedArtistsProvider);
},
);
}
return SafeArea(
bottom: false,
child: Scaffold(

View File

@ -8,8 +8,6 @@ import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
@ -21,7 +19,6 @@ import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/core/user.dart';
import 'package:auto_route/auto_route.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
@RoutePage()
class UserPlaylistsPage extends HookConsumerWidget {
@ -81,27 +78,10 @@ class UserPlaylistsPage extends HookConsumerWidget {
final controller = useScrollController();
if (playlistsQuery.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const Center(child: NoDefaultMetadataPlugin());
}
if (authenticated.asData?.value != true) {
return const AnonymousFallback();
}
if (playlistsQuery.hasError) {
return ErrorBox(
error: playlistsQuery.error!,
onRetry: () {
ref.invalidate(metadataPluginSavedPlaylistsProvider);
},
);
}
return material.RefreshIndicator.adaptive(
onRefresh: () async {
ref.invalidate(metadataPluginSavedPlaylistsProvider);

View File

@ -7,8 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/string.dart';
@ -18,10 +17,10 @@ import 'package:spotube/pages/search/tabs/all.dart';
import 'package:spotube/pages/search/tabs/artists.dart';
import 'package:spotube/pages/search/tabs/playlists.dart';
import 'package:spotube/pages/search/tabs/tracks.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/search/all.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:auto_route/auto_route.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
final searchTermStateProvider = StateProvider<String>((ref) {
return "";
@ -38,6 +37,8 @@ class SearchPage extends HookConsumerWidget {
final controller = useShadcnTextEditingController();
final focusNode = useFocusNode();
final authenticated = ref.watch(metadataPluginAuthenticatedProvider);
final searchTerm = ref.watch(searchTermStateProvider);
final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider);
final selectedChip = useState<String?>(
@ -82,163 +83,147 @@ class SearchPage extends HookConsumerWidget {
if (kTitlebarVisible)
const TitleBar(automaticallyImplyLeading: false, height: 30)
],
child: Builder(builder: (context) {
if (searchChipSnapshot.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _
)) {
return const NoDefaultMetadataPlugin();
}
if (searchChipSnapshot.hasError) {
return ErrorBox(
error: searchChipSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchChipsProvider);
},
);
}
return Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: authenticated.asData?.value != true
? const AnonymousFallback()
: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: ListenableBuilder(
listenable: controller,
builder: (context, _) {
final suggestions = controller.text.isEmpty
? KVStoreService.recentSearches
: KVStoreService.recentSearches
.where(
(s) =>
weightedRatio(
s.toLowerCase(),
controller.text.toLowerCase(),
) >
50,
)
.toList();
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
child: ListenableBuilder(
listenable: controller,
builder: (context, _) {
final suggestions = controller.text.isEmpty
? KVStoreService.recentSearches
: KVStoreService.recentSearches
.where(
(s) =>
weightedRatio(
s.toLowerCase(),
controller.text.toLowerCase(),
) >
50,
)
.toList();
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (value) {
final isEnter = value.logicalKey ==
LogicalKeyboardKey.enter;
if (isEnter) {
onSubmitted(controller.text);
focusNode.unfocus();
}
},
child: AutoComplete(
suggestions: suggestions.length <= 2
? [
...suggestions,
"Twenty One Pilots",
"Linkin Park",
"d4vd"
]
: suggestions,
completer: (suggestion) => suggestion,
mode: AutoCompleteMode.replaceAll,
child: TextField(
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
controller: controller,
features: [
const InputFeature.leading(
Icon(SpotubeIcons.search),
),
InputFeature.trailing(
AnimatedCrossFade(
duration:
const Duration(milliseconds: 300),
crossFadeState:
controller.text.isNotEmpty
onKeyEvent: (value) {
final isEnter = value.logicalKey ==
LogicalKeyboardKey.enter;
if (isEnter) {
onSubmitted(controller.text);
focusNode.unfocus();
}
},
child: AutoComplete(
suggestions: suggestions.length <= 2
? [
...suggestions,
"Twenty One Pilots",
"Linkin Park",
"d4vd"
]
: suggestions,
completer: (suggestion) => suggestion,
mode: AutoCompleteMode.replaceAll,
child: TextField(
autofocus: true,
controller: controller,
features: [
const InputFeature.leading(
Icon(SpotubeIcons.search),
),
InputFeature.trailing(
AnimatedCrossFade(
duration: const Duration(
milliseconds: 300),
crossFadeState: controller
.text.isNotEmpty
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: IconButton.ghost(
size: ButtonSize.small,
icon:
const Icon(SpotubeIcons.close),
onPressed: () {
controller.clear();
},
),
secondChild: const SizedBox.square(
dimension: 28),
),
)
],
textInputAction: TextInputAction.search,
placeholder: Text(context.l10n.search),
onSubmitted: onSubmitted,
),
),
);
}),
firstChild: IconButton.ghost(
size: ButtonSize.small,
icon: const Icon(
SpotubeIcons.close),
onPressed: () {
controller.clear();
},
),
secondChild:
const SizedBox.square(
dimension: 28),
),
)
],
textInputAction: TextInputAction.search,
placeholder: Text(context.l10n.search),
onSubmitted: onSubmitted,
),
),
);
}),
),
),
],
),
Row(
spacing: 8,
children: [
const Gap(12),
if (searchChipSnapshot.asData?.value != null)
for (final chip in searchChipSnapshot.asData!.value)
Chip(
style: selectedChip.value == chip
? ButtonVariance.primary.copyWith(
decoration: (context, states, value) {
return ButtonVariance.primary
.decoration(context, states)
.copyWithIfBoxDecoration(
borderRadius:
BorderRadius.circular(100),
);
},
)
: ButtonVariance.secondary.copyWith(
decoration: (context, states, value) {
return ButtonVariance.secondary
.decoration(context, states)
.copyWithIfBoxDecoration(
borderRadius:
BorderRadius.circular(100),
);
},
),
child: Text(chip.capitalize()),
onPressed: () {
selectedChip.value = chip;
},
),
],
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch (selectedChip.value) {
"tracks" => const SearchPageTracksTab(),
"albums" => const SearchPageAlbumsTab(),
"artists" => const SearchPageArtistsTab(),
"playlists" => const SearchPagePlaylistsTab(),
_ => const SearchPageAllTab(),
},
),
),
],
),
Row(
spacing: 8,
children: [
const Gap(12),
if (searchChipSnapshot.asData?.value != null)
for (final chip in searchChipSnapshot.asData!.value)
Chip(
style: selectedChip.value == chip
? ButtonVariance.primary.copyWith(
decoration: (context, states, value) {
return ButtonVariance.primary
.decoration(context, states)
.copyWithIfBoxDecoration(
borderRadius:
BorderRadius.circular(100),
);
},
)
: ButtonVariance.secondary.copyWith(
decoration: (context, states, value) {
return ButtonVariance.secondary
.decoration(context, states)
.copyWithIfBoxDecoration(
borderRadius:
BorderRadius.circular(100),
);
},
),
child: Text(chip.capitalize()),
onPressed: () {
selectedChip.value = chip;
},
),
],
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: switch (selectedChip.value) {
"tracks" => const SearchPageTracksTab(),
"albums" => const SearchPageAlbumsTab(),
"artists" => const SearchPageArtistsTab(),
"playlists" => const SearchPagePlaylistsTab(),
_ => const SearchPageAllTab(),
},
),
),
],
);
}),
),
),
);

View File

@ -2,7 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/search/loading.dart';
@ -24,15 +23,6 @@ class SearchPageAlbumsTab extends HookConsumerWidget {
final searchAlbums =
searchAlbumsSnapshot.asData?.value.items ?? [FakeData.albumSimple];
if (searchAlbumsSnapshot.hasError) {
return ErrorBox(
error: searchAlbumsSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchAlbumsProvider(searchTerm));
},
);
}
return SearchPlaceholder(
snapshot: searchAlbumsSnapshot,
child: Padding(

View File

@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/modules/search/loading.dart';
import 'package:spotube/pages/search/search.dart';
@ -20,15 +19,6 @@ class SearchPageAllTab extends HookConsumerWidget {
final searchSnapshot =
ref.watch(metadataPluginSearchAllProvider(searchTerm));
if (searchSnapshot.hasError) {
return ErrorBox(
error: searchSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchAllProvider(searchTerm));
},
);
}
return SearchPlaceholder(
snapshot: searchSnapshot,
child: InterScrollbar(

View File

@ -5,7 +5,6 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
@ -28,15 +27,6 @@ class SearchPageArtistsTab extends HookConsumerWidget {
ref.read(metadataPluginSearchArtistsProvider(searchTerm).notifier);
final searchArtists = searchArtistsSnapshot.asData?.value.items ?? [];
if (searchArtistsSnapshot.hasError) {
return ErrorBox(
error: searchArtistsSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchArtistsProvider(searchTerm));
},
);
}
return SearchPlaceholder(
snapshot: searchArtistsSnapshot,
child: AnimatedSwitcher(

View File

@ -2,7 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/modules/search/loading.dart';
@ -24,15 +23,6 @@ class SearchPagePlaylistsTab extends HookConsumerWidget {
final searchPlaylists = searchPlaylistsSnapshot.asData?.value.items ??
[FakeData.playlistSimple];
if (searchPlaylistsSnapshot.hasError) {
return ErrorBox(
error: searchPlaylistsSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchPlaylistsProvider(searchTerm));
},
);
}
return SearchPlaceholder(
snapshot: searchPlaylistsSnapshot,
child: Padding(

View File

@ -4,7 +4,6 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
@ -32,15 +31,6 @@ class SearchPageTracksTab extends HookConsumerWidget {
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
if (searchTracksSnapshot.hasError) {
return ErrorBox(
error: searchTracksSnapshot.error!,
onRetry: () {
ref.invalidate(metadataPluginSearchTracksProvider(searchTerm));
},
);
}
return SearchPlaceholder(
snapshot: searchTracksSnapshot,
child: InfiniteList(

View File

@ -1,67 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'
show ListTile, ListTileTheme, ListTileThemeData, Material, MaterialType;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart';
@RoutePage()
class SettingsScrobblingPage extends HookConsumerWidget {
static const name = "settings_scrobbling";
const SettingsScrobblingPage({super.key});
@override
Widget build(BuildContext context, ref) {
return Material(
type: MaterialType.transparency,
child: ListTileTheme(
data: ListTileThemeData(
contentPadding: EdgeInsets.zero,
minVerticalPadding: 0,
shape: RoundedRectangleBorder(
borderRadius: context.theme.borderRadiusLg,
side: BorderSide(
color: context.theme.colorScheme.border,
width: .5,
),
),
textColor: context.theme.colorScheme.foreground,
iconColor: context.theme.colorScheme.foreground,
selectedColor: context.theme.colorScheme.accent,
subtitleTextStyle: context.theme.typography.xSmall,
),
child: SafeArea(
bottom: false,
child: Scaffold(
headers: const [TitleBar(title: Text("Scrobbling"))],
child: ListView(
padding: const EdgeInsets.all(8),
children: [
Card(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListTile(
leading: const Icon(SpotubeIcons.lastFm, color: Colors.red),
title: Text(context.l10n.login_with_lastfm),
subtitle: Text(context.l10n.scrobble_to_lastfm),
trailing: Button.secondary(
leading: const Icon(SpotubeIcons.lastFm),
onPressed: () {
context.navigateTo(const LastFMLoginRoute());
},
child: Text(context.l10n.connect),
),
),
),
],
),
),
),
),
);
}
}

View File

@ -31,12 +31,16 @@ class SettingsAccountSection extends HookConsumerWidget {
),
if (scrobbler.asData?.value == null)
ListTile(
leading: const Icon(SpotubeIcons.music),
title: const Text("Audio scrobblers"),
onTap: () {
context.pushRoute(const SettingsScrobblingRoute());
},
trailing: const Icon(SpotubeIcons.angleRight),
leading: const Icon(SpotubeIcons.lastFm),
title: Text(context.l10n.login_with_lastfm),
subtitle: Text(context.l10n.scrobble_to_lastfm),
trailing: Button.secondary(
leading: const Icon(SpotubeIcons.lastFm),
onPressed: () {
context.navigateTo(const LastFMLoginRoute());
},
child: Text(context.l10n.connect),
),
)
else
ListTile(

View File

@ -11,15 +11,10 @@ final queryingTrackInfoProvider = Provider<bool>((ref) {
return false;
}
if (audioPlayer.activeTrack is! SpotubeFullTrackObject) {
return false;
}
return ref
.watch(trackSourcesProvider(
TrackSourceQuery.fromTrack(
audioPlayer.activeTrack! as SpotubeFullTrackObject,
),
audioPlayer.activeTrack! as SpotubeFullTrackObject),
))
.isLoading;
});

View File

@ -80,20 +80,17 @@ Future<void> _sendActiveTrack(SpotubeTrackObject? track) async {
final jsonTrack = track.toJson();
final image = track.album.images.firstOrNull;
final cachedImage = image == null
? null
: await DefaultCacheManager().getSingleFile(image.url);
final image = track.album?.images.first;
final cachedImage = await DefaultCacheManager().getSingleFile(image!.url);
final data = {
...jsonTrack,
"album": {
...jsonTrack["album"],
"images": [
if (cachedImage != null && image != null)
{
...image.toJson(),
"path": cachedImage.path,
}
{
...image.toJson(),
"path": cachedImage.path,
}
]
}
};

View File

@ -35,12 +35,6 @@ const imgMimeToExt = {
"image/gif": ".gif",
};
typedef MetadataFile = ({
Metadata? metadata,
File file,
String? art,
});
final localTracksProvider =
FutureProvider<Map<String, List<SpotubeLocalTrackObject>>>((ref) async {
try {
@ -95,7 +89,7 @@ final localTracksProvider =
}
}
final List<MetadataFile> filesWithMetadata = await Future.wait(
final List<Map<dynamic, dynamic>> filesWithMetadata = await Future.wait(
entities.map((file) async {
try {
final metadata = await MetadataGod.readMetadata(file: file.path);
@ -117,10 +111,10 @@ final localTracksProvider =
);
}
return (metadata: metadata, file: file, art: imageFile.path);
return {"metadata": metadata, "file": file, "art": imageFile.path};
} catch (e, stack) {
if (e case FrbException() || TimeoutException()) {
return (file: file, metadata: null, art: null);
return {"file": file};
}
AppLogger.reportError(e, stack);
return null;
@ -131,9 +125,9 @@ final localTracksProvider =
final tracksFromMetadata = filesWithMetadata
.map(
(fileWithMetadata) => SpotubeTrackObject.localTrackFromFile(
fileWithMetadata.file,
metadata: fileWithMetadata.metadata,
art: fileWithMetadata.art,
fileWithMetadata["file"] as File,
metadata: fileWithMetadata["metadata"] as Metadata?,
art: fileWithMetadata["art"] as String?,
) as SpotubeLocalTrackObject,
)
.toList();

View File

@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
final metadataPluginAlbumProvider =
FutureProvider.autoDispose.family<SpotubeFullAlbumObject, String>(
@ -12,7 +12,9 @@ final metadataPluginAlbumProvider =
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No metadata plugin is not set",
);
}
return metadataPlugin.album.getAlbum(id);

View File

@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
final metadataPluginArtistProvider =
FutureProvider.autoDispose.family<SpotubeFullArtistObject, String>(
@ -12,7 +12,9 @@ final metadataPluginArtistProvider =
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No metadata plugin is not set",
);
}
return metadataPlugin.artist.getArtist(artistId);

View File

@ -4,7 +4,7 @@ import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
class MetadataPluginSavedPlaylistsNotifier
extends PaginatedAsyncNotifier<SpotubeSimplePlaylistObject> {
@ -111,7 +111,9 @@ final metadataPluginIsSavedPlaylistProvider =
final plugin = await ref.watch(metadataPluginProvider.future);
if (plugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"Failed to get metadata plugin",
);
}
final follows = await plugin.user.isSavedPlaylist(id);

View File

@ -4,7 +4,7 @@ import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/user.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:spotube/services/metadata/metadata.dart';
class MetadataPluginPlaylistNotifier
@ -13,7 +13,9 @@ class MetadataPluginPlaylistNotifier
final metadataPlugin = await ref.read(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"Metadata plugin is not set",
);
}
return metadataPlugin;

View File

@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
final metadataPluginSearchAllProvider =
FutureProvider.autoDispose.family<SpotubeSearchResponseObject, String>(
@ -9,7 +9,9 @@ final metadataPluginSearchAllProvider =
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No default metadata plugin found",
);
}
return metadataPlugin.search.all(query);
@ -20,7 +22,9 @@ final metadataPluginSearchChipsProvider = FutureProvider((ref) async {
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No default metadata plugin found",
);
}
return metadataPlugin.search.chips;
});

View File

@ -1,14 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
final metadataPluginTrackProvider =
FutureProvider.family<SpotubeFullTrackObject, String>((ref, trackId) async {
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No metadata plugin is set as default.");
}
return metadataPlugin.track.getTrack(trackId);

View File

@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:spotube/services/metadata/metadata.dart';
extension PaginationExtension<T> on AsyncValue<T> {
@ -20,7 +20,8 @@ mixin MetadataPluginMixin<K>
final plugin = await ref.read(metadataPluginProvider.future);
if (plugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"Metadata plugin is not set");
}
return plugin;

View File

@ -20,7 +20,7 @@ import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
@ -97,7 +97,9 @@ class TrackOptionsActions {
final metadataPlugin = await ref.read(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin();
throw MetadataPluginException.noDefaultPlugin(
"No default metadata plugin set",
);
}
final tracks = await metadataPlugin.track.radio(track.id);

View File

@ -0,0 +1,12 @@
class MetadataPluginException implements Exception {
final String exceptionType;
final String message;
MetadataPluginException.noDefaultPlugin(this.message)
: exceptionType = "NoDefault";
@override
String toString() {
return "${exceptionType}MetadataPluginException: $message";
}
}

View File

@ -9,7 +9,6 @@ enum MetadataPluginErrorCode {
pluginDownloadFailed,
duplicatePlugin,
pluginByteCodeFileNotFound,
noDefaultPlugin,
}
class MetadataPluginException implements Exception {
@ -68,11 +67,6 @@ class MetadataPluginException implements Exception {
'Plugin byte code file, plugin.out not found. Please ensure the plugin is correctly packaged.',
errorCode: MetadataPluginErrorCode.pluginByteCodeFileNotFound,
);
MetadataPluginException.noDefaultPlugin()
: this._(
'No default metadata plugin is set. Please set a default plugin in the settings.',
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
);
@override
String toString() => 'MetadataPluginException: $message';

View File

@ -515,8 +515,8 @@ packages:
dependency: "direct main"
description:
path: "packages/desktop_webview_window"
ref: HEAD
resolved-ref: f261ff20e310d05713249b21c199a9fe17a3de6f
ref: "feat/cookies"
resolved-ref: f20e433d4a948515b35089d40069f7dd9bced9e4
url: "https://github.com/KRTirtho/flutter-plugins.git"
source: git
version: "0.2.4"
@ -670,10 +670,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.3"
file:
dependency: transitive
description:
@ -762,15 +762,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fk_user_agent:
dependency: transitive
description:
path: "."
ref: master
resolved-ref: "922f9f9eafd8b501da83dca67d56b2887fa8f916"
url: "https://github.com/TiffApps/fk_user_agent.git"
source: git
version: "2.1.1"
fluentui_system_icons:
dependency: "direct main"
description:
@ -805,10 +796,10 @@ packages:
dependency: "direct main"
description:
name: flutter_discord_rpc
sha256: "7ee12a3ff928e85e5e0a60c7c23417a2f1f4e604e4983fc0789ff6562fb15f2b"
sha256: "9363a803863d56fd89c0a21639c70b126245fcbafaeb0a3d091e7ac06951d03f"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.0.0"
flutter_displaymode:
dependency: "direct main"
description:
@ -992,10 +983,10 @@ packages:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
sha256: "0ad5079de35d317650fec59b26cb4d0c116ebc2ce703a29f9367513b8a91c287"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.5.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -1226,7 +1217,7 @@ packages:
description:
path: "."
ref: main
resolved-ref: "52cd25a12c1af6a8819963d222026539e8537586"
resolved-ref: c4895250ee45a59c88770f97abebc9e9bbb62259
url: "https://github.com/KRTirtho/hetu_spotube_plugin.git"
source: git
version: "0.0.1"
@ -1648,10 +1639,10 @@ packages:
dependency: "direct main"
description:
name: metadata_god
sha256: "2b19b7e88cf5ab7016fe8d1f868bb35a193aaef06fd96cfc7de4ec3eacc16eb0"
sha256: "025d8149059f62f44108ab9d74ebd77aa8f0af98b238f3f25121a4711ee3e5d0"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.0.0"
mime:
dependency: "direct main"
description:
@ -2020,14 +2011,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
random_user_agents:
dependency: transitive
description:
name: random_user_agents
sha256: "19facde509a2482dababb454faf2aceff797a6ae08e80f91268c0c8a7420f03b"
url: "https://pub.dev"
source: hosted
version: "1.0.15"
recase:
dependency: transitive
description:
@ -2278,10 +2261,10 @@ packages:
dependency: "direct main"
description:
name: smtc_windows
sha256: dee279b0ddf663c4c729a88bca4e57fb4861aa1b3d01e230bdbf1277b8bfe664
sha256: "80f7c10867da485ffdf87f842bf27e6763589933c18c11af5dc1cd1e158c3154"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.0.0"
source_gen:
dependency: transitive
description:

View File

@ -27,6 +27,7 @@ dependencies:
desktop_webview_window:
git:
path: packages/desktop_webview_window
ref: feat/cookies
url: https://github.com/KRTirtho/flutter-plugins.git
device_info_plus: ^11.1.1
dio: ^5.4.3+1
@ -51,7 +52,7 @@ dependencies:
url: https://github.com/KRTirtho/flutter_broadcasts.git
ref: 63931dfe06733d4fb7452e9981e1f0b23414d97a
flutter_cache_manager: ^3.3.0
flutter_discord_rpc: ^1.1.0
flutter_discord_rpc: ^1.0.0
flutter_displaymode: ^0.6.0
flutter_feather_icons: ^2.0.0+1
flutter_form_builder: ^9.6.0
@ -86,7 +87,7 @@ dependencies:
lrc: ^1.0.2
media_kit: ^1.1.10+1
media_kit_libs_audio: ^1.0.4
metadata_god: ^1.1.0
metadata_god: ^1.0.0
mime: ^2.0.0
open_file: ^3.5.10
package_info_plus: ^6.0.0
@ -110,7 +111,7 @@ dependencies:
skeletonizer: ^2.1.0+1
sliding_up_panel: ^2.0.0+1
sliver_tools: ^0.2.12
smtc_windows: ^1.1.0
smtc_windows: ^1.0.0
sqlite3: ^2.4.3
sqlite3_flutter_libs: ^0.5.23
stroke_text: ^0.0.2
@ -216,7 +217,6 @@ flutter:
- packages/flutter_undraw/assets/undraw/empty.svg
- packages/flutter_undraw/assets/undraw/no_data.svg
- packages/flutter_undraw/assets/undraw/process.svg
- packages/flutter_undraw/assets/undraw/stars.svg
# hetu script bytecode
- packages/hetu_std/assets/bytecode/std.out
- packages/hetu_otp_util/assets/bytecode/otp_util.out
@ -233,20 +233,6 @@ flutter:
- asset: assets/fonts/Cookie-Regular.ttf
style: normal
weight: 500
- family: Ubuntu Mono
fonts:
- asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf
style: normal
weight: 400
- asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Bold.ttf
style: normal
weight: 700
- asset: assets/fonts/Ubuntu_Mono/UbuntuMono-Italic.ttf
style: italic
weight: 400
- asset: assets/fonts/Ubuntu_Mono/UbuntuMono-BoldItalic.ttf
style: italic
weight: 700
flutter_gen:
output: lib/collections

13
website/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

31
website/.eslintrc.cjs Normal file
View File

@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

33
website/.gitignore vendored
View File

@ -1,24 +1,11 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.netlify

View File

@ -1 +1 @@
22.17.0
20.11.0

1
website/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

4
website/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
website/.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View File

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

120
website/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,120 @@
{
"prettier.documentSelectors": [
"**/*.svelte"
],
"tailwindCSS.classAttributes": [
"class",
"accent",
"active",
"animIndeterminate",
"aspectRatio",
"background",
"badge",
"bgBackdrop",
"bgDark",
"bgDrawer",
"bgLight",
"blur",
"border",
"button",
"buttonAction",
"buttonBack",
"buttonClasses",
"buttonComplete",
"buttonDismiss",
"buttonNeutral",
"buttonNext",
"buttonPositive",
"buttonTextCancel",
"buttonTextConfirm",
"buttonTextFirst",
"buttonTextLast",
"buttonTextNext",
"buttonTextPrevious",
"buttonTextSubmit",
"caretClosed",
"caretOpen",
"chips",
"color",
"controlSeparator",
"controlVariant",
"cursor",
"display",
"element",
"fill",
"fillDark",
"fillLight",
"flex",
"flexDirection",
"gap",
"gridColumns",
"height",
"hover",
"inactive",
"indent",
"justify",
"meter",
"padding",
"position",
"regionAnchor",
"regionBackdrop",
"regionBody",
"regionCaption",
"regionCaret",
"regionCell",
"regionChildren",
"regionChipList",
"regionChipWrapper",
"regionCone",
"regionContent",
"regionControl",
"regionDefault",
"regionDrawer",
"regionFoot",
"regionFootCell",
"regionFooter",
"regionHead",
"regionHeadCell",
"regionHeader",
"regionIcon",
"regionInput",
"regionInterface",
"regionInterfaceText",
"regionLabel",
"regionLead",
"regionLegend",
"regionList",
"regionListItem",
"regionNavigation",
"regionPage",
"regionPanel",
"regionRowHeadline",
"regionRowMain",
"regionSummary",
"regionSymbol",
"regionTab",
"regionTrail",
"ring",
"rounded",
"select",
"shadow",
"slotDefault",
"slotFooter",
"slotHeader",
"slotLead",
"slotMessage",
"slotMeta",
"slotPageContent",
"slotPageFooter",
"slotPageHeader",
"slotSidebarLeft",
"slotSidebarRight",
"slotTrail",
"spacing",
"text",
"track",
"transition",
"width",
"zIndex"
]
}

View File

@ -1,46 +1,38 @@
# Astro Starter Kit: Basics
# create-svelte
```sh
pnpm create astro@latest -- --template basics
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
pnpm create svelte@latest
# create a new project in my-app
pnpm create svelte@latest my-app
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## Developing
## 🚀 Project Structure
Once you've created a project and installed dependencies with `pnpm install` (or `pnpm install` or `yarn`), start a development server:
Inside of your Astro project, you'll see the following folders and files:
```bash
pnpm run dev
```text
/
├── public/
│ └── favicon.svg
├── src
│   ├── assets
│   │   └── astro.svg
│   ├── components
│   │   └── Welcome.astro
│   ├── layouts
│   │   └── Layout.astro
│   └── pages
│   └── index.astro
└── package.json
# or start the server and open the app in a new browser tab
pnpm run dev -- --open
```
To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
## Building
## 🧞 Commands
To create a production version of your app:
All commands are run from the root of the project, from a terminal:
```bash
pnpm run build
```
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `pnpm install` | Installs dependencies |
| `pnpm dev` | Starts local dev server at `localhost:4321` |
| `pnpm build` | Build your production site to `./dist/` |
| `pnpm preview` | Preview your build locally, before deploying |
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
| `pnpm astro -- --help` | Get help using the Astro CLI |
You can preview the production build with `pnpm run preview`.
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

View File

@ -1,52 +0,0 @@
// @ts-check
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import pagefind from "astro-pagefind";
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
markdown: {
syntaxHighlight: "shiki",
shikiConfig: {
langAlias: {
hetu_script: "javascript",
},
},
gfm: true,
rehypePlugins: [
[rehypeSlug, {}],
[
rehypeAutolinkHeadings,
{
behavior: "wrap", // Adds the link at the end of the heading
properties: {
className: ["heading-link"], // Add a class for styling
"aria-hidden": "true",
},
content: {
// Optional: Use an SVG icon or text for the link
type: "element",
tagName: "span",
properties: { className: ["icon", "icon-link"] },
children: [{ type: "text", value: " #" }],
},
},
],
],
},
integrations: [react(), mdx(), pagefind()],
redirects: {
"/docs": "/docs/get-started/introduction",
"/docs/get-started": "/docs/get-started/introduction",
"/docs/developing-plugins": "/docs/developing-plugins/introduction",
"/docs/plugin-apis": "/docs/plugin-apis/webview",
"/docs/reference": "/docs/reference/models",
},
});

View File

@ -1,39 +1,74 @@
{
"name": "website",
"version": "1.0.0",
"private": true,
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.3.3",
"@astrojs/react": "^4.3.0",
"@octokit/rest": "^22.0.0",
"@skeletonlabs/skeleton-react": "^1.2.4",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"astro": "^5.12.8",
"astro-pagefind": "^1.8.3",
"date-fns": "^4.1.0",
"markdown-it": "^14.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"sanitize-html": "^2.17.0",
"shiki": "^3.9.2",
"tailwindcss": "^4.1.11",
"usehooks-ts": "^3.1.1"
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "playwright test",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@skeletonlabs/skeleton": "^3.1.7",
"@tailwindcss/typography": "^0.5.16",
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.0"
"@playwright/test": "^1.41.2",
"@skeletonlabs/skeleton": "2.8.0",
"@skeletonlabs/tw-plugin": "0.3.1",
"@sveltejs/adapter-cloudflare": "^4.1.0",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@tailwindcss/typography": "0.5.10",
"@types/eslint": "8.56.0",
"@types/node": "^20.11.16",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"autoprefixer": "10.4.17",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"mdsvex": "^0.11.0",
"postcss": "8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.10",
"svelte-check": "^3.6.3",
"tailwindcss": "3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.0",
"vite-plugin-tailwind-purgecss": "0.2.0"
},
"dependencies": {
"@floating-ui/dom": "1.6.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@octokit/openapi-types": "^22.2.0",
"@octokit/rest": "^21.0.2",
"date-fns": "^3.3.1",
"highlight.js": "11.9.0",
"lucide-svelte": "^0.323.0",
"mdsvex-relative-images": "^1.0.3",
"rehype-auto-ads": "^1.2.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"remark-container": "^0.1.2",
"remark-external-links": "^9.0.1",
"remark-gfm": "^4.0.0",
"remark-github": "^12.0.0",
"remark-reading-time": "^1.0.1",
"svelte-fa": "^4.0.2",
"svelte-markdown": "^0.4.1"
},
"packageManager": "pnpm@10.4.0+sha512.6b849d0787d97f8f4e1f03a9b8ff8f038e79e153d6f11ae539ae7c435ff9e796df6a862c991502695c7f9e8fac8aeafc1ac5a8dab47e36148d183832d886dd52",
"pnpm": {
"onlyBuiltDependencies": [
"@fortawesome/fontawesome-common-types",
"@fortawesome/free-brands-svg-icons",
"@sveltejs/kit",
"esbuild",
"svelte-preprocess"
]
}
}

View File

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild
- sharp

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,36 @@
---
title: From Idea to Impact
author: Prottoy Roy
date: 2024-12-22
published: true
cover_img: /images/from-idea-to-impact/cover.jpg
---
> An school magazine article by the beloved brother of the founder of the Spotube app
In the vibrant city of Narayanganj, Dhaka, Bangladesh, a young man named Kingkor Roy Tirtho was carving out his path in the world of technology. Currently a second-year Computer Science and Engineering (CSE) student at East West University, Kingkor had always been captivated by the magic of coding. From a young age, he spent countless hours tinkering with computers, teaching himself programming languages and exploring the digital realm.
Kingkor's passion wasn't just about writing code; it was about solving problems and creating innovative solutions. Inspired by the way technology could enhance everyday life, he dreamed of building apps that would bring joy and convenience to users. His dedication was evident; he often participated in hackathons and coding competitions, where he showcased his talent and creativity.
The turning point in his journey came when he envisioned an app that would revolutionize music streaming. With millions of people seeking accessible music, he wanted to create a platform that could bridge gaps and provide a seamless experience. Thus, Spotube was born.
Initially, Kingkor faced numerous challenges. Balancing his academic responsibilities with app development was no easy feat. There were nights filled with coding, debugging, and sleepless hours fueled by caffeine and determination. Despite setbacks and moments of self-doubt, Kingkor remained resilient. He sought feedback, learned from criticisms, and continually iterated on his project.
As Spotube gained traction, it garnered attention for its user-friendly interface and innovative features. Kingkors ability to blend technical skills with an understanding of user needs made the app a hit among music lovers. He received positive reviews, not just for the functionality, but for the passion evident in his work.
Kingkors story is one of perseverance and innovation. He embodies the spirit of a new generation of tech enthusiasts who believe that with dedication, anything is possible. His journey serves as an inspiration to his peers at East West University and beyond, reminding them that the intersection of creativity and technology can lead to remarkable achievements.
Today, Kingkor continues to evolve as a developer, always looking for ways to improve Spotube and explore new ideas. His story illustrates that genius isn't just about raw talent; it's about hard work, resilience, and the willingness to dream big. Kingkor Roy Tirtho is a shining example of what can be achieved when passion meets perseverance, and he is just getting international attentions.
Here is some key features of Spotube:
1. **Seamless Music Streaming**: Spotube offers a smooth streaming experience with a vast library of tracks, allowing users to easily find and play their favorite songs.
1. **Offline Listening**: Users can download their favorite tracks for offline playback, making it convenient to enjoy music anytime, anywhere, without relying on an internet connection.
1. **User-Friendly Interface**: The app is designed with an intuitive interface, making navigation easy for users of all ages. Its clean layout ensures a pleasant user experience.
1. **Cross-Platform Compatibility**: Spotube is accessible on multiple devices, enabling users to enjoy their music on smartphones, tablets, and desktops seamlessly.
1. **Personalized Playlists**: Users can create and manage their playlists, helping them curate their listening experience based on their mood and preferences.
1. **Social Sharing Features**: The app allows users to share their favorite tracks and playlists with friends and family, fostering a community of music lovers.
1. **Regular Updates**: Spotube is continually updated with new features and improvements based on user feedback, reflecting Kingkor's commitment to enhancing the app's performance and user satisfaction.
1. **Global Reach**: With its growing popularity, Spotube is gaining attention worldwide, attracting users from various countries and cultures, showcasing Kingkors vision of accessible music for everyone. He's recently got mentioned in a Spanish well known magazine for his invention.
As Spotube continues to evolve, Kingkor Roy Tirtho's innovative approach is positioning him and his app as significant players in the music streaming landscape, capturing the attention of users and industry experts alike.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

26
website/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface PageData {}
// interface Error {}
// interface Platform {}
interface Platform {
env: {
COUNTER: DurableObjectNamespace;
};
context: {
waitUntil(promise: Promise<any>): void;
};
caches: CacheStorage & { default: Cache };
}
}
declare namespace globalThis {
declare var adsbygoogle: any[];
}
declare module "@fortawesome/pro-solid-svg-icons/index.es" {
export * from "@fortawesome/pro-solid-svg-icons";
}

26
website/src/app.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<!-- favicon 32x32 -->
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<!-- favicon 16x16 -->
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<meta name="viewport" content="width=device-width" />
<!-- Apple icons -->
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<!-- Android Chrome -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-6419300932495863"
data-overlays="bottom" crossorigin="anonymous"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="wintry">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

23
website/src/app.postcss Normal file
View File

@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
/* vintage theme */
@font-face {
font-family: 'Abril Fatface';
src: url('/fonts/AbrilFatface.ttf');
font-display: swap;
}
.text-stroke {
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000,
-1px 0 0 #000,
1px 0 0 #000,
0 -1px 0 #000,
0 1px 0 #000;
}

View File

@ -1,38 +0,0 @@
---
interface Props {
adSlot: number;
adFormat: "auto" | "fluid";
fullWidthResponsive?: boolean;
style?: string;
adLayout?: "in-article" | "in-feed" | "in-page";
adLayoutKey?: string;
}
const {
adSlot,
adFormat,
fullWidthResponsive = true,
style,
adLayout,
adLayoutKey,
} = Astro.props;
const AD_CLIENT = "ca-pub-6419300932495863";
---
<ins
class="adsbygoogle"
{style}
data-ad-layout={adLayout}
data-ad-client={AD_CLIENT}
data-ad-slot={adSlot}
data-ad-format={adFormat}
data-full-width-responsive={fullWidthResponsive}
data-ad-layout-key={adLayoutKey}></ins>
<script is:inline type="text/javascript">
// When the DOM is ready, push the ad request to the adsbygoogle array
document.addEventListener("DOMContentLoaded", function () {
(window.adsbygoogle = window.adsbygoogle || []).push({});
});
</script>

View File

@ -1,43 +0,0 @@
---
import { LuMenu } from "react-icons/lu";
---
<button
id="button-toggle"
class="btn btn-icon"
class:list={[Astro.props.class]}
>
{(<LuMenu />)}
</button>
<div
id="drawer"
class="fixed bg-white dark:bg-surface-800 shadow-lg transition-all duration-300 left-0 top-0 h-screen w-64 -translate-x-[100vw] z-[100]"
>
<button
id="button-close"
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700"
aria-label="Close"
>
&times;
</button>
<div class="p-4">
<slot />
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
const buttonToggle = document.getElementById("button-toggle");
const drawer = document.getElementById("drawer");
const buttonClose = document.getElementById("button-close");
buttonToggle?.addEventListener("click", () => {
drawer?.classList.toggle("-translate-x-[100vw]");
});
buttonClose?.addEventListener("click", () => {
drawer?.classList.add("-translate-x-[100vw]");
});
});
</script>

View File

@ -1,53 +0,0 @@
---
import { getNavigationCollection } from "~/utils/get-collection";
interface Props {
classList?: string[];
}
const { classList } = Astro.props;
const navigation = await getNavigationCollection();
const pathname = Astro.url.pathname.endsWith("/")
? Astro.url.pathname.slice(0, -1)
: Astro.url.pathname;
---
<aside class="text-sm grid gap-10" class:list={[classList]}>
{
navigation.map((group) => (
<nav class="flex flex-col gap-2">
<span class="text-sm font-bold ml-2">{group.title}</span>
<ul class="flex flex-col gap-1">
{group.items.map((item) => (
<li>
<a
href={item.href}
title={item.title}
class="flex justify-between items-center"
>
<span
class="grow px-2 py-1 rounded-base"
class:list={[
{
"preset-tonal": pathname === item.href,
anchor: pathname !== item.href,
},
]}
>
{item.title}
</span>
{item.tag && (
<span class="no-underline preset-tonal-primary text-xs px-1 capitalize rounded">
{item.tag}
</span>
)}
</a>
</li>
))}
</ul>
</nav>
))
}
</aside>

View File

@ -1,72 +0,0 @@
---
import { routes } from "~/collections/app";
import { FaGithub } from "react-icons/fa6";
import SidebarButton from "./sidebar-button";
import Search from "astro-pagefind/components/Search";
const pathname = Astro.url.pathname;
---
<header
class="flex justify-between items-center py-2 md:p-4 top-0 fixed w-full z-10 backdrop-blur-md"
>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
{
pathname.startsWith("/docs") ? (
<div class="h-10 w-10 md:hidden" />
) : (
<SidebarButton client:only />
)
}
<h2 class="text-xl md:text-3xl">
<a href="/" class="flex gap-2 items-center">
<img src="/images/spotube-logo.png" width="40px" alt="Spotube Logo" />
Spotube
</a>
</h2>
</div>
<Search
id="search"
className="pagefind-ui"
uiOptions={{
showImages: false,
showSubResults: true,
}}
/>
<a
class="mw-2 me-4"
href="https://github.com/KRTirtho/spotube?referrer=spotube.krtirtho.dev"
target="_blank"
>
<button class="btn preset-filled flex items-center gap-2">
<FaGithub />
Star us
</button>
</a>
</div>
<nav class="hidden md:flex gap-3 items-center pr-2">
{
Object.entries(routes).map((route) => {
const Icon = route[1][1];
const isActive =
route[0] === "/" ? pathname === "/" : pathname.startsWith(route[0]);
return (
<a href={route[0]}>
<button
type="button"
class={`btn flex gap-2 ${route[0] === "/downloads" ? "preset-filled-primary-300-700" : "preset-filled-secondary-100-900"} ${isActive ? "underline" : ""}`}
>
{Icon && <Icon />}
{route[1][0]}
</button>
</a>
);
})
}
</nav>
</header>

View File

@ -1,45 +0,0 @@
import { useRef, useState } from "react";
import { LuMenu } from "react-icons/lu";
import { useOnClickOutside } from "usehooks-ts";
import { routes } from "~/collections/app.ts";
export default function SidebarButton() {
const ref = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false);
useOnClickOutside(ref as React.RefObject<HTMLDivElement>, () => {
setIsOpen(false);
})
return <>
<div className={
`fixed h-screen w-72 bg-primary-50-950 top-0 left-0 bg-surface z-50 transition-all duration-300 ${isOpen ? "" : "-translate-x-full opacity-0"}`
}
ref={ref}
>
{
Object.entries(routes).map((route) => {
const Icon = route[1][1];
return (
<a
key={route[0]}
href={route[0]}
className="flex items-center gap-2 p-4 hover:bg-surface/80 transition-colors duration-200"
>
{Icon && <Icon />}
<span className="text-lg">{route[1][0]}</span>
</a>
)
})
}
</div>
<button
className="p-2 md:hidden"
onClick={() => {
setIsOpen(!isOpen);
}}
>
<LuMenu />
</button>
</>;
}

View File

@ -1,18 +0,0 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const docs = defineCollection({
schema: z.object({
title: z.string().optional().default('(Title)'),
description: z.string().optional().default('(Description)'),
pubDate: z.date().optional(),
tags: z.array(z.string()).optional(),
order: z.number().optional().default(0)
}),
loader: glob({
base: './src/content/docs',
pattern: ['**/*.mdx', '!**/_*.mdx']
}),
});
export const collections = { docs };

View File

@ -1,83 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Create your first plugin
description: ""
order: 1
---
If you are comfortable with Dart, Flutter and Hetu Script, you can start developing your first plugin.
This guide will help you initialize a plugin project and write your first plugin.
## Initializing a plugin project
[spotube-plugin-template][spotube-plugin-template] is a template repository for Spotube plugins. It's a starting point
with everything you need to get started with plugin development. You should use it to create your own plugin.
Simply clone or click "Use this template" button on the GitHub repository page to create a new repository.
```bash
$ git clone https://github.com/KRTirtho/spotube-plugin-template.git
$ cd spotube-plugin-template
```
## Understanding plugins.json
After cloning the repository, you will find a file named `plugins.json` in the root directory.
This file is crucial for Spotube to recognize your plugin. It looks like this:
```json
{
"type": "metadata",
"version": "1.0.0",
"name": "Alphanumeric plugin name with hyphens or underscore",
"author": "Your Name",
"description": "A brief description of the plugin's functionality.",
"entryPoint": "plugin class name",
"apis": ["webview", "localstorage", "timezone"],
"abilities": ["authentication", "scrobbling"],
"repository": "https://github.com/KRTirtho/spotube-plugin-template",
"pluginApiVersion": "1.0.0"
}
```
| Property | Description |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | The type of the plugin, which is always `metadata` for Spotube plugins. |
| `version` | The version of the plugin, following [semantic versioning][semantic-version] (e.g., `1.0.0`). |
| `name` | The name of the plugin |
| `author` | The name of the plugin author. |
| `description` | A brief description of the plugin's functionality. |
| `entryPoint` | The name of the class that serves as the entry point for the plugin. |
| `apis` | An array of APIs that the plugin uses. This is used to determine which APIs are available to the plugin. Following APIs are available "webview", "localstorage", "timezone" |
| `abilities` | An array of abilities that the plugin has. This is used to determine which abilities the plugin has. Following abilities can be listed: "authentication", "scrobbling" |
| `repository` | The URL of the plugin's repository. This is used to display the plugin's repository in the plugin manager. |
| `pluginApiVersion` | The version of the plugin API that the plugin uses. This is used to determine if the plugin is compatible with the current version of Spotube. |
Change the values in the `plugins.json` file to match your plugin's information.
## Running the `example` app
There's an `example` folder that contains a simple Flutter app that utilizes all the methods
Spotube would call on your plugin. You can run this app to test your plugin's functionality.
But first you need too compile the plugin to bytecode. You can simply do this using:
```shell
$ make
```
Make sure you've `make` command installed on your system and also must have the [hetu_script_dev_tools][hetu_script_dev_tools] package globally installed.
After compiling the plugin, you can run the example app like any other Flutter app.
```shell
$ cd example
$ flutter run
```
> Most of the buttons, will not work as they not yet implemented. You've to implement the methods in your plugin source code.
> We will cover how to implement the methods in the next section.
{/* Links */}
[spotube-plugin-template]: https://github.com/KRTirtho/spotube-plugin-template
[semantic-version]: https://semver.org/
[hetu_script_dev_tools]: https://pub.dev/packages/hetu_script_dev_tools

View File

@ -1,493 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Implementing Endpoints
description: ""
order: 2
---
## AuthEndpoint
> If your plugin doesn't need authentication support, you can skip this section.
In the `src/segments/auth.ht` file you can find all the required method definition. These are the necessary
methods Spotube calls in it's lifecycle.
```hetu_script
class AuthEndpoint {
var client: HttpClient
final controller: StreamController
get authStateStream -> Stream => controller.stream
construct (this.client){
controller = StreamController.broadcast()
}
fun isAuthenticated() -> bool {
// TODO: Implement method
return false
}
fun authenticate() -> Future {
// TODO: Implement method
}
fun logout() -> Future {
// TODO: Implement method
}
}
```
For this specific endpoint, you may need `WebView` or `Forms` to get user inputs. The [`hetu_spotube_plugin`][hetu_spotube_plugin] provides
such APIs.
> Learn more about it in the [Spotube Plugin API][spotube_plugin_api] section
### The `.authStateStream` property
The `AuthEndpoint.authStateStream` property is also necessary to notify Spotube about the authentication status. [`hetu_std`][hetu_std] is a built-in
module and it exports `StreamController` which basically 1:1 copy of the Dart's [StreamController][dart_stream_controller].
If the status of authentication changes you need to add a new event using the `controller.add`
Following events are respected by Spotube:
| Name | Description |
| ----------- | ------------------------------------------------------------ |
| `login` | When user successfully completes login |
| `logout` | When user logs out of the service |
| `recovered` | When user's cached/saved credentials are recovered from disk |
| `refreshed` | When user's session is refreshed |
Example of adding a new authentication event:
```hetu_script
controller.add({ type: "login" }.toJson())
```
By the way, the event type is a `Map<String, dynamic>` in the Dart side, so make sure to always convert hetu_script's [structs into Maps][hetu_struct_into_map]
## UserEndpoint
The UserEndpoint is used to fetch user information and manage user-related actions.
In the `src/segments/user.ht` file you can find all the required method definitions. These are the necessary
methods Spotube calls in its lifecycle.
> Most of these methods should be just a mapping to an API call with minimum latency. Avoid calling plugin APIs like WebView or Forms
> in these methods. User interactions should be avoided here generally.
```hetu_script
class UserEndpoint {
var client: HttpClient
construct (this.client)
fun me() {
// TODO: Implement method
}
fun savedTracks({ offset: int, limit: int }) {
// TODO: Implement method
}
fun savedPlaylists({ offset: int, limit: int }) {
// TODO: Implement method
}
fun savedAlbums({ offset: int, limit: int }) {
// TODO: Implement method
}
fun savedArtists({ offset: int, limit: int }) {
// TODO: Implement method
}
fun isSavedPlaylist(playlistId: string) { // Future<bool>
// TODO: Implement method
}
fun isSavedTracks(trackIds: List) { // Future<List<bool>>
// TODO: Implement method
}
fun isSavedAlbums(albumIds: List) { // Future<List<bool>>
// TODO: Implement method
}
fun isSavedArtists(artistIds: List) { // Future<List<bool>>
// TODO: Implement method
}
}
```
These methods are pretty self-explanatory. You need to implement them to fetch user information from your service.
| Method | Description | Returns |
| ------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `me()` | Fetches the current user's information. | [`SpotubeUserObject`][SpotubeUserObject] |
| `savedTracks()` | Fetches the user's saved tracks with pagination support. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
| `savedPlaylists()` | Fetches the user's saved playlists with pagination support. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject] |
| `savedAlbums()` | Fetches the user's saved albums with pagination support. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject] |
| `savedArtists()` | Fetches the user's saved artists with pagination support. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullArtistObject`][SpotubeFullArtistObject] |
| `isSavedPlaylist()` | Checks if a playlist is saved by the user. Returns a `Future<bool>`. | `bool` |
| `isSavedTracks()` | Checks if tracks are saved by the user. Returns a `Future<List<bool>>`. | `List<bool>` (each boolean corresponds to a track ID) |
| `isSavedAlbums()` | Checks if albums are saved by the user. Returns a `Future<List<bool>>`. | `List<bool>` (each boolean corresponds to an album ID) |
| `isSavedArtists()` | Checks if artists are saved by the user. Returns a `Future<List<bool>>`. | `List<bool>` (each boolean corresponds to an artist ID) |
> Note: The `isSavedTracks`, `isSavedAlbums`, and `isSavedArtists` methods accept a list of IDs and return a list of booleans
> indicating whether each item is saved by the user. The order of the booleans in the list corresponds to the order of the IDs
> in the input list.
## TrackEndpoint
The TrackEndpoint is used to fetch track information and do track-related actions. In the `src/segments/track.ht` file you can find all the
required method definitions.
```hetu_script
class TrackEndpoint {
var client: HttpClient
construct (this.client)
fun getTrack(id: string) {
// TODO: Implement method
}
fun save(trackIds: List) { // List<String>
// TODO: Implement method
}
fun unsave(trackIds: List) { // List<String>
// TODO: Implement method
}
fun radio(id: string) {
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| `getTrack()` | Fetches track information by ID. | [SpotubeFullTrackObject][SpotubeFullTrackObject] |
| `save()` | Saves the specified tracks. Accepts a list of track IDs. | void |
| `unsave()` | Removes the specified tracks from saved tracks. Accepts a list of track IDs. | void |
| `radio()` | Fetches related tracks based on specified tracks. Try to return a List of 50 tracks. | [List\<SpotubeFullTrackObject\>][SpotubeFullTrackObject] |
{/* Urls */}
## AlbumEndpoint
The AlbumEndpoint is used to fetch album information and do album-related actions. In the `src/segments/album.ht` file you can find all the
required method definitions.
```hetu_script
class AlbumEndpoint {
construct (this.client)
fun getAlbum(id: string) {
// TODO: Implement method
}
fun tracks(id: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun releases({offset: int, limit: int}) {
// TODO: Implement method
}
fun save(albumIds: List) { // List<String>
// TODO: Implement method
}
fun unsave(albumIds: List) { // List<String>
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ------------ | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `getAlbum()` | Fetches album information by ID. | [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject] |
| `tracks()` | Fetches tracks of the specified album. Accepts an ID and optional pagination parameters. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
| `releases()` | Fetches new album releases user followed artists or globally | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject] |
| `save()` | Saves the specified albums. Accepts a list of album IDs. | `void` |
| `unsave()` | Removes the specified albums from saved albums. Accepts a list of album IDs. | `void` |
## ArtistEndpoint
The ArtistEndpoint is used to fetch artist information and do artist-related actions. In the `src/segments/artist.ht` file you can find all the
required method definitions.
```hetu_script
class ArtistEndpoint {
var client: HttpClient
construct (this.client)
fun getArtist(id: string) {
// TODO: Implement method
}
fun related(id: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun topTracks(id: string, {limit: int, offset: int}) {
// TODO: Implement method
}
fun albums(id: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun save(artistIds: List) {
// TODO: Implement method
}
fun unsave(artistIds: List) {
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `getArtist()` | Fetches artist information by ID. | [`SpotubeFullArtistObject`][SpotubeFullArtistObject] |
| `related()` | Fetches related artists based on the specified artist ID. Accepts optional pagination. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullArtistObject`][SpotubeFullArtistObject] |
| `topTracks()` | Fetches top tracks of the specified artist. Accepts optional pagination. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
| `albums()` | Fetches albums of the specified artist. Accepts optional pagination. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject] |
| `save()` | Saves the specified artists. Accepts a list of artist IDs. | `void` |
| `unsave()` | Removes the specified artists from saved artists. Accepts a list of artist IDs. | `void` |
## PlaylistEndpoint
The PlaylistEndpoint is used to fetch playlist information and do track-related actions. In the `src/segments/playlist.ht` file you can find all the
required method definitions.
```hetu_script
class PlaylistEndpoint {
var client: HttpClient
construct (this.client)
fun getPlaylist(id: string) {
// TODO: Implement method
}
fun tracks(id: string, { offset: int, limit: int }) {
// TODO: Implement method
}
fun create(userId: string, {
name: string,
description: string,
public: bool,
collaborative: bool
}) {
// TODO: Implement method
}
fun update(playlistId: string, {
name: string,
description: string,
public: bool,
collaborative: bool
}) {
// TODO: Implement method
}
fun deletePlaylist(playlistId: string) {
// TODO: Implement method
}
fun addTracks(playlistId: string, { trackIds: List, position: int }) {
// TODO: Implement method
}
fun removeTracks(playlistId: string, { trackIds: List }) {
// TODO: Implement method
}
fun save(playlistId: string) {
// TODO: Implement method
}
fun unsave(playlistId: string) {
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ---------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `getPlaylist` | Fetches a playlist by its ID. | [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject] |
| `tracks` | Fetches tracks in a playlist. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
| `create` | Creates a new playlist and returns | [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject] |
| `update` | Updates an existing playlist. | `void` |
| `deletePlaylist` | Deletes a playlist. | `void` |
| `addTracks` | Adds tracks to a playlist. | `void` |
| `removeTracks` | Removes tracks from a playlist. | `void` |
| `save` | Saves a playlist to the user's library. | `void` |
| `unsave` | Removes a playlist from the user's library. | `void` |
## SearchEndpoint
The SearchEndpoint is used to fetch search playlist, tracks, album and artists. In the `src/segments/search.ht` file you can find all the
required method definitions.
```hetu_script
class SearchEndpoint {
var client: HttpClient
construct (this.client)
get chips -> List { // Set<string>
// can be tracks, playlists, artists, albums and all
return ["all", "tracks", "albums", "artists", "playlists"]
}
fun all(query: string) {
// TODO: Implement method
}
fun albums(query: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun artists(query: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun tracks(query: string, {offset: int, limit: int}) {
// TODO: Implement method
}
fun playlists(query: string, {offset: int, limit: int}) {
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `chips` | Returns the available search chips. | `List<string>` |
| `all()` | Searches for all types of content. | [`SpotubeSearchResponseObject`][SpotubeSearchResponseObject] |
| `albums()` | Searches only for albums. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject] |
| `artists()` | Searches only for artists. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullArtistObject`][SpotubeFullArtistObject] |
| `tracks()` | Searches only for tracks. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
| `playlists()` | Searches only for playlists. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject] |
## BrowseEndpoint
The BrowseEndpoint is used to fetch recommendations and catalogs of playlists, albums and artists. In the `src/segments/browse.ht` file you can find all the
required method definitions.
```hetu_script
class BrowseEndpoint {
var client: HttpClient
construct (this.client)
fun sections({offset: int, limit: int}) {
// TODO: Implement method
}
fun sectionItems(id: string, {offset: int, limit: int}) {
// TODO: Implement method
}
}
```
| Method | Description | Returns |
| ---------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `sections()` | Returns the sections of the home page. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of [`SpotubeBrowseSectionObject`][SpotubeBrowseSectionObject] of `Object` |
| `sectionItems()` | Returns the items of a specific section. | [`SpotubePaginationResponseObject`][SpotubePaginationResponseObject] of `Object` |
> In `sectionItems()` The `id` it takes comes from `sections()`. It is basically used in an expanded screen to show the browse section items with pagination.
>
> For sections returned by `sections()` if `browseMore` is `true` that's when `sectionItems()` is used to fetch the items of that section.
By the way, the `Object` can be any of the following types:
- [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject]
- [`SpotubeFullArtistObject`][SpotubeFullArtistObject]
- [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject]
## CoreEndpoint
The CoreEndpoint is a special subclass which is used to check update and scrobbling and to get support text. In the `src/segments/core.ht` file you can find all the
required method definitions.
```hetu_script
class CorePlugin {
var client: HttpClient
construct (this.client)
/// Checks for updates to the plugin.
/// [currentConfig] is just plugin.json's file content.
///
/// If there's an update available, it will return a map of:
/// - [downloadUrl] -> direct download url to the new plugin.smplug file.
/// - [version] of the new plugin.
/// - [changelog] Optionally, a changelog for the update (markdown supported).
///
/// If no update is available, it will return null.
fun checkUpdate(currentConfig: Map) -> Future {
// TODO: Check for updates
}
/// Returns the support information for the plugin in Markdown or plain text.
/// Supports images and links.
get support -> string {
// TODO: Return support information
return ""
}
/// Scrobble the provided details to the scrobbling service supported by the plugin.
/// "scrobbling" must be set as an ability in the plugin.json
/// [details] is a map containing the scrobble information, such as:
/// - [id] -> The unique identifier of the track.
/// - [title] -> The title of the track.
/// - [artists] -> List of artists
/// - [id] -> The unique identifier of the artist.
/// - [name] -> The name of the artist.
/// - [album] -> The album of the track
/// - [id] -> The unique identifier of the album.
/// - [name] -> The name of the album.
/// - [timestamp] -> The timestamp of the scrobble (optional).
/// - [duration_ms] -> The duration of the track in milliseconds (optional).
/// - [isrc] -> The ISRC code of the track (optional).
fun scrobble(details: Map) {
// TODO: Implement scrobbling
}
}
```
| Method | Description | Returns |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `checkUpdate()` | Checks for updates to the plugin. | `Future` with a map containing `downloadUrl`, `version`, and optionally `changelog`. If no update is available, returns `null`. |
| `support` | Returns support information. | `string` containing the support information in Markdown or plain text. |
| `scrobble()` | [Scrobbles][scrobbling_wiki] the provided track details. This is only called if your plugin.json has scrobbling in the `abilities` field | `void` |
> In the `checkUpdate()` method the `plugin.json`'s content will be passed as map. You can use that to check updates using the `version` field.
>
> Also, the `downloadUrl` it provides should be a direct binary download link (redirect is supported) for the `.smplug` file
{/* Urls */}
[scrobbling_wiki]: https://en.wikipedia.org/wiki/Last.fm
[hetu_script_import_export_docs]: https://hetu-script.github.io/docs/en-US/grammar/import/
[hetu_spotube_plugin]: https://github.com/KRTirtho/hetu_spotube_plugin
[hetu_std]: https://github.com/hetu-community/hetu_std
[dart_stream_controller]: https://api.flutter.dev/flutter/dart-async/StreamController-class.html
[hetu_struct_into_map]: https://hetu-script.github.io/docs/en-US/api_reference/hetu/#struct
[spotube_plugin_api]: /docs/plugin-apis
[SpotubeUserObject]: /docs/reference/models#user
[SpotubePaginationResponseObject]: /docs/reference/models#pagination-response
[SpotubeFullAlbumObject]: /docs/reference/models#spotubefullalbumobject
[SpotubeFullArtistObject]: /docs/reference/models#spotubefullartistobject
[SpotubeFullTrackObject]: /docs/reference/models#track
[SpotubeFullPlaylistObject]: /docs/reference/models#spotubefullplaylistobject
[SpotubeSearchResponseObject]: /docs/reference/models#search-response
[SpotubeBrowseSectionObject]: /docs/reference/models#browse-section

View File

@ -1,95 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Implementing plugin methods
description: Tutorial on how to implement methods in your Spotube plugin.
order: 2
---
In the previous section, you learned how to create a plugin project and run the example app.
In this section, you will learn how to implement methods in your Spotube plugin.
## The `entryPoint` class
The `entryPoint` (from the plugin.json) class is the main class of your plugin. You can find it in `src/plugin.ht`. It's the class
that Spotube will instantiate when it loads your plugin. You can pretty much keep this class same as the template, unless you
there's something specific you want to change.
```hetu_script
// The name of the class should match the `entryPoint` in plugin.json
class TemplateMetadataProviderPlugin {
// These are required properties that Spotube will use to call the methods.
// ==== Start of required properties ====
var auth: AuthEndpoint
var album: AlbumEndpoint
var artist: ArtistEndpoint
var browse: BrowseEndpoint
var playlist: PlaylistEndpoint
var search: SearchEndpoint
var track: TrackEndpoint
var user: UserEndpoint
var core: CorePlugin
// ==== End of required properties ====
var api: HttpClient
construct (){
api = HttpClient(
HttpBaseOptions(
baseUrl: "https://example.com"
)
)
auth = AuthEndpoint(api)
album = AlbumEndpoint(api)
artist = ArtistEndpoint(api)
browse = BrowseEndpoint(api)
playlist = PlaylistEndpoint(api)
search = SearchEndpoint(api)
track = TrackEndpoint(api)
user = UserEndpoint(api)
core = CorePlugin(api)
auth.authStateStream.listen((event) {
// get authentication events
})
}
}
```
If you read how the import/export works for [hetu_script][hetu_script_import_export_docs], you should realize it's pretty similar to ECMA Script modules or ES6+ Modules
from the JavaScript world.
```hetu_script
import { AuthEndpoint } from './segments/auth.ht'
import { AlbumEndpoint } from "./segments/album.ht"
import { ArtistEndpoint } from "./segments/artist.ht"
import { BrowseEndpoint } from "./segments/browse.ht"
import { PlaylistEndpoint } from './segments/playlist.ht'
import { SearchEndpoint } from './segments/search.ht'
import { TrackEndpoint } from './segments/track.ht'
import { UserEndpoint } from './segments/user.ht'
import { CorePlugin } from './segments/core.ht'
```
## Implementing subclasses
Now that we've seen `entryPoint` class, we can look into the properties of that classes which are the actual
classes that contains methods that Spotube calls. All of them are in `src/segments` folder
> **IMPORTANT!:** hetu\*script claims it supports async/await. But unfortunately it still doesn't work yet.
> So for now, we have to bear with .then()
>
> Also, if you've read the hetu_script docs, you should know hetu_script doesn't support <ins>Error Handling</ins>.
> This is a design decision of the language and the errors should only be handled in the Dart code.
> So there's no try/catch/finally or .catch() method
In the next section, we will cover how to implement the methods in these classes.
{/* Urls */}
[hetu_script_import_export_docs]: https://hetu-script.github.io/docs/en-US/grammar/import/
[hetu_spotube_plugin]: https://github.com/KRTirtho/hetu_spotube_plugin
[spotube_plugin_api]: /
[hetu_std]: https://github.com/hetu-community/hetu_std
[dart_stream_controller]: https://api.flutter.dev/flutter/dart-async/StreamController-class.html
[hetu_struct_into_map]: https://hetu-script.github.io/docs/en-US/api_reference/hetu/#struct

View File

@ -1,60 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Introduction
description: Learn how to develop plugins for Spotube
order: 0
---
Plugins in Spotube are used for fetching metadata (Playlist, Album, Artist, Track Info, Search etc) and scrobbling. It gives developers
access to Spotube's internal APIs to create custom metadata providers and audio [scrobblers][scrobbler_wiki].
Plugins needs to be written in [hetu_script][hetu_script_link], which is a Dart-based scripting language.
You probably never heard of it before.
## Requirements
To develop plugins for Spotube, you need to have the following requirements:
- Basic programming knowledge
- [Dart][dart] and [Flutter][flutter] knowledge
- [Visual Studio Code][vscode] or any other code editor
Spotube uses [hetu_script][hetu_script_link]. It's kind of similar to Typescript.
Learning it shouldn't take much time if you already know Dart or Javascript.
Go to Hetu Script's [official website and documentation][hetu_script_link] to learn more about it.
## Resources
The [`hetu_script`][hetu_script_link] programming/scripting language is relatively new. So there's no ecosystem around it yet.
However, we created some helpful libraries to aid with Spotube plugin development. The [hetu-community][hetu_community] is a
community driven effort to create libraries and tools for Hetu Script. Below are available libraries:
#### Core Libraries
- [**hetu-community/hetu_std**][hetu_std]: A standard library for Hetu Script. Provides basic functionality like Http client, DateTime, Cryptography API,
encoding/decoding (JSON, Utf8, Base32) etc.
- [**KRTirtho/hetu_spotube_plugin**][hetu_spotube_plugin]: A library for Spotube plugin development. It provides access to Spotube's internal APIs
(Webview, Forms, LocalStorage etc.) and utilities for fetching metadata and scrobbling.
> You can find more libraries in the [hetu-community GitHub organization][hetu_community].
#### Programming aid
- [Hetu Script Plugin for VSCode][hetu_script_vscode]: A VSCode extension for Hetu Script. It provides basic syntax highlighting
support. But it doesn't support [LSP (Language Server Protocol)][lsp] yet so no autocompletion or linting is available.
- [hetu_script_dev_tools][hetu_script_dev_tools]: A CLI tool for compiling hetu script files to bytecode or directly running them and a REPL
{/* Link Variables */}
[hetu_script_link]: https://hetu-script.github.io/
[scrobbler_wiki]: https://en.wikipedia.org/wiki/Scrobbling
[dart]: https://dart.dev/
[flutter]: https://flutter.dev/
[vscode]: https://code.visualstudio.com/
[lsp]: https://en.wikipedia.org/wiki/Language_Server_Protocol
[hetu_script_vscode]: https://marketplace.visualstudio.com/items?itemName=hetu-script.hetuscript
[hetu_community]: https://github.com/hetu-community
[hetu_std]: https://github.com/hetu-community/hetu_std
[hetu_otp_util]: https://github.com/hetu-community/hetu_otp_util
[hetu_spotube_plugin]: https://github.com/KRTirtho/hetu_spotube_plugin
[hetu_script_dev_tools]: https://pub.dev/packages/hetu_script_dev_tools

View File

@ -1,30 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Installing plugins
description: Learn how to install and manage plugins in Spotube
order: 1
---
Let's first learn how to install plugins in Spotube. It's pretty simple.
1. Open Spotube (duh!)
1. Go to Settings
1. Then go to the top option, "Metadata provider plugins"
1. You can see a list of all the plugins that are available to install
![Navigate to plugins page](/docs/getting-started/installing-plugins/navigate.webp)
## More ways to install new plugins
Usually, Spotube will list public repositories of plugins from github and codeberg in the _Available plugins_ section.
This is a non curated list, so be careful when installing plugins. Always check the source before installing.
A malicious plugin given full access can easily steal your credentials. So be careful!
Try to use the `Official` tagged plugins all the time if you don't want to deal with potential security risks.
- **Upload plugin from local file**: You can also install plugins from local file (plugin.smplug) using the _Orange Upload button_ on the top right beside the text field.
- **Install plugin from URL**: If you have a direct link to a plugin file, you can just paste the URL in the text field and use the gray download button beside it
> If you're a developer, you can create your own plugins and share them with the community. Check out the [Plugin Development Guide][developing_plugins] for more information.
[developing_plugins]: /docs/developing-plugins/introduction

View File

@ -1,20 +0,0 @@
---
layout: 'layouts/DocLayout.astro'
title: Introduction
description: ""
order: 0
---
Spotube is an extensible music player designed to give users full control over their listening experience. With a flexible configuration system, Spotube can be tailored to fit individual preferences and workflows.
## Key Features
- **Extensible Architecture:** Spotube supports a powerful plugin system, allowing users to integrate with any music metadata service or extend functionality as needed.
- **Multiple Integrations:** Out of the box, Spotube connects with various music services, making it easy to access and manage your library.
- **Customizable Experience:** Users can configure Spotube to match their unique requirements, from interface themes to playback options.
## Why Spotube?
Spotube is built for music enthusiasts who want more than a standard player. Whether you need advanced metadata management, custom integrations, or a personalized interface, Spotube provides the tools to create your ideal music environment.
Explore the documentation to learn how to set up Spotube, install plugins, and make the most of its features.

View File

@ -1,72 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Forms
description: Documentation for the Forms API for spotube plugins
order: 1
---
Spotube provides a Forms API that allows plugin developers to create and manage forms within the Spotube application.
## Usage
Following will show a form with 2 text fields and text in between them:
```hetu_script
import "module:spotube_plugin" as spotube
spotube.SpotubeForm.show(
"The form page title",
[
{
objectType: "input",
id: "name",
variant: "text",
placeholder: "Enter your name",
required: true,
}.toJson(),
{
objectType: "input",
id: "password",
variant: "password", // This will obfuscate the input
placeholder: "Enter your password",
required: true,
}.toJson(),
{
objectType: "text",
text: "This is some text after the two fields.",
}.toJson(),
]
).then((result) {
// Handle the result
print(result)
})
```
The method `spotube.SpotubeForm.show` takes a title and a list of form field declaration map. The map should be, well obviously a `Map`.
Following are field map properties:
| Property | Type | Description |
| -------------- | ----------------- | ---------------------------------------------------------------------------------- |
| `objectType` | `text` or `input` | Type of the object, should be `text` for text fields and `input` for input fields. |
| `id` | `String` | Unique identifier for the field. (`input` type only) |
| `variant` | `String` | Variant of the field, can be `text`, `password` or `number`. (`input` type only) |
| `placeholder` | `String` | Optional placeholder text for the field. (`input` type only) |
| `required` | `Boolean` | Whether the field is required or not. (`input` type only) |
| `defaultValue` | `String` | Optional default value for the field. (`input` type only) |
| `regex` | `String` | Optional regex pattern to validate the input. (`input` type only) |
| `text` | `String` | Optional text for `text` object type. (Only applicable for `text` type) |
The method `spotube.SpotubeForm.show` returns a following format:
```json
[
{
"id": "name",
"value": "John Doe"
},
{
"id": "password",
"value": "12345678"
}
]
```

View File

@ -1,103 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: LocalStorage
description: Documentation for the LocalStorage API for spotube plugins
order: 2
---
The `LocalStorage` API is a plain text key/value holding persistent storage for spotube plugins. It's similar to the `localStorage` API in web browsers.
The API is a 1:1 port of [shared_preferences][shared_preferences] package from [pub.dev](https://pub.dev) (Flutter package registry)
The only difference is that the `LocalStorage` API is 100% asynchronous. So every method returns a `Future`
## Usage
#### Get values
Retrieve stored information by key:
```hetu_script
import "module:spotube_plugin" as spotube
var LocalStorage = spotube.LocalStorage
// Get a string value by key
LocalStorage.getString("key").then((value) {
print("Value for 'key': $value")
})
// Get an integer value by key
LocalStorage.getInt("key").then((value) {
print("Value for 'key': $value")
})
// Get a double value by key
LocalStorage.getDouble("key").then((value) {
print("Value for 'key': $value")
})
// Get a boolean value by key
LocalStorage.getBool("key").then((value) {
print("Value for 'key': $value")
})
// Get a list of strings by key
LocalStorage.getStringList("key").then((value) {
for (var item in value) {
print("Item in list: $item")
}
})
```
#### Set values
To set or store data in the local storage, you can use the following methods:
```hetu_script
// Set a string value by key
LocalStorage.setString("key", "value")
// Set an integer value by key
LocalStorage.setInt("key", 42)
// Set a double value by key
LocalStorage.setDouble("key", 3.14)
// Set a boolean value by key
LocalStorage.setBool("key", true)
// Set a list of strings by key
LocalStorage.setStringList("key", ["item1", "item2", "item3"])
```
#### Key operations
To remove a value from the local storage, you can use the `remove` method:
```hetu_script
// Remove a value by key
LocalStorage.remove("key")
```
To clear all values from the local storage, you can use the `clear` method:
```hetu_script
// Clear all values from local storage
LocalStorage.clear()
```
To check if a key exists in the local storage, you can use the `containsKey` method:
```hetu_script
// Check if a key exists
LocalStorage.containsKey("key").then((exists) {
if (exists) {
print("Key 'key' exists in local storage")
} else {
print("Key 'key' does not exist in local storage")
}
})
```
{/* Links */}
[shared_preferences]: https://pub.dev/packages/shared_preferences

View File

@ -1,37 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: TimeZone
description: Documentation for the TimeZone API for spotube plugins
order: 3
---
The `TimeZone` API provides access to the current time zone of the device running Spotube. This can be useful for plugins that need to display
or handle time-related information based on the user's local time zone.
## Usage
To use the `TimeZone` API, you can import the `spotube_plugin` module and access the `TimeZone` class.
```hetu_script
import "module:spotube_plugin" as spotube
var TimeZone = spotube.TimeZone
```
To get current local time zone for the device, you can use the `getLocalTimeZone` method:
```hetu_script
TimeZone.getLocalTimeZone().then((timeZone) {
print("Current local time zone: $timeZone") // e.g., "America/New_York"
})
```
To get all available time zones, you can use the `getAvailableTimeZones` method:
```hetu_script
TimeZone.getAvailableTimeZones().then((timeZones) {
for (var tz in timeZones) {
print("Available time zone: $tz") // e.g., "America/New_York", "Europe/London", etc.
}
})
```

View File

@ -1,75 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: WebView
description: Documentation for the WebView API for spotube plugins
order: 0
---
The [hetu_spotube_plugin][hetu_spotube_plugin] is a built-in module that plugin developers can use in their plugins.
```hetu_script
import "module:spotube_plugin" as spotube
```
## WebView API
The WebView API allows plugins to create and manage web views within the Spotube application.
### Usage
First, an WebView instance needs to be created with `uri`.
```hetu_script
import "module:spotube_plugin" as spotube
let webview = spotube.Webview(uri: "https://example.com")
```
To open the webview, you can use the `open` method:
```hetu_script
webview.open() // returns Future<void>
```
To close the webview, you can use the `close` method:
```hetu_script
webview.close() // returns Future<void>
```
### Listening to URL changes
You can listen to url change events by using the `onUrlRequestStream` method. It's emitted when the URL of the webview changes,
such as when the user navigates to a different page or clicks a link.
```hetu_script
// Make sure to import the hetu_std and Stream
import "module:hetu_std" as std
var Stream = std.Stream
// ... created webview instance and other stuff
var subscription = webview.onUrlRequestStream().listen((url) {
// Handle the URL change
print("URL changed to: $url")
})
// Don't forget to cancel the subscription when it's no longer needed
subscription.cancel()
```
### Retrieving cookies
To get cookies from the webview, you can use the `getCookies` method:
```hetu_script
webview.getCookies("https://example.com") // returns Future<List<Cookie>>
```
You can find the [`Cookie` class][spotube_plugin_cookie] and all it's methods and properties in the
`hetu_spotube_plugin` module source code
{/* Links */}
[hetu_spotube_plugin]: https://github.com/KRTirtho/hetu_spotube_plugin
[spotube_plugin_cookie]: https://github.com/KRTirtho/hetu_spotube_plugin/blob/main/lib/assets/hetu/webview.ht

View File

@ -1,15 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Libraries
description: List of libraries for Spotube Plugins.
order: 1
---
- [`hetu_std`][hetu_std] (built-in) - A standard library for hetu_script that provides standard set of functions and utilities.
- [`hetu_spotube_plugin`][hetu_spotube_plugin] (built-in) - Access to Spotube Plugin API, which provides functions to interact with Spotube.
- [`hetu_otp_util`][hetu_otp_util] - A pure hetu_script library for OTP utilities, such as generating and verifying OTPs.
{/* Links */}
[hetu_std]: https://github.com/hetu-community/hetu_std
[hetu_spotube_plugin]: https://github.com/KRTirtho/hetu_spotube_plugin
[hetu_otp_util]: https://github.com/hetu-community/hetu_otp_util

View File

@ -1,190 +0,0 @@
---
layout: "layouts/DocLayout.astro"
title: Plugin Models
description: "Different types of objects used in Spotube."
order: 0
---
## Image
Following is the structure of the `SpotubeImageObject`:
| Property | Type |
| -------- | --------------- |
| width | `int` or `null` |
| height | `int` or `null` |
| url | `string` |
## User
Structure of the `SpotubeUserObject`, which is used to represent a user in Spotube returned by Spotube Plugins.
| Property | Type |
| ----------- | -------------------------------------------------- |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] |
> `externalUri` is a URL that points to the user's profile on the external service (e.g. Listenbrainz)
## Artist
### SpotubeSimpleArtistObject
Following is the structure of the `SpotubeArtistObject`:
| Property | Type |
| ----------- | ------------------------------------------------------------ |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] or `null` |
### SpotubeFullArtistObject
Following is the structure of the `SpotubeFullArtistObject`:
| Property | Type |
| ----------- | ----------------------------------------------------- |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] or |
| followers | `number` |
| genres | List of `string` or `null` |
## Album
### SpotubeSimpleAlbumObject
Following is the structure of the `SpotubeAlbumObject`:
| Property | Type |
| ----------- | ---------------------------------------------------------------- |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] |
| albumType | `album`, `single` or `compilation` |
| artists | List of [`SpotubeSimpleArtistObject`][SpotubeSimpleArtistObject] |
| releaseDate | `string` (YYYY-MM-DD format) or `null` |
### SpotubeFullAlbumObject
Following is the structure of the `SpotubeFullAlbumObject`:
| Property | Type |
| ----------- | ---------------------------------------------------------------- |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] |
| albumType | `album`, `single` or `compilation` |
| artists | List of [`SpotubeSimpleArtistObject`][SpotubeSimpleArtistObject] |
| releaseDate | `string` (YYYY-MM-DD format) |
| totalTracks | `number` |
| recordLabel | `string` or `null` |
## Track
Following is the structure of the `SpotubeFullTrackObject`:
| Property | Type |
| ---------------------------- | ---------------------------------------------------------------- |
| id | `string` |
| name | `string` |
| externalUri | `string` |
| artists | List of [`SpotubeSimpleArtistObject`][SpotubeSimpleArtistObject] |
| album | [`SpotubeSimpleAlbumObject`][SpotubeSimpleAlbumObject] |
| durationMs (in milliseconds) | `number` |
| explicit | `boolean` |
| [isrc][isrc_wiki] | `string` |
> `isrc` stands for International Standard Recording Code, which is a unique identifier for tracks.
> It is used to identify recordings and is often used in music distribution and royalty collection. The format is typically a 12-character alphanumeric code.
## Playlist
### SpotubeSimplePlaylistObject
Following is the structure of the `SpotubeSimplePlaylistObject`:
| Property | Type |
| ----------- | ------------------------------------------------------------ |
| id | `string` |
| name | `string` |
| description | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] or `null` |
| owner | [`SpotubeUserObject`][SpotubeUserObject] |
### SpotubeFullPlaylistObject
Following is the structure of the `SpotubeFullPlaylistObject`:
| Property | Type |
| ------------- | ------------------------------------------------------------ |
| id | `string` |
| name | `string` |
| description | `string` |
| externalUri | `string` |
| images | List of [`SpotubeImageObject`][SpotubeImageObject] or `null` |
| owner | [`SpotubeUserObject`][SpotubeUserObject] |
| collaborators | List of [`SpotubeUserObject`][SpotubeUserObject] or `null` |
| collaborative | `boolean` |
| public | `boolean` |
## Search Response
Following is the structure of the `SpotubeSearchResponseObject`:
| Property | Type |
| --------- | -------------------------------------------------------------------- |
| albums | List of [`SpotubeSimpleAlbumObject`][SpotubeSimpleAlbumObject] |
| artists | List of [`SpotubeFullArtistObject`][SpotubeFullArtistObject] |
| playlists | List of [`SpotubeSimplePlaylistObject`][SpotubeSimplePlaylistObject] |
| tracks | List of [`SpotubeFullTrackObject`][SpotubeFullTrackObject] |
## Browse Section
Following is the structure of `SpotubeBrowseSectionObject`:
| Property | Type |
| ----------- | ---------------- |
| id | `string` |
| title | `string` |
| externalUri | `string` |
| browseMore | `boolean` |
| items | List of `Object` |
The `items` property array can contain multiple type of `Object` in it but it will always be
- [`SpotubeFullPlaylistObject`][SpotubeFullPlaylistObject]
- [`SpotubeFullAlbumObject`][SpotubeFullAlbumObject]
- [`SpotubeFullArtistObject`][SpotubeFullArtistObject]
## Pagination Response
`SpotubePaginationResponseObject` is generic model. The `items` property can contain any type of `Object` in it.
This is the structure of `SpotubePaginationResponseObject`:
| Property | Type |
| ---------- | ----------------------------------------------- |
| limit | `number` |
| nextOffset | `number` or `null` |
| total | `number` |
| hasMore | `boolean` |
| items | List of generic type `T` which extends `Object` |
[isrc_wiki]: https://en.wikipedia.org/wiki/International_Standard_Recording_Code
[SpotubeImageObject]: /docs/reference/models#image
[SpotubeSimpleArtistObject]: /docs/reference/models#spotubesimpleartistobject
[SpotubeSimpleAlbumObject]: /docs/reference/models#spotubesimplealbumobject
[SpotubeUserObject]: /docs/reference/models#user
[SpotubeFullArtistObject]: /docs/reference/models#spotubefullartistobject
[SpotubeSimplePlaylistObject]: /docs/reference/models#spotubesimpleplaylistobject
[SpotubeFullTrackObject]: /docs/reference/models#track
[SpotubeFullPlaylistObject]: /docs/reference/models#spotubefullplaylistobject
[SpotubeFullAlbumObject]: /docs/reference/models#spotubefullalbumobject

View File

@ -1,78 +0,0 @@
---
import DocSideBar from "~/components/navigation/DocSideBar.astro";
import Breadcrumbs from "~/modules/docs/Breadcrumbs.astro";
import TableOfContents from "~/modules/docs/TableOfContents.astro";
interface PageHeadings {
depth: number;
slug: string;
text: string;
}
// interface Chip {
// label: string;
// href: string;
// icon?: string;
// preset?: string;
// }
interface Props {
frontmatter: {
// Required ---
title: string;
description: string;
};
headings: PageHeadings[];
}
const { frontmatter, headings } = Astro.props;
// GitHub Settings
// const branch = "website";
// URLs
// const urls = {
// githubDocsUrl: `https://github.com/KRTirtho/spotube/tree/${branch}/website/src/content`,
// githubSpotubeUrl: `https://github.com/KRTirtho/spotube`,
// };
---
<div
class="container mx-auto grid grid-cols-1 xl:grid-cols-[240px_minmax(0px,_1fr)_280px] px-4 xl:px-10"
>
<!-- Navigation -->
<aside
class="hidden xl:block self-start sticky top-[70px] h-[calc(100vh-70px)] py-4 xl:py-10 overflow-y-auto pr-10"
data-navigation
>
<DocSideBar />
</aside>
<!-- Main -->
<main
class="px-4 xl:px-10 py-10 space-y-8 [&_.scroll-header]:scroll-mt-[calc(70px+40px)]"
>
<!-- Header -->
<header class="scroll-header space-y-4" data-pagefind-body id="_top">
<!-- Breadcrumbs -->
<Breadcrumbs />
<h1 class="h1">{frontmatter.title ?? "(title)"}</h1>
<p class="text-lg opacity-60">
{frontmatter.description ?? "(description)"}
</p>
</header>
<!-- Content -->
<article
class="space-y-8 prose lg:prose-xl dark:prose-invert"
data-pagefind-body
>
<slot />
</article>
<!-- Footer -->
<!-- <Footer classList="py-4 px-4 xl:px-0" /> -->
</main>
<!-- Sidebar -->
<aside
class="hidden xl:block self-start sticky top-[70px] h-[calc(100vh-70px)] py-4 xl:py-10 space-y-8 overflow-y-auto"
>
<TableOfContents {headings} />
</aside>
</div>

View File

@ -1,3 +0,0 @@
<div class="prose lg:prose-lg dark:prose-invert max-w-5xl mx-auto">
<slot />
</div>

View File

@ -1,106 +0,0 @@
---
import { FaGithub } from "react-icons/fa6";
import "../styles/global.css";
import TopBar from "~/components/navigation/TopBar.astro";
interface Props {
metadata?: {
title?: string;
description?: string;
keywords?: string;
author?: string;
};
}
const { metadata } = Astro.props as Props;
---
<!doctype html>
<html lang="en" data-theme="wintry">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<meta name="generator" content={Astro.generator} />
<title>{metadata?.title || "Spotube"}</title>
<meta
name="description"
content={metadata?.description ||
"A cross-platform extensible open-source music streaming platform"}
/>
<meta
name="keywords"
content={metadata?.keywords ||
"music, client, open source, music, streaming"}
/>
<meta name="author" content={metadata?.author || "KRTirtho"} />
<meta property="og:image" content="/images/spotube-logo.png" />
<meta property="og:image:alt" content="Spotube Logo" />
<meta property="og:image:type" content="image/png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metadata?.title || "Spotube"} />
<meta
name="twitter:description"
content={metadata?.description ||
"A cross-platform extensible open-source music streaming platform"}
/>
<meta name="twitter:image" content="/images/spotube-logo.png" />
<meta name="twitter:creator" content={metadata?.author || "KRTirtho"} />
<meta name="twitter:site" content="@KRTirtho" />
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1DB954" />
<script
is:inline
async
src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-6419300932495863"
data-overlays="bottom"
crossorigin="anonymous"></script>
</head>
<body>
<main class="p-2 md:p-4 min-h-[90vh]">
<TopBar />
<slot />
</main>
<footer class="w-full bg-tertiary-100-900 p-4 flex justify-between">
<div>
<h3 class="h3">Spotube</h3>
<p>
Copyright © {new Date().getFullYear()} Spotube
</p>
</div>
<ul>
<li>
<a href="https://github.com/KRTirtho/spotube">
<FaGithub className="inline mr-1" />
Github
</a>
</li>
<li>
<a href="https://opencollective.org/spotube">
<img
src="https://avatars0.githubusercontent.com/u/13403593?v=4"
alt="OpenCollective"
height="20"
width="20"
class="inline mr-1"
/>
OpenCollective
</a>
</li>
</ul>
</footer>
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import { onMount } from 'svelte';
export let adSlot: number;
export let adFormat: 'auto' | 'fluid';
// biome-ignore lint/style/useConst: This is just props
export let fullWidthResponsive = true;
// biome-ignore lint/style/useConst: This is just props
export let style = 'display:block';
// biome-ignore lint/style/useConst: This is just props
export let adLayout: 'in-article' | 'in-feed' | 'in-page' | undefined = undefined;
// biome-ignore lint/style/useConst: This is just props
export let adLayoutKey: string | undefined = undefined;
const AD_CLIENT = 'ca-pub-6419300932495863';
onMount(() => {
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
(window.adsbygoogle = window.adsbygoogle || []).push({});
});
</script>
<ins
class="adsbygoogle"
{style}
data-ad-layout={adLayout}
data-ad-client={AD_CLIENT}
data-ad-slot={adSlot}
data-ad-format={adFormat}
data-full-width-responsive={fullWidthResponsive}
data-ad-layout-key={adLayoutKey}
></ins>

View File

@ -0,0 +1,25 @@
<script lang="ts">
import type { IconDefinition } from '@fortawesome/free-brands-svg-icons';
import Fa from 'svelte-fa';
export let links: Record<string, [string, IconDefinition[], string]>;
</script>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each Object.entries(links) as link}
<a
href={link[1][0]}
class="flex flex-col btn variant-ghost-primary rounded-xl p-0 overflow-hidden"
>
<div class="relative bg-primary-500 p-4 flex gap-4 justify-center rounded-t-xl w-full">
{#each link[1][1] as icon}
<Fa {icon} />
{/each}
<p class="chip variant-ghost-warning text-warning-400 absolute right-2 uppercase">
{link[1][2]}
</p>
</div>
<p class="p-4">{link[0]}</p>
</a>
{/each}
</div>

View File

@ -0,0 +1,3 @@
<article class="prose lg:prose-lg dark:prose-invert max-w-5xl mx-auto">
<slot />
</article>

View File

@ -0,0 +1,54 @@
<script lang="ts">
import SvelteMarkdown from "svelte-markdown";
import Layout from "$lib/components/markdown/layout.svelte";
const mdContent = `
## 🚨 Spotube is banned from using "Spotify™ API" 🚨
The developer of Spotube has received a cease and desist letter from Spotify USA Inc. and Spotify AB, asserting a legal threat concerning the distribution and development of any application that utilizes Spotifys data API in conjunction with content from YouTube® to facilitate ad-free playback of music tracks. The letter contends that this specific use of the Spotify™ APIs contravenes the Spotify™ Agreements and may also infringe upon the rights of music rights holders.
Consequently, as the official maintainer of Spotube, I will immediately cease all forms of official distribution and development of Spotube that continue to employ the aforementioned 'Spotify™ APIs'
<ins>Their exact reasoning</ins>: (any) "uses of Spotifys data API in connection with content from YouTube to provide ad-free playback of music tracks. The use of the Spotify APIs in this manner violates the Spotify Agreements and may also violate the rights of music rights holders."
## So what's now?
> In short, we are cooked (legally)
For now, I've to:
1. Stop distributing/developing Spotube/any app that uses "Spotify™ APIs"
That means, I can no longer distribute Spotube through the website, GitHub, any app store and immediately have to take down the versions that uses Spotify™ APIs.
1. Stop using their logo/image/name/intellectual property in a manner that "seems infringement"
1. Forever desist from aiding or assisting any other person or entity in the activities described above
---
**For the users of Spotube:**
Don't worry, Spotube is banned only from (or assisting other) using those APIs. As long as the app isn't using them or no way helps anyone else to use them, it's ok.
In future, I'll try to rewrite Spotube to ensure it operates within the bounds of copyright law and platform policies. And give ways for the users to extend the app to their use cases. Work is already in progress to implement this! So expect some big updates soon!
But for eternity, you can't download versions of Spotube that still uses "Spotify™ APIs" from official means (website/Github/app stores). Those will be taken down.
**But newer version of Spotube that _doesn't_ use "Spotify™ APIs" will be available to replace those.**
That means, in the upcoming new versions, you will no longer be able to login with your "Spotify™ Account", access your saved playlists, albums, tracks, followed artists or perform any action on that account or anything that is from "Spotify™" or owned by "Spotify™" (yes the API public data (e.g. track metadata) as well) through Spotube.
**Conclusion:** I'm extremely sorry for this disruption to your day to day music listening experience. Spotube existed and it used by a large number of users because they find it better. And we'll continue to be better than others but legally\* from now on.
`
</script>
<div class="bg-primary-100 p-5 rounded-lg overflow-scroll max-h-[95vh]">
<Layout>
<SvelteMarkdown source={mdContent}/>
</Layout>
<p class="w-1 h-60"></p>
<p class="text-surface-500 mt-20">
Spotube has no affiliation with Spotify™ or any of its subsidiaries.
</p>
</div>

View File

@ -0,0 +1,30 @@
<script lang="ts">
import { SlideToggle } from '@skeletonlabs/skeleton';
import { persisted } from '$lib/persisted-store';
import { browser } from '$app/environment';
export const isDark = persisted('dark-mode', false);
$: {
if (browser) {
$isDark
? document.documentElement.classList.add('dark')
: document.documentElement.classList.remove('dark');
}
}
export let label: string | undefined;
</script>
<div class="inline-flex gap-2">
<SlideToggle
label={label}
active="bg-primary-backdrop-token"
size="sm"
name="dark-mode"
checked={$isDark}
on:change={() => {
isDark.update((prev) => !prev);
}}
/>
</div>

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/stores';
import { routes } from '$lib';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { SlideToggle, getDrawerStore } from '@skeletonlabs/skeleton';
import { Menu } from 'lucide-svelte';
import Fa from 'svelte-fa';
import DarkmodeToggle from './darkmode-toggle.svelte';
const drawerStore = getDrawerStore();
</script>
<header class="flex justify-between">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<button
class="btn btn-icon md:hidden"
on:click={() => {
drawerStore.set({ id: 'navdrawer', width: '400px', open: !$drawerStore.open });
}}
>
<Menu />
</button>
<h2 class="text-3xl">
<a href="/" class="flex gap-2 items-center">
<img src="/images/spotube-logo.png" width="40px" alt="Spotube Logo" />
Spotube
</a>
</h2>
</div>
<a
class="mw-2 md:me-4"
href="https://github.com/KRTirtho/spotube?referrer=spotube.krtirtho.dev"
target="_blank"
>
<button class="btn variant-filled flex items-center gap-2">
<Fa icon={faGithub} />
Star us
</button>
</a>
</div>
<nav class="hidden md:flex gap-3 items-center">
{#each Object.entries(routes) as route}
<a href={route[0]}>
<button
class={`btn rounded-full flex gap-2 ${route[0] === '/downloads' ? 'variant-glass-primary' : 'variant-glass-surface'} ${$page.url.pathname.endsWith(route[0]) ? 'underline' : ''}`}
>
{#if route[1][1] !== null}
<svelte:component this={route[1][1]} size={16} />
{/if}
{route[1][0]}
</button>
</a>
{/each}
<DarkmodeToggle />
</nav>
</header>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { routes } from '$lib';
import { ListBox, ListBoxItem, getDrawerStore } from '@skeletonlabs/skeleton';
import { X } from 'lucide-svelte';
import DarkmodeToggle from '../navbar/darkmode-toggle.svelte';
let currentRoute: string = $page.url.pathname;
const drawerStore = getDrawerStore();
</script>
<nav class="px-2">
<div class="flex justify-end">
<button class="btn btn-icon" on:click={drawerStore.close}>
<X />
</button>
</div>
<ListBox>
{#each Object.entries(routes) as route}
<ListBoxItem
bind:group={currentRoute}
name="item"
value={route[0]}
on:click={() => {
goto(route[0]);
}}
>
<div class="flex gap-2 items-center">
<svelte:component this={route[1][1]} size={16} />
{route[1][0]}
</div>
</ListBoxItem>
{/each}
<DarkmodeToggle label="Theme" />
</ListBox>
</nav>

View File

@ -1,67 +1,66 @@
import type { IconType } from "react-icons";
import {
FaAndroid,
FaApple,
FaDebian,
FaFedora,
FaOpensuse,
FaUbuntu,
FaWindows,
FaRedhat,
} from "react-icons/fa6";
import { LuHouse, LuNewspaper, LuDownload, LuBook } from "react-icons/lu";
faAndroid,
faApple,
faDebian,
faFedora,
faOpensuse,
faUbuntu,
faWindows,
faRedhat,
} from "@fortawesome/free-brands-svg-icons";
import type { IconDefinition } from "@fortawesome/free-brands-svg-icons/index";
import { Home, Newspaper, Download } from "lucide-svelte";
export const routes: Record<string, [string, IconType|null]> = {
"/": ["Home", LuHouse],
"/blog": ["Blog", LuNewspaper],
"/docs": ["Docs", LuBook],
"/downloads": ["Downloads", LuDownload],
export const routes: Record<string, [string, any]> = {
"/": ["Home", Home],
"/blog": ["Blog", Newspaper],
"/downloads": ["Downloads", Download],
"/about": ["About", null],
};
const releasesUrl =
"https://github.com/KRTirtho/Spotube/releases/latest/download";
export const downloadLinks: Record<string, [string, IconType[]]> = {
"Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid]],
export const downloadLinks: Record<string, [string, IconDefinition[]]> = {
"Android Apk": [`${releasesUrl}/Spotube-android-all-arch.apk`, [faAndroid]],
"Windows Executable": [
`${releasesUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows],
[faWindows],
],
"macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple]],
"macOS Dmg": [`${releasesUrl}/Spotube-macos-universal.dmg`, [faApple]],
"Ubuntu, Debian": [
`${releasesUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian],
[faUbuntu, faDebian],
],
"Fedora, Redhat, Opensuse": [
`${releasesUrl}/Spotube-linux-x86_64.rpm`,
[FaFedora, FaRedhat, FaOpensuse],
[faFedora, faRedhat, faOpensuse],
],
"iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple]],
"iPhone Ipa": [`${releasesUrl}/Spotube-iOS.ipa`, [faApple]],
};
export const extendedDownloadLinks: Record<
string,
[string, IconType[], string]
[string, IconDefinition[], string]
> = {
Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [FaAndroid], "apk"],
Android: [`${releasesUrl}/Spotube-android-all-arch.apk`, [faAndroid], "apk"],
Windows: [
`${releasesUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows],
[faWindows],
"exe",
],
macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"],
macOS: [`${releasesUrl}/Spotube-macos-universal.dmg`, [faApple], "dmg"],
"Ubuntu, Debian": [
`${releasesUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian],
[faUbuntu, faDebian],
"deb",
],
"Fedora, Redhat, Opensuse": [
`${releasesUrl}/Spotube-linux-x86_64.rpm`,
[FaFedora, FaRedhat, FaOpensuse],
[faFedora, faRedhat, faOpensuse],
"rpm",
],
iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"],
iPhone: [`${releasesUrl}/Spotube-iOS.ipa`, [faApple], "ipa"],
};
const nightlyReleaseUrl =
@ -69,30 +68,30 @@ const nightlyReleaseUrl =
export const extendedNightlyDownloadLinks: Record<
string,
[string, IconType[], string]
[string, IconDefinition[], string]
> = {
Android: [
`${nightlyReleaseUrl}/Spotube-android-all-arch.apk`,
[FaAndroid],
[faAndroid],
"apk",
],
Windows: [
`${nightlyReleaseUrl}/Spotube-windows-x86_64-setup.exe`,
[FaWindows],
[faWindows],
"exe",
],
macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [FaApple], "dmg"],
macOS: [`${nightlyReleaseUrl}/Spotube-macos-universal.dmg`, [faApple], "dmg"],
"Ubuntu, Debian": [
`${nightlyReleaseUrl}/Spotube-linux-x86_64.deb`,
[FaUbuntu, FaDebian],
[faUbuntu, faDebian],
"deb",
],
"Fedora, Redhat, Opensuse": [
`${nightlyReleaseUrl}/Spotube-linux-x86_64.rpm`,
[FaFedora, FaRedhat, FaOpensuse],
[faFedora, faRedhat, faOpensuse],
"rpm",
],
iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [FaApple], "ipa"],
iPhone: [`${nightlyReleaseUrl}/Spotube-iOS.ipa`, [faApple], "ipa"],
};
export const ADS_SLOTS = Object.freeze({

View File

@ -0,0 +1,106 @@
import { writable as internal, type Writable } from 'svelte/store';
declare type Updater<T> = (value: T) => T;
declare type StoreDict<T> = { [key: string]: Writable<T> };
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Stores {
local: StoreDict<any>;
session: StoreDict<any>;
}
const stores: Stores = {
local: {},
session: {}
};
export interface Serializer<T> {
parse(text: string): T;
stringify(object: T): string;
}
export type StorageType = 'local' | 'session';
export interface Options<T> {
serializer?: Serializer<T>;
storage?: StorageType;
syncTabs?: boolean;
onError?: (e: unknown) => void;
}
function getStorage(type: StorageType) {
return type === 'local' ? localStorage : sessionStorage;
}
/** @deprecated `writable()` has been renamed to `persisted()` */
export function writable<T>(key: string, initialValue: T, options?: Options<T>): Writable<T> {
console.warn(
"writable() has been deprecated. Please use persisted() instead.\n\nchange:\n\nimport { writable } from 'svelte-persisted-store'\n\nto:\n\nimport { persisted } from 'svelte-persisted-store'"
);
return persisted<T>(key, initialValue, options);
}
export function persisted<T>(key: string, initialValue: T, options?: Options<T>): Writable<T> {
const serializer = options?.serializer ?? JSON;
const storageType = options?.storage ?? 'local';
const syncTabs = options?.syncTabs ?? true;
const onError =
options?.onError ??
((e) =>
console.error(`Error when writing value from persisted store "${key}" to ${storageType}`, e));
const browser = typeof window !== 'undefined' && typeof document !== 'undefined';
const storage = browser ? getStorage(storageType) : null;
function updateStorage(key: string, value: T) {
try {
storage?.setItem(key, serializer.stringify(value));
} catch (e) {
onError(e);
}
}
function maybeLoadInitial(): T {
const json = storage?.getItem(key);
if (json) {
return <T>serializer.parse(json);
}
return initialValue;
}
if (!stores[storageType][key]) {
const initial = maybeLoadInitial();
const store = internal(initial, (set) => {
if (browser && storageType == 'local' && syncTabs) {
const handleStorage = (event: StorageEvent) => {
if (event.key === key) set(event.newValue ? serializer.parse(event.newValue) : null);
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}
});
const { subscribe, set } = store;
stores[storageType][key] = {
set(value: T) {
set(value);
updateStorage(key, value);
},
update(callback: Updater<T>) {
return store.update((last) => {
const value = callback(last);
updateStorage(key, value);
return value;
});
},
subscribe
};
}
return stores[storageType][key];
}

44
website/src/lib/posts.ts Normal file
View File

@ -0,0 +1,44 @@
export interface Post {
date: string;
title: string;
tags: string[];
published: boolean;
author: string;
cover_img: string | null;
readingTime: {
text: string;
minutes: number;
time: number;
words: number;
};
reading_time_text: string;
preview_html: string;
preview: string;
previewHtml: string;
slug: string | null;
path: string;
}
export const getPosts = async () => {
// Fetch posts from local Markdown files
const posts: Post[] = await Promise.all(
Object.entries(import.meta.glob("../../posts/**/*.md")).map(
async ([path, resolver]) => {
const resolved = (await resolver()) as { metadata: Post };
const { metadata } = resolved;
const slug = path.split("/").pop()?.slice(0, -3) ?? "";
return { ...metadata, slug };
},
),
).then((posts) => posts.filter((post) => post.published));
let sortedPosts = posts.sort((a, b) => +new Date(b.date) - +new Date(a.date));
sortedPosts = sortedPosts.map((post) => ({
...post,
}));
return {
posts: sortedPosts,
};
};

View File

@ -1,32 +0,0 @@
---
const breadcrumbs = Astro.url.pathname
.split("/")
.filter((crumb) => Boolean(crumb) && crumb !== "docs");
---
<ol class="text-xs flex gap-2">
{
breadcrumbs.map((crumb, i) => (
<>
<li
class="capitalize"
class:list={{ "opacity-60": i !== breadcrumbs.length - 1 }}
>
{i > 0 &&
i !== breadcrumbs.length - 1 &&
breadcrumbs[0] !== "components" ? (
<a
href={`/docs/${breadcrumbs[0]}/${crumb}`}
class="hover:underline"
>
{crumb.replace("-", " ")}
</a>
) : (
crumb.replace("-", " ")
)}
</li>
{i !== breadcrumbs.length - 1 && <li class="opacity-60">&rsaquo;</li>}
</>
))
}
</ol>

View File

@ -1,47 +0,0 @@
---
interface PageHeadings {
depth: number;
slug: string;
text: string;
}
interface Props {
headings: PageHeadings[];
}
const { headings } = Astro.props;
function setDepthClass(depth: number) {
if (depth === 3) return "ml-4";
if (depth === 4) return "ml-6";
if (depth === 5) return "ml-8";
if (depth === 6) return "ml-10";
return;
}
---
{
headings.length > 0 && (
<nav class="text-sm space-y-2">
<div class="font-bold">On This Page</div>
<ul class="space-y-2">
<li>
<a href={`#_top`} class="anchor block">
Overview
</a>
</li>
{headings.map((heading: PageHeadings) => (
<li>
<a
href={`#${heading.slug}`}
class="anchor block"
class:list={`${setDepthClass(heading.depth)}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
)
}

View File

@ -1,33 +0,0 @@
---
import type { IconType } from "react-icons";
interface Props {
links: Record<string, [string, IconType[], string]>;
}
const { links } = Astro.props;
---
<div class="grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{
Object.entries(links).map((link) => {
return (
<a
href={link[1][0]}
class="flex flex-col btn preset-filled-primary-100-900 rounded-xl p-0 overflow-hidden border border-primary-500"
>
<div class="relative bg-primary-500 p-4 flex gap-4 justify-center rounded-t-xl w-full">
{link[1][1].map((icon) => {
const Icon = icon;
return <Icon />;
})}
<p class="chip preset-tonal-warning text-warning-400 absolute right-2 uppercase">
{link[1][2]}
</p>
</div>
<p class="p-4">{link[0]}</p>
</a>
);
})
}
</div>

View File

@ -1,39 +0,0 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { LuBook, LuChevronDown, LuChevronUp } from "react-icons/lu";
import markdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
interface Props {
release: RestEndpointMethodTypes["repos"]["getReleaseByTag"]["response"]["data"];
}
export default function ReleaseBody({ release }: Props) {
const summary = "Release Notes & Changelogs";
const body = release.body ?? "No release notes available.";
const md = markdownIt({
html: true,
linkify: true,
typographer: true,
});
const sanitizedBody = sanitizeHtml(md.render(body));
return (<details className="rounded-md p-4 my-4 preset-tonal-primary group">
<summary className="flex items-center cursor-pointer font-semibold text-lg gap-2">
<LuBook className="inline" />
{summary}
<span className="ml-auto flex items-center">
<span className="block group-open:hidden">
<LuChevronDown />
</span>
<span className="hidden group-open:block">
<LuChevronUp />
</span>
</span>
</summary>
<article
className="prose lg:prose-xl dark:prose-invert"
dangerouslySetInnerHTML={{ __html: sanitizedBody }}
/>
</details>)
}

View File

@ -1,183 +0,0 @@
import { formatDistanceToNow, formatRelative } from "date-fns";
import ReleaseBody from "~/modules/downloads/older/release-body";
import RootLayout from "~/layouts/RootLayout.astro";
import { Octokit, type RestEndpointMethodTypes } from "@octokit/rest";
import {
FaAndroid,
FaApple,
FaGit,
FaGooglePlay,
FaLinux,
FaWindows,
} from "react-icons/fa6";
import type { IconType } from "react-icons";
import { useEffect, useState } from "react";
function getIcon(assetUrl: string) {
assetUrl = assetUrl.toLowerCase();
if (assetUrl.includes("linux")) return FaLinux;
if (assetUrl.includes("windows")) return FaWindows;
if (assetUrl.includes("mac")) return FaApple;
if (assetUrl.includes("android")) return FaAndroid;
if (assetUrl.includes("playstore")) return FaGooglePlay;
if (assetUrl.includes("ios")) return FaApple;
return FaGit;
}
function formatName(assetName: string) {
// format the assetName to be
// {OS} ({package extension})
const lowerCasedAssetName = assetName.toLowerCase();
const extension = assetName.split(".").at(-1);
if (lowerCasedAssetName.includes("linux")) {
if (lowerCasedAssetName.includes("aarch64")) {
return [`Linux`, extension, `ARM64`]
}
return [`Linux`, extension, `x64`]
};
if (lowerCasedAssetName.includes("windows")) return [`Windows`, extension];
if (lowerCasedAssetName.includes("mac")) return [`macOS`, extension];
if (
lowerCasedAssetName.includes("android") ||
lowerCasedAssetName.includes("playstore")
)
return [`Android`, extension];
if (lowerCasedAssetName.includes("ios")) return [`iOS`, extension];
return [assetName.replace(`.${extension}`, ""), extension];
}
type OctokitAsset =
RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"][0]["assets"][0];
function groupByOS(downloads: OctokitAsset[]) {
return downloads.reduce(
(acc, val) => {
const lowName = val.name.toLowerCase();
if (lowName.includes("android") || lowName.includes("playstore"))
acc["android"] = [...(acc.android ?? []), val];
if (lowName.includes("linux"))
acc["linux"] = [...(acc["linux"] ?? []), val];
if (lowName.includes("windows"))
acc["windows"] = [...(acc["windows"] ?? []), val];
if (lowName.includes("ios")) acc["ios"] = [...(acc["ios"] ?? []), val];
if (lowName.includes("mac")) acc["mac"] = [...(acc["mac"] ?? []), val];
return acc;
},
{} as Record<
"android" | "ios" | "mac" | "linux" | "windows",
OctokitAsset[]
>
);
}
const icons: Record<string, [IconType, string]> = {
android: [FaAndroid, "#3DDC84"],
mac: [FaApple, ""],
ios: [FaApple, ""],
linux: [FaLinux, "#000000"],
windows: [FaWindows, "#0078D7"],
};
export default function ReleasesSection() {
const github = new Octokit();
const [releases, setReleases] = useState<RestEndpointMethodTypes["repos"]["listReleases"]["response"]["data"]>([]);
useEffect(() => {
github.repos.listReleases({
owner: "KRTirtho",
repo: "spotube",
}).then((res) => {
setReleases(
res.data.filter((release) => {
// Ignore all releases that were published before March 18 2025
return new Date(release.published_at ?? new Date()) >= new Date("2025-03-18T00:00:00Z");
})
);
})
}, [])
return <>
{
releases.map((release) => {
return (
<div>
<h4
className="h4"
title={formatRelative(
release.published_at ?? new Date(),
new Date()
)}
>
{release.tag_name}
<span className="text-sm font-normal">
(
{formatDistanceToNow(release.published_at ?? new Date(), {
addSuffix: true,
})}
)
</span>
</h4>
<div className="flex flex-col gap-5">
{Object.entries(groupByOS(release.assets)).map(
([osName, assets]) => {
const Icon = icons[osName][0];
return (
<div className="flex flex-col gap-4">
<h5 className="h5 capitalize">
<Icon className="inline" color={icons[osName][1]} />
{osName}
</h5>
<div className="flex flex-wrap gap-4">
{assets.map((asset) => {
const Icon = getIcon(asset.browser_download_url);
const formattedName = formatName(asset.name);
return (
<a href={asset.browser_download_url}>
<button className="btn preset-tonal-primary rounded p-0 flex flex-col">
<span className="bg-primary-500 rounded-t p-3 w-full">
<Icon className="inline" />
</span>
<span className="p-4 space-x-1">
<span>
{formattedName[0]}
</span>
<span className="chip preset-tonal-error">
{formattedName[1]}
</span>
{
formattedName[2] ?
<span className="chip preset-tonal-error">
{formattedName[2]}
</span> : <></>
}
</span>
</button>
</a>
);
})}
</div>
</div>
);
}
)}
</div>
<ReleaseBody release={release} />
<hr />
</div>
);
})
}
</>
}

View File

@ -1,80 +0,0 @@
import { useEffect, useState } from "react";
import { Avatar } from "@skeletonlabs/skeleton-react";
interface Member {
MemberId: number;
createdAt: string;
type: string;
role: string;
isActive: boolean;
totalAmountDonated: number;
currency?: string;
lastTransactionAt: string;
lastTransactionAmount: number;
profile: string;
name: string;
company?: string;
description?: string;
image?: string;
email?: string;
twitter?: string;
github?: string;
website?: string;
tier?: string;
}
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
compactDisplay: 'short',
maximumFractionDigits: 0
});
export function Supporters() {
const [members, setMembers] = useState<Member[]>([]);
useEffect(() => {
// Fetch members data from an API or other source
async function fetchMembers() {
const res = await fetch('https://opencollective.com/spotube/members/all.json');
const members = (await res.json()) as Member[];
setMembers(
members
.filter((m) => m.totalAmountDonated > 0)
.sort((a, b) => b.totalAmountDonated - a.totalAmountDonated)
);
};
fetchMembers();
}, []);
return <div
className="gap-4 grid"
style={{
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gridAutoRows: 'minmax(50px, auto)',
}}
>
{
members.map((member) => {
return <a
key={member.MemberId}
href={member.profile}
target="_blank"
className="flex items-center gap-2 px-2 py-1 overflow-ellipsis preset-tonal-secondary rounded-lg"
>
<Avatar src={member.image} name={member.name} classes="w-10 h-10" />
<div className="flex flex-col overflow-hidden">
<p className="truncate">{member.name}</p>
<p className="capitalize text-sm underline decoration-dotted">
{formatter.format(member.totalAmountDonated)}
({member.role.toLowerCase()})
</p>
</div>
</a>;
})
}
</div>;
}

Some files were not shown because too many files have changed in this diff Show More