Merge branch 'dev' into website

This commit is contained in:
Kingkor Roy Tirtho 2024-03-05 22:56:42 +06:00
commit 57ac3177c0
105 changed files with 3347 additions and 783 deletions

View File

@ -1,4 +1,4 @@
{
"flutterSdkVersion": "3.16.0",
"flutterSdkVersion": "3.19.1",
"flavors": {}
}

View File

@ -26,7 +26,7 @@ on:
default: true
env:
FLUTTER_VERSION: '3.16.3'
FLUTTER_VERSION: '3.19.1'
jobs:
windows:
@ -181,6 +181,7 @@ jobs:
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'release' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
@ -189,6 +190,16 @@ jobs:
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz
- uses: actions/upload-artifact@v3
if: ${{ inputs.channel == 'nightly' }}
with:
if-no-files-found: error
name: Spotube-Release-Binaries
path: |
dist/Spotube-linux-x86_64.deb
dist/Spotube-linux-x86_64.rpm
dist/spotube-linux-nightly-x86_64.tar.xz
- name: Debug With SSH When fails
if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }}
uses: mxschmitt/action-tmate@v3
@ -424,6 +435,11 @@ jobs:
RELEASE.md5sum
RELEASE.sha256sum
- name: Debug With SSH
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
- name: Upload Release Binaries (stable)
if: ${{ !inputs.dry_run && inputs.channel == 'stable' }}
uses: ncipollo/release-action@v1

View File

@ -28,6 +28,7 @@ publishaur:
innoinstall:
powershell curl -o build\installer.exe http://files.jrsoftware.org/is/6/innosetup-${INNO_VERSION}.exe
powershell git clone https://github.com/DomGries/InnoDependencyInstaller.git build\inno-depend
powershell build\installer.exe /verysilent /allusers /dir=build\iscc
inno:

View File

@ -13,6 +13,8 @@ Btw it's not just another Electron app 😉
<a href="https://patreon.com/krtirtho"><img alt="Support me on Patron" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/patreon-singular_vector.svg"></a>
<a href="https://www.buymeacoffee.com/krtirtho"><img alt="Buy me a Coffee" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/donate/buymeacoffee-singular_vector.svg"></a>
[![HackerNews](https://hackerbadge.vercel.app/api?id=39066136&type=dark)](https://news.ycombinator.com/item?id=39066136)
<a href="https://opencollective.com/spotube"><img src="https://opencollective.com/spotube/donate/button.png?color=blue" alt="Donate to our Open Collective" height="45"></a>
---
@ -136,6 +138,15 @@ This handy table lists all the methods you can use to install Spotube:
</a>
</td>
</tr>
<tr>
<td>Macos - <a href="https://brew.sh">Homebrew</a></td>
<td>
<pre lang="bash">
brew tap krtirtho/apps
brew install --cask spotube
</pre>
</td>
</tr>
<tr>
<td>Windows - <a href="https://chocolatey.org">Chocolatey</a></td>
<td>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
assets/logos/songlink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>11.0</string>
<string>12.0</string>
</dict>
</plist>

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'
# platform :ios, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -196,7 +196,7 @@ SPEC CHECKSUMS:
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
@ -221,6 +221,6 @@ SPEC CHECKSUMS:
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
PODFILE CHECKSUM: e36c7ad9836dfd8d22934c7680185432a658e28f
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
COCOAPODS: 1.14.3
COCOAPODS: 1.15.2

View File

@ -406,7 +406,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1430;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -23,11 +25,32 @@
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -42,25 +65,6 @@
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
<true />
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -41,6 +41,10 @@
<string>This app require access to the photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -24,10 +26,31 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -43,24 +66,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -24,10 +26,31 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@ -43,24 +66,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -9,6 +9,21 @@
import 'package:flutter/widgets.dart';
class $AssetsLogosGen {
const $AssetsLogosGen();
/// File path: assets/logos/songlink-transparent.png
AssetGenImage get songlinkTransparent =>
const AssetGenImage('assets/logos/songlink-transparent.png');
/// File path: assets/logos/songlink.png
AssetGenImage get songlink =>
const AssetGenImage('assets/logos/songlink.png');
/// List of all assets
List<AssetGenImage> get values => [songlinkTransparent, songlink];
}
class $AssetsTutorialGen {
const $AssetsTutorialGen();
@ -37,6 +52,7 @@ class Assets {
static const AssetGenImage jiosaavn = AssetGenImage('assets/jiosaavn.png');
static const AssetGenImage likedTracks =
AssetGenImage('assets/liked-tracks.jpg');
static const $AssetsLogosGen logos = $AssetsLogosGen();
static const AssetGenImage placeholder =
AssetGenImage('assets/placeholder.png');
static const AssetGenImage spotubeHeroBanner =

View File

@ -4,8 +4,8 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -64,6 +64,7 @@ class HomeTabIntent extends Intent {
class HomeTabAction extends Action<HomeTabIntent> {
@override
invoke(intent) {
final router = intent.ref.read(routerProvider);
switch (intent.tab) {
case HomeTabs.browse:
router.go("/");

View File

@ -6,6 +6,11 @@ class ISOLanguageName {
required this.name,
required this.nativeName,
});
@override
String toString() {
return "$name ($nativeName)";
}
}
// Uncomment the languages as we add support for them
@ -348,10 +353,10 @@ abstract class LanguageLocals {
// name: "Kongo",
// nativeName: "KiKongo",
// ),
// "ko": const ISOLanguageName(
// name: "Korean",
// nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
// ),
"ko": const ISOLanguageName(
name: "Korean",
nativeName: "한국어 (韓國語), 조선말 (朝鮮語)",
),
// "ku": const ISOLanguageName(
// name: "Kurdish",
// nativeName: "Kurdî, كوردی‎",
@ -700,10 +705,10 @@ abstract class LanguageLocals {
// name: "Venda",
// nativeName: "Tshivenḓa",
// ),
// "vi": const ISOLanguageName(
// name: "Vietnamese",
// nativeName: "Tiếng Việt",
// ),
"vi": const ISOLanguageName(
name: "Vietnamese",
nativeName: "Tiếng Việt",
),
// "vo": const ISOLanguageName(
// name: "Volapük",
// nativeName: "Volapük",

View File

@ -2,8 +2,10 @@ import 'package:catcher_2/catcher_2.dart';
import 'package:flutter/foundation.dart' hide Category;
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
import 'package:spotube/pages/home/home.dart';
@ -18,6 +20,8 @@ import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/pages/track/track.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/pages/artist/artist.dart';
@ -31,7 +35,8 @@ import 'package:spotube/pages/mobile_login/mobile_login.dart';
final rootNavigatorKey = Catcher2.navigatorKey;
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter(
final routerProvider = Provider((ref) {
return GoRouter(
navigatorKey: rootNavigatorKey,
routes: [
ShellRoute(
@ -40,7 +45,20 @@ final router = GoRouter(
routes: [
GoRoute(
path: "/",
pageBuilder: (context, state) => const SpotubePage(child: HomePage()),
redirect: (context, state) async {
final authNotifier =
ref.read(AuthenticationNotifier.provider.notifier);
final json = await authNotifier.box.get(authNotifier.cacheKey);
if (json?["cookie"] == null &&
!KVStoreService.doneGettingStarted) {
return "/getting-started";
}
return null;
},
pageBuilder: (context, state) =>
const SpotubePage(child: HomePage()),
routes: [
GoRoute(
path: "genres",
@ -131,7 +149,8 @@ final router = GoRouter(
path: "/artist/:id",
pageBuilder: (context, state) {
assert(state.pathParameters["id"] != null);
return SpotubePage(child: ArtistPage(state.pathParameters["id"]!));
return SpotubePage(
child: ArtistPage(state.pathParameters["id"]!));
},
),
GoRoute(
@ -163,6 +182,13 @@ final router = GoRouter(
child: MiniLyricsPage(prevSize: state.extra as Size),
),
),
GoRoute(
path: "/getting-started",
parentNavigatorKey: rootNavigatorKey,
pageBuilder: (context, state) => const SpotubePage(
child: GettingStarting(),
),
),
GoRoute(
path: "/login",
parentNavigatorKey: rootNavigatorKey,
@ -184,4 +210,5 @@ final router = GoRouter(
const SpotubePage(child: LastFMLoginPage()),
),
],
);
);
});

View File

@ -112,4 +112,7 @@ abstract class SpotubeIcons {
static const discord = SimpleIcons.discord;
static const youtube = SimpleIcons.youtube;
static const radio = FeatherIcons.radio;
static const github = SimpleIcons.github;
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
}

View File

@ -0,0 +1,31 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class BlurCard extends HookConsumerWidget {
final Widget child;
const BlurCard({super.key, required this.child});
@override
Widget build(BuildContext context, ref) {
return Container(
margin: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
constraints: const BoxConstraints(maxWidth: 400),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: double.infinity,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: child,
),
),
),
);
}
}

View File

@ -1,3 +1,6 @@
import 'dart:ffi';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
@ -69,6 +72,10 @@ class HomePageFriendsSection extends HookConsumerWidget {
),
),
SliverToBoxAdapter(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: PointerDeviceKind.values.toSet(),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Column(
@ -88,6 +95,7 @@ class HomePageFriendsSection extends HookConsumerWidget {
),
),
),
),
],
),
);

View File

@ -50,10 +50,11 @@ enum SortBy {
none,
ascending,
descending,
artist,
album,
newest,
oldest,
duration,
artist,
album,
}
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {

View File

@ -25,6 +25,7 @@ import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PlayerView extends HookConsumerWidget {
final PanelController panelController;
@ -94,10 +95,10 @@ class PlayerView extends HookConsumerWidget {
final topPadding = MediaQueryData.fromView(View.of(context)).padding.top;
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
panelController.close();
return WillPopScope(
onWillPop: () async {
await panelController.close();
return false;
},
child: IconTheme(
data: theme.iconTheme.copyWith(color: bodyTextColor),
@ -137,11 +138,32 @@ class PlayerView extends HookConsumerWidget {
onPressed: panelController.close,
),
actions: [
TextButton.icon(
icon: Assets.logos.songlinkTransparent.image(
width: 20,
height: 20,
color: bodyTextColor,
),
label: Text(context.l10n.song_link),
style: TextButton.styleFrom(
foregroundColor: bodyTextColor,
padding: EdgeInsets.zero,
),
onPressed: currentTrack == null
? null
: () {
final url =
"https://song.link/s/${currentTrack.id}";
launchUrlString(url);
},
),
IconButton(
icon: const Icon(SpotubeIcons.info, size: 18),
tooltip: context.l10n.details,
style: IconButton.styleFrom(
foregroundColor: bodyTextColor),
foregroundColor: bodyTextColor,
),
onPressed: currentTrack == null
? null
: () {

View File

@ -15,8 +15,6 @@ class InterScrollbar extends HookWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (DesktopTools.platform.isDesktop) return child;
return DraggableScrollbar.semicircle(

View File

@ -48,6 +48,11 @@ class SortTracksDropdown extends StatelessWidget {
enabled: value != SortBy.oldest,
title: Text(context.l10n.sort_oldest),
),
PopSheetEntry(
value: SortBy.duration,
enabled: value != SortBy.duration,
title: Text(context.l10n.sort_duration),
),
PopSheetEntry(
value: SortBy.artist,
enabled: value != SortBy.artist,

View File

@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
@ -26,10 +27,12 @@ import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/search.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
album,
share,
songlink,
addToPlaylist,
addToQueue,
removeFromPlaylist,
@ -165,6 +168,7 @@ class TrackOptions extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final mediaQuery = MediaQuery.of(context);
final router = GoRouter.of(context);
final ThemeData(:colorScheme) = Theme.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
@ -276,6 +280,10 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details:
showDialog(
context: context,
@ -418,6 +426,15 @@ class TrackOptions extends HookConsumerWidget {
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
),
PopSheetEntry(
value: TrackOptionValue.songlink,
leading: Assets.logos.songlinkTransparent.image(
width: 22,
height: 22,
color: colorScheme.onSurface.withOpacity(0.5),
),
title: Text(context.l10n.song_link),
),
PopSheetEntry(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),

View File

@ -70,9 +70,9 @@ class TrackViewHeaderActions extends HookConsumerWidget {
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () {
props.onHeart?.call();
if (isUserPlaylist) {
onPressed: () async {
final shouldPop = await props.onHeart?.call();
if (isUserPlaylist && shouldPop == true && context.mounted) {
context.pop();
}
},

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
@ -13,6 +15,7 @@ class TrackView extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final controller = useScrollController();
return Scaffold(
appBar: DesktopTools.platform.isDesktop
@ -29,8 +32,11 @@ class TrackView extends HookConsumerWidget {
extendBodyBehindAppBar: true,
body: RefreshIndicator(
onRefresh: props.pagination.onRefresh,
child: const CustomScrollView(
slivers: [
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: const [
TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: Duration(milliseconds: 500),
@ -39,6 +45,7 @@ class TrackView extends HookConsumerWidget {
],
),
),
),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:spotify/spotify.dart';
@ -62,7 +64,7 @@ class InheritedTrackView extends InheritedWidget {
final String shareUrl;
// events
final VoidCallback? onHeart; // if null heart button will hidden
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
const InheritedTrackView({
super.key,

View File

@ -19,6 +19,8 @@ void useDeepLinking(WidgetRef ref) {
final spotify = ref.watch(spotifyProvider);
final queryClient = useQueryClient();
final router = ref.watch(routerProvider);
useEffect(() {
void uriListener(List<SharedFile> files) async {
for (final file in files) {

View File

@ -41,6 +41,7 @@
"sort_z_a": "Sort by Z-A",
"sort_artist": "Sort by Artist",
"sort_album": "Sort by Album",
"sort_duration": "Sort by Duration",
"sort_tracks": "Sort Tracks",
"currently_downloading": "Currently Downloading ({tracks_length})",
"cancel_all": "Cancel All",
@ -290,5 +291,27 @@
"start_a_radio": "Start a Radio",
"how_to_start_radio": "How do you want to start the radio?",
"replace_queue_question": "Do you want to replace the current queue or append to it?",
"endless_playback": "Endless Playback"
"endless_playback": "Endless Playback",
"delete_playlist": "Delete Playlist",
"delete_playlist_confirmation": "Are you sure you want to delete this playlist?",
"local_tracks": "Local Tracks",
"song_link": "Song Link",
"skip_this_nonsense": "Skip this nonsense",
"freedom_of_music": "“Freedom of Music”",
"freedom_of_music_palm": "“Freedom of Music in the palm of your hand”",
"get_started": "Let's get started",
"youtube_source_description": "Recommended and works best.",
"piped_source_description": "Feeling free? Same as YouTube but a lot free.",
"jiosaavn_source_description": "Best for South Asian region.",
"highest_quality": "Highest Quality: {quality}",
"select_audio_source": "Select Audio Source",
"endless_playback_description": "Automatically append new songs\nto the end of the queue",
"choose_your_region": "Choose your region",
"choose_your_region_description": "This will help Spotube show you the right content\nfor your location.",
"choose_your_language": "Choose your language",
"help_project_grow": "Help this project grow",
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
"contribute_on_github": "Contribute on GitHub",
"donate_on_open_collective": "Donate on Open Collective",
"browse_anonymously": "Browse Anonymously"
}

291
lib/l10n/app_ko.arb Normal file
View File

@ -0,0 +1,291 @@
{
"guest": "게스트",
"browse": "찾아보기",
"search": "검색",
"library": "라이브러리",
"lyrics": "가사",
"settings": "설정",
"genre_categories_filter": "카테고리 혹은 장르별로 불러오기",
"genre": "장르",
"personalized": "맞춤 추천",
"featured": "인기",
"new_releases": "신곡",
"songs": "노래",
"playing_track": "{track} 을 재생",
"queue_clear_alert": "현재 재생 대기열을 없앱니다。{track_length} 곡이 제거됩니다。\n계속 진행할까요?",
"load_more": "더 불러오기",
"playlists": "플레이리스트",
"artists": "아티스트",
"albums": "앨범",
"tracks": "곡",
"downloads": "다운로드한 곡",
"filter_playlists": "플레이리스트를 필터링",
"liked_tracks": "좋아하는 곡",
"liked_tracks_description": "좋아요를 남긴 곡들",
"create_playlist": "플레이리스트 생성",
"create_a_playlist": "플레이리스트를 생성",
"create": "생성",
"cancel": "취소",
"playlist_name": "플레이리스트명",
"name_of_playlist": "플레이리스트의 이름",
"description": "설명",
"public": "공개",
"collaborative": "공유 플레이리스트",
"search_local_tracks": "기기에 저장된 곡을 검색하기",
"play": "재생",
"delete": "삭제",
"none": "없음",
"sort_a_z": "A-Z 순으로 정렬",
"sort_z_a": "Z-A 순으로 정렬",
"sort_artist": "아티스트 순으로 정렬",
"sort_album": "앨범 순으로 정렬",
"sort_tracks": "곡명 순으로 정렬",
"currently_downloading": "현재 ({tracks_length}) 곡 다운로드 중",
"cancel_all": "모두 취소",
"filter_artist": "아티스트 필터링",
"followers": "{followers} 팔로워",
"add_artist_to_blacklist": "이 아티스트를 블랙리스트에 추가",
"top_tracks": "인기곡",
"fans_also_like": "애청자들이 좋아하는 곡",
"loading": "불러오는 중...",
"artist": "아티스트",
"blacklisted": "블랙리스트",
"following": "팔로우 중",
"follow": "팔로우하기",
"artist_url_copied": "아티스트의 URL 주소를 클립보드에 복사함",
"added_to_queue": "{tracks} 곡을 대기열에 추가함",
"filter_albums": "앨범 필터링",
"synced": "동기화됨",
"plain": "그대로",
"shuffle": "셔플",
"search_tracks": "곡 검색하기",
"released": "공개일",
"error": "에러",
"title": "타이틀",
"time": "길이",
"more_actions": "다른 작업",
"download_count": "({count}) 곡 다운로드",
"add_count_to_playlist": "플레이리스트에 ({count}) 곡을 추가",
"add_count_to_queue": "대기열에 ({count}) 곡을 추가",
"play_count_next": "이 다음에 ({count}) 곡을 재생",
"album": "앨범",
"copied_to_clipboard": "{data} 를 클립보드에 복사함",
"add_to_following_playlists": "{track} 을 이 플레이리스트에 추가",
"add": "추가",
"added_track_to_queue": "대기열에 {track} 을 추가함",
"add_to_queue": "대기열에 추가",
"track_will_play_next": "{track} 을 이 다음에 재생",
"play_next": "이 다음에 재생",
"removed_track_from_queue": "대기열에서 {track} 를 제거함",
"remove_from_queue": "대기열에서 제거",
"remove_from_favorites": "즐겨찾기에서 제거",
"save_as_favorite": "즐겨찾기에 추가",
"add_to_playlist": "플레이리스트에 추가",
"remove_from_playlist": "플레이리스트에서 제거",
"add_to_blacklist": "블랙리스트에 추가",
"remove_from_blacklist": "블랙리스트에서 제거",
"share": "공유",
"mini_player": "미니 플레이어",
"slide_to_seek": "앞뒤로 슬라이드하여 탐색",
"shuffle_playlist": "플레이리스트를 섞기",
"unshuffle_playlist": "플레이리스트를 섞지 않기",
"previous_track": "이전 곡",
"next_track": "다음 곡",
"pause_playback": "일시정지",
"resume_playback": "재개",
"loop_track": "반복 재생",
"repeat_playlist": "플레이리스트 반복",
"queue": "재생 대기열",
"alternative_track_sources": "대체가능한 음악 서버",
"download_track": "곡 다운로드",
"tracks_in_queue": "대기열에 {tracks} 곡이 있음",
"clear_all": "모두 제거",
"show_hide_ui_on_hover": "마우스를 올리면 UI를 표시/숨김",
"always_on_top": "항상 위에 표시",
"exit_mini_player": "미니 플레이어 닫기",
"download_location": "다운로드 경로",
"account": "계정",
"login_with_spotify": "Spotify 계정으로 로그인",
"connect_with_spotify": "Spotify에 연결",
"logout": "로그아웃",
"logout_of_this_account": "이 계정에서 로그아웃",
"language_region": "언어 & 지역",
"language": "언어",
"system_default": "시스템 기본설정",
"market_place_region": "마켓플레이스 지역",
"recommendation_country": "추천 국가",
"appearance": "디자인",
"layout_mode": "레이아웃 모드",
"override_layout_settings": "반응형 레이아웃 모드 설정 덮어씌우기",
"adaptive": "적응형",
"compact": "컴팩트",
"extended": "확장",
"theme": "테마",
"dark": "다크",
"light": "라이트",
"system": "시스템과 동일",
"accent_color": "보조색",
"sync_album_color": "앨범 색상",
"sync_album_color_description": "앨범아트의 주요 색상을 보조색으로 사용",
"playback": "재생",
"audio_quality": "음질",
"high": "높음",
"low": "낮음",
"pre_download_play": "재생할 곡을 미리 다운로드",
"pre_download_play_description": "스트리밍 방식을 쓰는 대신 파일 단위로 다운로드 받고 재생 (인터넷 대역폭이 높은 환경에서 추천)",
"skip_non_music": "음악이 아닌 부분을 스킵 (SponsorBlock)",
"blacklist_description": "블랙리스트에 추가된 곡과 아티스트",
"wait_for_download_to_finish": "현재 진행중인 다운로드가 끝날 때까지 기다려주세요",
"desktop": "데스크톱",
"close_behavior": "닫을 때의 동작",
"close": "닫기",
"minimize_to_tray": "트레이로 최소화",
"show_tray_icon": "시스템 트레이 아이콘 표시",
"about": "앱 정보",
"u_love_spotube": "Spotube... 사랑하시죠?",
"check_for_updates": "업데이트 확인",
"about_spotube": "Spotube에 관해",
"blacklist": "블랙리스트",
"please_sponsor": "후원해주시면 감사하겠습니다.",
"spotube_description": "Spotube는, 경량에 크로스플랫폼인데다 무료이기까지한 스포티파이 클라이언트입니다",
"version": "버전",
"build_number": "빌드 번호",
"founder": "창시자",
"repository": "리포지토리",
"bug_issues": "버그 및 이슈",
"made_with": "❤️을 담아 방글라데시에서 만듦",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "라이선스",
"add_spotify_credentials": "먼저 Spotify의 로그인정보를 추가하기",
"credentials_will_not_be_shared_disclaimer": "걱정마세요. 개인정보를 수집하거나 공유하지 않습니다.",
"know_how_to_login": "어떻게 하는건지 모르겠나요?",
"follow_step_by_step_guide": "사용법 확인하기",
"spotify_cookie": "Spotify {name} Cookies",
"cookie_name_cookie": "{name} Cookies",
"fill_in_all_fields": "모든 필드에 정보를 입력해주세요",
"submit": "제출",
"exit": "종료",
"previous": "이전으로",
"next": "다음으로",
"done": "완료",
"step_1": "1단계",
"first_go_to": "가장 먼저 먼저 들어갈 곳은 ",
"login_if_not_logged_in": "그리고 로그인을 하지 않았다면 로그인해주세요",
"step_2": "2단계",
"step_2_steps": "1. 로그인에 성공하면、F12나 마우스 우클릭 검사(Inspect)을 눌러 브라우저의 개발자 도구(devtools)를 열어주세요.\n2. 애플리케이션 (Application) 탭 (Chrome, Edge, Brave 등) 또는 스토리지 탭 (Firefox, Palemoon 등)을 열어주세요.\n3. 쿠키 (Cookies) 섹션으로 들어가서, https://accounts.spotify.com 서브섹션으로 들어가주세요.",
"step_3": "3단계",
"success_emoji": "성공🥳",
"success_message": "성공적으로 스포티파이 게정으로 로그인했습니다. 잘했어요!",
"step_4": "4단계",
"something_went_wrong": "알 수 없는 이유로 동작에 실패했습니다.",
"piped_instance": "Piped 서버의 인스턴스",
"piped_description": "곡 탐색에 사용할 Piped 서버 인스턴스",
"piped_warning": "몇몇 서버는 제대로 동작하지 않을 수 있습니다. 본인 책임 하에 이용해주세요.",
"generate_playlist": "플레이리스트 생성",
"track_exists": "곡 {track} 은 이미 리스트에 있습니다",
"replace_downloaded_tracks": "다운로드한 모든 곡을 교체",
"skip_download_tracks": "다운로드가 끝난 곡을 모두 건너뛰기",
"do_you_want_to_replace": "현재 곡을 교체하시겠습니까?",
"replace": "교체",
"skip": "건너뛰기",
"select_up_to_count_type": "{type}을 {count}개까지 선택",
"select_genres": "장르 선택",
"add_genres": "장르 추가",
"country": "국가",
"number_of_tracks_generate": "생성할 곡 수",
"acousticness": "반주 구간 (Acousticness)",
"danceability": "흥겨운 정도 (Danceability)",
"energy": "에너지 (Energy)",
"instrumentalness": "기악성 (Instrumentalness)",
"liveness": "생동감 (Liveness)",
"loudness": "라우드니스 (Loudness)",
"speechiness": "회화성 (Speechniss)",
"valence": "감정가 (Valence)",
"popularity": "인기도 (Popularity)",
"key": "조성 (키)",
"duration": "길이 (초)",
"tempo": "템포 (BPM)",
"mode": "장조",
"time_signature": "박자",
"short": "짧음",
"medium": "중간",
"long": "긺",
"min": "최소",
"max": "최대",
"target": "목표",
"moderate": "보통",
"deselect_all": "모두 선택해제",
"select_all": "모두 선택",
"are_you_sure": "괜찮겠습니까?",
"generating_playlist": "커스텀 플레이리스트를 생성하는 중...",
"selected_count_tracks": "{count} 곡이 선택되었습니다.",
"download_warning": "모든 트랙을 대량으로 다운로드하는 것은 명백한 불법 복제이며 음악 창작 사회에 피해를 입히는 행위입니다. 이 점을 알아주셨으면 합니다. 항상 아티스트의 노력을 존중하고 응원해 주세요.",
"download_ip_ban_warning": "참고로, 평소보다 과도한 다운로드 요청으로 인해 YouTube에서 IP가 차단될 수 있습니다. IP 차단은 해당 IP 기기에서 최소 2~3개월 동안 (로그인한 상태에서도) YouTube를 사용할 수 없음을 의미합니다. 그리고 이런 일이 발생하더라도 스포튜브는 어떠한 책임도 지지 않습니다.",
"by_clicking_accept_terms": "'동의'를 클릭하면 다음 약관에 동의하는 것입니다:",
"download_agreement_1": "알고 있습니다. 전 나쁜 사람입니다.",
"download_agreement_2": "제가 할 수 있는 모든 곳에서 아티스트를 지원할 것이며, 저는 그들의 작품을 살 돈이 없기 때문에 이렇게 하는 것뿐입니다.",
"download_agreement_3": "본인은 YouTube에서 내 IP가 차단될 수 있음을 완전히 알고 있으며, 현재 내 행동으로 인해 발생하는 사고에 대해 Spotube 또는 그 소유자/기여자에게 책임을 묻지 않습니다.",
"decline": "거절",
"accept": "동의",
"details": "상세",
"youtube": "YouTube",
"channel": "채널",
"likes": "좋아요",
"dislikes": "싫어요",
"views": "조회수",
"streamUrl": "스트림 URL",
"stop": "중지",
"sort_newest": "최근에 추가된 순으로 정렬",
"sort_oldest": "예전에 추가된 순으로 정렬",
"sleep_timer": "취침 타이머",
"mins": "{minutes} 분",
"hours": "{hours} 시간",
"hour": "{hours} 시간",
"custom_hours": "시간 설정",
"logs": "로그",
"developers": "개발",
"not_logged_in": "로그인하지 않았습니다",
"search_mode": "검색 모드",
"audio_source": "오디오 출처",
"ok": "알겠습니다",
"failed_to_encrypt": "암호화에 실패했습니다",
"encryption_failed_warning": "Spotube는 암호화를 사용하여 데이터를 안전하게 저장합니다. 하지만 그렇게 하지 못했습니다. 따라서 안전하지 않은 저장소로 대체됩니다.\n리눅스를 사용하는 경우, 비밀 서비스(gnome-keyring, kde-wallet, keepassxc 등)가 설치되어 있는지 확인하세요.",
"querying_info": "정보를 얻는 중...",
"piped_api_down": "Piped API가 응답하지 않습니다",
"piped_down_error_instructions": "Piped 인스턴스 {pipedInstance}가 현재 다운되었습니다.\n\n인스턴스를 변경하거나 'API 유형'을 공식 YouTube API로 변경하세요.\n\n변경 후 앱을 다시 시작해야 합니다.",
"you_are_offline": "현재 오프라인입니다",
"connection_restored": "인터넷에 다시 연결되었습니다",
"use_system_title_bar": "시스템 타이틀바를 사용",
"update_playlist": "플레이리스트를 업데이트",
"update": "업데이트",
"crunching_results": "결과를 처리하는 중...",
"search_to_get_results": "결과를 얻으려면 검색해주세요",
"use_amoled_mode": "AMOLED모드를 사용",
"pitch_dark_theme": "검정색 기반의 어두운 테마",
"normalize_audio": "오디오 노멀라이즈",
"change_cover": "커버 변경",
"add_cover": "커버 추가",
"restore_defaults": "기본값으로 복원",
"download_music_codec": "다운로드 음악 코덱",
"streaming_music_codec": "스트리밍 음악 코덱",
"login_with_lastfm": "Last.fm에 로그인",
"connect": "연결",
"disconnect_lastfm": "Last.fm에서 연결 해제",
"disconnect": "연결 해제",
"username": "사용자명",
"password": "비밀번호",
"login": "로그인",
"login_with_your_lastfm": "내 Last.fm 계정으로로그인",
"scrobble_to_lastfm": "Scrobble to Last.fm",
"go_to_album": "앨범으로 이동",
"discord_rich_presence": "Discord Rich Presence",
"browse_all": "모두 탐색",
"genres": "장르",
"explore_genres": "장르 탐색",
"step_3_steps": "\"sp_dc\" 쿠키의 값을 복사",
"step_4_steps": "복사한 \"sp_dc\"값을 붙여넣기",
"friends": "친구",
"no_lyrics_available": "죄송하지만 이 곡의 가사를 찾지 못했습니다",
"@@locale": "ko"
}

288
lib/l10n/app_vi.arb Normal file
View File

@ -0,0 +1,288 @@
{
"guest": "Khách",
"browse": "Khám phá",
"search": "Tìm kiếm",
"library": "Thư viên",
"lyrics": "Lời bài hát",
"settings": "Cài đặt",
"genre_categories_filter": "Lọc theo thể loại nhạc...",
"genre": "Thể loại nhạc",
"personalized": "Cá nhân hóa",
"featured": "Nổi bật",
"new_releases": "Bản phát hành mới",
"songs": "Bài hát",
"playing_track": "Đang phát {track}",
"queue_clear_alert": "Điều này sẽ xóa hàng đợi hiện tại. {track_length} bài hát sẽ bị xóa\nBạn có muốn tiếp tục không?",
"load_more": "Tải thêm",
"playlists": "Danh sách phát",
"artists": "Nghệ sĩ",
"albums": "Album",
"tracks": "Bài hát",
"downloads": "Tải về",
"filter_playlists": "Lọc danh sách phát...",
"liked_tracks": "Bài hát được thích",
"liked_tracks_description": "Tất cả bài hát bạn đã thích",
"create_playlist": "Tạo danh sách phát",
"create_a_playlist": "Tạo danh sách phát",
"update_playlist": "Cập nhật danh sách phát",
"create": "Tạo",
"cancel": "Hủy",
"update": "Cập nhật",
"playlist_name": "Tên danh sách phát",
"name_of_playlist": "Tên của danh sách phát",
"description": "Mô tả",
"public": "Công khai",
"collaborative": "Hợp tác",
"search_local_tracks": "Tìm kiếm bài hát trong máy...",
"play": "Phát",
"delete": "Xóa",
"none": "Không có",
"sort_a_z": "Sắp xếp theo A-Z",
"sort_z_a": "Sắp xếp theo Z-A",
"sort_artist": "Sắp xếp theo Nghệ sĩ",
"sort_album": "Sắp xếp theo Album",
"sort_tracks": "Sắp xếp các bài hát",
"currently_downloading": "Đang tải về ({tracks_length} bài hát)",
"cancel_all": "Hủy tất cả",
"filter_artist": "Lọc nghệ sĩ...",
"followers": "{followers} Người theo dõi",
"add_artist_to_blacklist": "Thêm nghệ sĩ vào blacklist",
"top_tracks": "Bài hát nổi bật",
"fans_also_like": "Người hâm mộ cũng thích",
"loading": "Đang tải...",
"artist": "Nghệ sĩ",
"blacklisted": "Đã đưa vào blacklist",
"following": "Đang theo dõi",
"follow": "Theo dõi",
"artist_url_copied": "Đã sao chép URL nghệ sĩ",
"added_to_queue": "Đã thêm {tracks} bài hát vào hàng đợi",
"filter_albums": "Lọc album...",
"synced": "Đồng bộ",
"plain": "Bình thường",
"shuffle": "Trộn",
"search_tracks": "Tìm kiếm bài hát...",
"released": "Phát hành",
"error": "Lỗi {error}",
"title": "Đề mục",
"time": "Thời gian",
"more_actions": "Thao tác khác",
"download_count": "Tải xuống ({count})",
"add_count_to_playlist": "Thêm ({count}) vào danh sách phát",
"add_count_to_queue": "Thêm ({count}) vào hàng đợi",
"play_count_next": "Phát ({count}) tiếp theo",
"album": "Album",
"copied_to_clipboard": "Đã sao chép {data} vào clipboard",
"add_to_following_playlists": "Thêm {track} vào danh sách phát đang theo dõi",
"add": "Thêm",
"added_track_to_queue": "Đã thêm {track} vào hàng đợi",
"add_to_queue": "Thêm vào hàng đợi",
"track_will_play_next": "{track} sẽ được phát tiếp theo",
"play_next": "Phát tiếp theo",
"removed_track_from_queue": "Đã xóa {track} khỏi hàng đợi",
"remove_from_queue": "Xóa khỏi hàng đợi",
"remove_from_favorites": "Xóa khỏi bài hát yêu thích",
"save_as_favorite": "Thêm vào bài hát yêu thích",
"add_to_playlist": "Thêm vào danh sách phát",
"remove_from_playlist": "Xóa khỏi danh sách phát",
"add_to_blacklist": "Thêm vào blacklist",
"remove_from_blacklist": "Xóa khỏi blacklist",
"share": "Chia sẻ",
"mini_player": "Trình phát thu nhỏ",
"slide_to_seek": "Trượt để tìm kiếm tiến hoặc lùi",
"shuffle_playlist": "Xáo trộn bài hát",
"unshuffle_playlist": "Hủy xáo trộn bài hát",
"previous_track": "Bài hát trước",
"next_track": "Bài hát tiếp theo",
"pause_playback": "Tạm dừng phát",
"resume_playback": "Tiếp tục phát",
"loop_track": "Lặp lại bài hát",
"repeat_playlist": "Lặp lại danh sách phát",
"queue": "Hàng đợi",
"alternative_track_sources": "Đổi nguồn bài hát",
"download_track": "Tải xuống",
"tracks_in_queue": "{tracks} bài hát trong hàng đợi",
"clear_all": "Xóa tất cả",
"show_hide_ui_on_hover": "Hiển thị/Ẩn giao diện người dùng khi di chuột qua",
"always_on_top": "Luôn ở trên cùng",
"exit_mini_player": "Thoát khỏi trình phát thu nhỏ",
"download_location": "Vị trí tải xuống",
"account": "Tài khoản",
"login_with_spotify": "Đăng nhập bằng tài khoản Spotify của bạn",
"connect_with_spotify": "Liên kết với Spotify",
"logout": "Đăng xuất",
"logout_of_this_account": "Đăng xuất khỏi tài khoản này",
"language_region": "Ngôn ngữ và Khu vực",
"language": "Ngôn ngữ",
"system_default": "Mặc định hệ thống",
"market_place_region": "Khu vực Marketplace",
"recommendation_country": "Quốc gia gợi ý",
"appearance": "Giao diện",
"layout_mode": "Chế độ layout",
"override_layout_settings": "Ghi đè cài đặt layout",
"adaptive": "Tương thích",
"compact": "Nhỏ gọn",
"extended": "Mở rộng",
"theme": "Chủ đề",
"dark": "Tối",
"light": "Sáng",
"system": "Hệ thống",
"accent_color": "Màu nhấn",
"sync_album_color": "Đồng bộ màu album",
"sync_album_color_description": "Sử dụng màu chủ đạo của hình ảnh album làm màu nhấn",
"playback": "Phát",
"audio_quality": "Chất lượng âm thanh",
"high": "Cao",
"low": "Thấp",
"pre_download_play": "Tải xuống và phát",
"pre_download_play_description": "Thay vì stream âm thanh, tải xuống trước và phát (Khuyến nghị cho người dùng có băng thông cao)",
"skip_non_music": "Bỏ qua các đoạn không phải nhạc (SponsorBlock)",
"blacklist_description": "Các bài hát và nghệ sĩ trong blacklist",
"wait_for_download_to_finish": "Vui lòng đợi quá trình tải xuống hiện tại hoàn thành",
"desktop": "Máy tính",
"close_behavior": "Thao tác đóng",
"close": "Đóng",
"minimize_to_tray": "Thu nhỏ vào khay hệ thống",
"show_tray_icon": "Hiển thị biểu tượng trên khay hệ thống",
"about": "Về chúng tôi",
"u_love_spotube": "Chúng tôi biết bạn yêu Spotube",
"check_for_updates": "Kiểm tra cập nhật",
"about_spotube": "Về Spotube",
"blacklist": "blacklist",
"please_sponsor": "Vui lòng tài trợ/ủng hộ",
"spotube_description": "Spotube, một ứng dụng Spotify nhẹ, đa nền tảng và miễn phí",
"version": "Phiên bản",
"build_number": "Số phiên bản",
"founder": "Người sáng lập",
"repository": "Mã nguồn",
"bug_issues": "Báo cáo lỗi",
"made_with": "Được làm bằng ❤️ ở Băng-la-đét",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "Giấy phép",
"add_spotify_credentials": "Điền thông tin đăng nhập Spotify của bạn",
"credentials_will_not_be_shared_disclaimer": "Đừng lo, thông tin đăng nhập của bạn sẽ không được thu thập hoặc chia sẻ với bất kỳ ai",
"know_how_to_login": "Không biết cách lấy thông tin đăng nhập?",
"follow_step_by_step_guide": "Các bước lấy thông tin đăng nhập",
"spotify_cookie": "Cookie Spotify {name}",
"cookie_name_cookie": "Cookie {name}",
"fill_in_all_fields": "Vui lòng điền đầy đủ thông tin",
"submit": "Gửi",
"exit": "Thoát",
"previous": "Trước",
"next": "Tiếp",
"done": "Hoàn tất",
"step_1": "Bước 1",
"first_go_to": "Đầu tiên, truy cập",
"login_if_not_logged_in": "và Đăng nhập/Đăng ký nếu chưa có tài khoản",
"step_2": "Bước 2",
"step_2_steps": "1. Sau khi đăng nhập, nhấn F12 hoặc Chuột phải > Mở devtools của trình duyệt.\n2. Sau đó, chuyển đến Tab \"Ứng dụng/Application\" (Chrome, Edge, Brave, v.v.) hoặc Tab \"Lưu trữ/Storage\" (Firefox, Palemoon, v.v.)\n3. Chuyển đến phần \"Cookie\" sau đó phần con \"https://accounts.spotify.com\"",
"step_3": "Bước 3",
"step_3_steps": "Sao chép giá trị của Cookie \"sp_dc\" và \"sp_key\" (hoặc sp_gaid)",
"success_emoji": "Thành công🥳",
"success_message": "Bây giờ bạn đã đăng nhập thành công bằng tài khoản Spotify của mình. Làm tốt lắm!",
"step_4": "Bước 4",
"step_4_steps": "Dán giá trị đã sao chép của Cookie \"sp_dc\" và \"sp_key\" (hoặc sp_gaid) vào các trường tương ứng",
"something_went_wrong": "Đã xảy ra lỗi",
"piped_instance": "Phiên bản Server Piped",
"piped_description": "Phiên bản Piped để sử dụng cho Track matching",
"piped_warning": "Một số phiên bản Piped có thể không hoạt động tốt",
"generate_playlist": "Tạo danh sách phát",
"track_exists": "Bài hát {track} đã tồn tại",
"replace_downloaded_tracks": "Thay thế tất cả các bài hát đã tải",
"skip_download_tracks": "Bỏ qua tải xuống tất cả các bài hát đã tải",
"do_you_want_to_replace": "Bạn có muốn thay thế bài hát hiện có không?",
"replace": "Thay thế",
"skip": "Bỏ qua",
"select_up_to_count_type": "Chọn tối đa {count} {type}",
"select_genres": "Chọn Thể loại",
"add_genres": "Thêm Thể loại",
"country": "Quốc gia",
"number_of_tracks_generate": "Số lượng bài hát để tạo",
"acousticness": "Độ âm thanh",
"danceability": "Khả năng nhảy",
"energy": "Năng lượng",
"instrumentalness": "Độ nhạc cụ",
"liveness": "Sống động",
"loudness": "Độ ồn",
"speechiness": "Độ nói",
"valence": "Tính tích cực",
"popularity": "Độ phổ biến",
"key": "Tông",
"duration": "Thời lượng (giây)",
"tempo": "Nhịp độ (BPM)",
"mode": "Chế độ",
"time_signature": "Chữ ký thời gian",
"short": "Ngắn",
"medium": "Trung bình",
"long": "Dài",
"min": "Tối thiểu",
"max": "Tối đa",
"target": "Mục tiêu",
"moderate": "Trung bình",
"deselect_all": "Bỏ chọn tất cả",
"select_all": "Chọn tất cả",
"are_you_sure": "Bạn có chắc chắn?",
"generating_playlist": "Đang tạo danh sách phát tùy chỉnh của bạn...",
"selected_count_tracks": "Đã chọn {count} bài hát",
"download_warning": "Tải xuống tất cả các bài hát một lần, sẽ vi phạm bản quyền âm nhạc và gây thiệt hại cho xã hội sáng tạo âm nhạc. Hy vọng bạn nhận thức được điều này. Hãy luôn tôn trọng và ủng hộ công sức của nghệ sĩ",
"download_ip_ban_warning": "Địa chỉ IP của bạn có thể bị chặn trên YouTube do yêu cầu tải xuống quá mức so với bình thường. Chặn IP có nghĩa là bạn không thể sử dụng YouTube (ngay cả khi bạn đã đăng nhập) ít nhất 2-3 tháng từ thiết bị IP đó. Và Spotube không chịu trách nhiệm nếu điều này xảy ra",
"by_clicking_accept_terms": "Bằng cách nhấp vào 'Chấp nhận', bạn đồng ý với các điều khoản sau:",
"download_agreement_1": "Tôi biết mình đang vi phạm bản quyền âm nhạc. Đó là không tốt.",
"download_agreement_2": "Tôi sẽ ủng hộ nghệ sĩ bất cứ nơi nào tôi có thể và tôi chỉ làm điều này vì tôi không có tiền để mua tác phẩm của họ",
"download_agreement_3": "Tôi hoàn toàn nhận thức được rằng địa chỉ IP của tôi có thể bị chặn trên YouTube và tôi không đổ lỗi cho Spotube hoặc chủ sở hữu/người đóng góp của nó về bất kỳ tai nạn nào do hành động này của tôi",
"decline": "Từ chối",
"accept": "Chấp nhận",
"details": "Chi tiết",
"youtube": "YouTube",
"channel": "Kênh",
"likes": "Thích",
"dislikes": "Không thích",
"views": "Lượt xem",
"streamUrl": "URL phát trực tiếp",
"stop": "Dừng",
"sort_newest": "Sắp xếp theo mới nhất",
"sort_oldest": "Sắp xếp theo cũ nhất",
"sleep_timer": "Hẹn giờ tắt",
"mins": "{minutes} Phút",
"hours": "{hours} Giờ",
"hour": "{hours} Giờ",
"custom_hours": "Giờ Tùy chỉnh",
"logs": "Nhật ký",
"developers": "Nhà phát triển",
"not_logged_in": "Bạn chưa đăng nhập",
"search_mode": "Chế độ tìm kiếm",
"audio_source": "Nguồn âm thanh",
"ok": "Ok",
"failed_to_encrypt": "Mã hóa không thành công",
"encryption_failed_warning": "Spotube không thành công trong việc mã hóa nhằm lưu trữ dữ liêu an toàn. vậy nên sẽ chuyển về lưu trữ không an toàn\nNếu bạn đang sử dụng Linux, đảm bảo rằng bạn có sử dụng dịch vụ bảo mật (gnome-keyring, kde-wallet, keepassxc, v.v.)",
"querying_info": "Đang truy vấn thông tin...",
"piped_api_down": "API Piped đang gặp sự cố",
"piped_down_error_instructions": "Phiên bản Piped {pipedInstance} hiện đang gặp sự cố\n\nThay đổi phiên bản hoặc thay đổi 'Loại API' thành API YouTube official\n\nKhởi động lai ứng dụng sau khi thay đổi.",
"you_are_offline": "Bạn đang ngoại tuyến",
"connection_restored": "Kết nối internet của bạn đã được khôi phục",
"use_system_title_bar": "Sử dụng thanh tiêu đề hệ thống",
"crunching_results": "Đang tìm kiếm...",
"search_to_get_results": "Chưa tìm kiếm",
"use_amoled_mode": "Chủ đề tối hoàn toàn",
"pitch_dark_theme": "Chế độ AMOLED",
"normalize_audio": "Bình thường hóa âm thanh",
"change_cover": "Thay đổi ảnh bìa",
"add_cover": "Thêm ảnh bìa",
"restore_defaults": "Khôi phục mặc định",
"download_music_codec": "Định dạng tải xuống",
"streaming_music_codec": "Định dạng nghe",
"login_with_lastfm": "Đăng nhập bằng tài khoản Last.fm",
"connect": "Liên kết",
"disconnect_lastfm": "Dừng liên kết Last.fm",
"disconnect": "Ngắt kết nối",
"username": "Tên người dùng",
"password": "Mật khẩu",
"login": "Đăng nhập",
"login_with_your_lastfm": "Đăng nhập bằng tài khoản Last.fm của bạn",
"scrobble_to_lastfm": "Scrobble đến Last.fm",
"go_to_album": "Đi đến Album",
"discord_rich_presence": "Hiển thị trạng thái Discord",
"browse_all": "Duyệt tất cả",
"genres": "Thể loại",
"explore_genres": "Khám phá Thể loại"
}

View File

@ -9,6 +9,8 @@
/// energywave@github, ncvescera@github, OpenCode@github => Italian
/// mdksec@github => Turkish
/// Stephan-P@github, SecularSteve@github => Dutch
/// doannc2212@github => Vietnamese
/// sappho192@github => Korean
import 'package:flutter/material.dart';
class L10n {
@ -25,6 +27,7 @@ class L10n {
const Locale('hi', 'IN'),
const Locale('it', 'IT'),
const Locale('ja', 'JP'),
const Locale('ko', 'KR'),
const Locale('nl', 'NL'),
const Locale('pl', 'PL'),
const Locale('pt', 'PT'),
@ -32,5 +35,6 @@ class L10n {
const Locale('uk', 'UA'),
const Locale('tr', 'TR'),
const Locale('zh', 'CN'),
const Locale('vi', 'VN'),
];
}

View File

@ -29,6 +29,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/cli/cli.dart';
import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:system_theme/system_theme.dart';
@ -68,6 +69,9 @@ Future<void> main(List<String> rawArgs) async {
DiscordRPC.initialize();
}
await KVStoreService.initialize();
KVStoreService.doneGettingStarted = false;
final hiveCacheDir =
kIsWeb ? null : (await getApplicationSupportDirectory()).path;
@ -184,6 +188,7 @@ class SpotubeState extends ConsumerState<Spotube> {
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
final paletteColor =
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
final router = ref.watch(routerProvider);
useDisableBatteryOptimizations();
useInitSysTray(ref);

View File

@ -15,9 +15,9 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumPage extends HookConsumerWidget {
final AlbumSimple album;
const AlbumPage({
Key? key,
super.key,
required this.album,
}) : super(key: key);
});
@override
Widget build(BuildContext context, ref) {
@ -47,6 +47,7 @@ class AlbumPage extends HookConsumerWidget {
image: TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.albumArt,
index: 0,
),
title: album.name!,
description:
@ -69,8 +70,9 @@ class AlbumPage extends HookConsumerWidget {
shareUrl: album.externalUrls!.spotify!,
isLiked: isLiked,
onHeart: albumIsSaved.hasData
? () {
toggleAlbumLike.mutate(isLiked);
? () async {
await toggleAlbumLike.mutate(isLiked);
return null;
}
: null,
child: const TrackView(),

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/getting_started/sections/greeting.dart';
import 'package:spotube/pages/getting_started/sections/playback.dart';
import 'package:spotube/pages/getting_started/sections/region.dart';
import 'package:spotube/pages/getting_started/sections/support.dart';
class GettingStarting extends HookConsumerWidget {
const GettingStarting({super.key});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme) = Theme.of(context);
final pageController = usePageController();
final onNext = useCallback(() {
pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}, [pageController]);
final onPrevious = useCallback(() {
pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}, [pageController]);
return Scaffold(
appBar: PageWindowTitleBar(
backgroundColor: Colors.transparent,
actions: [
ListenableBuilder(
listenable: pageController,
builder: (context, _) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: pageController.hasClients &&
(pageController.page == 0 || pageController.page == 3)
? const SizedBox()
: TextButton(
onPressed: () {
pageController.animateToPage(
3,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
},
child: Text(
context.l10n.skip_this_nonsense,
style: TextStyle(
decoration: TextDecoration.underline,
decorationColor: colorScheme.primary,
),
),
),
);
},
),
],
),
extendBodyBehindAppBar: true,
body: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: Assets.bengaliPatternsBg.provider(),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
colorScheme.background.withOpacity(0.2),
BlendMode.srcOver,
),
),
),
child: PageView(
controller: pageController,
children: [
GettingStartedPageGreetingSection(onNext: onNext),
GettingStartedPageLanguageRegionSection(onNext: onNext),
GettingStartedPagePlaybackSection(
onNext: onNext,
onPrevious: onPrevious,
),
const GettingStartedScreenSupportSection(),
],
),
),
);
}
}

View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class GettingStartedPageGreetingSection extends HookConsumerWidget {
final VoidCallback onNext;
const GettingStartedPageGreetingSection({super.key, required this.onNext});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
return Center(
child: BlurCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Assets.spotubeLogoPng.image(height: 200),
const Gap(24),
Text(
"Spotube",
style:
textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const Gap(4),
Text(
kIsMobile
? context.l10n.freedom_of_music_palm
: context.l10n.freedom_of_music,
textAlign: TextAlign.center,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w300,
fontStyle: FontStyle.italic,
),
),
const Gap(84),
Directionality(
textDirection: TextDirection.rtl,
child: FilledButton.icon(
onPressed: onNext,
icon: const Icon(SpotubeIcons.angleRight),
label: Text(context.l10n.get_started),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
final audioSourceToIconMap = {
AudioSource.youtube: const Icon(
SpotubeIcons.youtube,
color: Colors.red,
size: 30,
),
AudioSource.piped: const Icon(SpotubeIcons.piped, size: 30),
AudioSource.jiosaavn: Assets.jiosaavn.image(width: 48, height: 48),
};
class GettingStartedPagePlaybackSection extends HookConsumerWidget {
final VoidCallback onNext;
final VoidCallback onPrevious;
const GettingStartedPagePlaybackSection({
super.key,
required this.onNext,
required this.onPrevious,
});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme, :dividerColor) =
Theme.of(context);
final preferences = ref.watch(userPreferencesProvider);
final preferencesNotifier = ref.read(userPreferencesProvider.notifier);
final audioSourceToDescription = useMemoized(
() => {
AudioSource.youtube: "${context.l10n.youtube_source_description}\n"
"${context.l10n.highest_quality("148kbps mp4, 128kbps opus")}",
AudioSource.piped: context.l10n.piped_source_description,
AudioSource.jiosaavn:
"${context.l10n.jiosaavn_source_description}\n"
"${context.l10n.highest_quality("320kbps mp")}",
},
[]);
return Center(
child: BlurCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(SpotubeIcons.album, size: 16),
const Gap(8),
Text(context.l10n.playback, style: textTheme.titleMedium),
],
),
const Gap(16),
ListTile(
title: Text(
context.l10n.select_audio_source,
style: textTheme.titleMedium,
),
),
const Gap(16),
ToggleButtons(
isSelected: [
for (final source in AudioSource.values)
preferences.audioSource == source,
],
onPressed: (index) {
preferencesNotifier.setAudioSource(AudioSource.values[index]);
},
borderRadius: BorderRadius.circular(8),
children: [
for (final source in AudioSource.values)
SizedBox.square(
dimension: 84,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
audioSourceToIconMap[source]!,
const Gap(8),
Text(
source.name,
style: textTheme.bodySmall!.copyWith(
color: preferences.audioSource == source
? colorScheme.primary
: null,
),
),
],
),
),
],
),
ListTile(
title: Align(
alignment: switch (preferences.audioSource) {
AudioSource.youtube => Alignment.centerLeft,
AudioSource.piped => Alignment.center,
AudioSource.jiosaavn => Alignment.centerRight,
},
child: Text(
audioSourceToDescription[preferences.audioSource]!,
style: textTheme.bodySmall?.copyWith(
color: dividerColor,
),
),
),
),
const Gap(16),
ListTile(
title: Text(context.l10n.endless_playback),
subtitle: Text(
context.l10n.endless_playback_description,
style: textTheme.bodySmall?.copyWith(
color: dividerColor,
),
),
onTap: () {
preferencesNotifier
.setEndlessPlayback(!preferences.endlessPlayback);
},
trailing: Switch(
value: preferences.endlessPlayback,
onChanged: (value) {
preferencesNotifier.setEndlessPlayback(value);
},
),
),
const Gap(34),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FilledButton.icon(
icon: const Icon(SpotubeIcons.angleLeft),
label: Text(context.l10n.previous),
onPressed: onPrevious,
),
Directionality(
textDirection: TextDirection.rtl,
child: FilledButton.icon(
icon: const Icon(SpotubeIcons.angleRight),
label: Text(context.l10n.next),
onPressed: onNext,
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/language_codes.dart';
import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class GettingStartedPageLanguageRegionSection extends HookConsumerWidget {
final void Function() onNext;
const GettingStartedPageLanguageRegionSection(
{super.key, required this.onNext});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :dividerColor) = Theme.of(context);
final preferences = ref.watch(userPreferencesProvider);
return SafeArea(
child: Center(
child: BlurCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(
SpotubeIcons.language,
size: 16,
),
const SizedBox(width: 8),
Text(
context.l10n.language_region,
style: textTheme.titleMedium,
),
],
),
const Gap(48),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.choose_your_region,
style: textTheme.titleSmall,
),
Text(
context.l10n.choose_your_region_description,
style: textTheme.bodySmall?.copyWith(
color: dividerColor,
),
),
const Gap(16),
DropdownMenu(
initialSelection: preferences.recommendationMarket,
onSelected: (value) {
if (value == null) return;
ref
.read(userPreferencesProvider.notifier)
.setRecommendationMarket(value);
},
hintText: preferences.recommendationMarket.name,
label: Text(context.l10n.market_place_region),
inputDecorationTheme:
const InputDecorationTheme(isDense: true),
dropdownMenuEntries: [
for (final market in spotifyMarkets)
DropdownMenuEntry(
value: market.$1,
label: market.$2,
),
],
),
const Gap(36),
Text(
context.l10n.choose_your_language,
style: textTheme.titleSmall,
),
const Gap(16),
DropdownMenu(
initialSelection: preferences.locale,
onSelected: (locale) {
if (locale == null) return;
ref
.read(userPreferencesProvider.notifier)
.setLocale(locale);
},
hintText: context.l10n.system_default,
label: Text(context.l10n.language),
inputDecorationTheme:
const InputDecorationTheme(isDense: true),
dropdownMenuEntries: [
DropdownMenuEntry(
value: const Locale("system", "system"),
label: context.l10n.system_default,
),
for (final locale in L10n.all)
DropdownMenuEntry(
value: locale,
label: LanguageLocals.getDisplayLanguage(
locale.languageCode)
.toString(),
),
],
),
],
),
const Gap(48),
Align(
alignment: Alignment.centerRight,
child: Directionality(
textDirection: TextDirection.rtl,
child: FilledButton.icon(
icon: const Icon(SpotubeIcons.angleRight),
label: Text(context.l10n.next),
onPressed: onNext,
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/getting_started/blur_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:url_launcher/url_launcher_string.dart';
class GettingStartedScreenSupportSection extends HookConsumerWidget {
const GettingStartedScreenSupportSection({super.key});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
BlurCard(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(SpotubeIcons.heartFilled, color: Colors.pink),
const SizedBox(width: 8),
Text(
context.l10n.help_project_grow,
style:
textTheme.titleMedium?.copyWith(color: Colors.pink),
),
],
),
const Gap(16),
Text(context.l10n.help_project_grow_description),
const Gap(16),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
icon: const Icon(SpotubeIcons.github),
label: Text(context.l10n.contribute_on_github),
style: FilledButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () async {
await launchUrlString(
"https://github.com/KRTirtho/spotube",
mode: LaunchMode.externalApplication,
);
},
),
const Gap(16),
FilledButton.icon(
icon: const Icon(SpotubeIcons.openCollective),
label: Text(context.l10n.donate_on_open_collective),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xff4cb7f6),
foregroundColor: Colors.white,
),
onPressed: () async {
await launchUrlString(
"https://opencollective.com/spotube",
mode: LaunchMode.externalApplication,
);
},
),
],
),
],
),
),
const Gap(48),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 250),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
colors: [
colorScheme.primary,
colorScheme.secondary,
],
),
),
child: TextButton.icon(
icon: const Icon(SpotubeIcons.anonymous),
label: Text(context.l10n.browse_anonymously),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
),
onPressed: () {
KVStoreService.doneGettingStarted = true;
context.go("/");
},
),
),
const Gap(16),
FilledButton.icon(
icon: const Icon(SpotubeIcons.spotify),
label: Text(context.l10n.connect_with_spotify),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xff1db954),
foregroundColor: Colors.white,
),
onPressed: () {
KVStoreService.doneGettingStarted = true;
context.push("/login");
},
),
],
),
),
],
),
);
}
}

View File

@ -18,8 +18,7 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
class GenrePlaylistsPage extends HookConsumerWidget {
final Category category;
const GenrePlaylistsPage({Key? key, required this.category})
: super(key: key);
const GenrePlaylistsPage({super.key, required this.category});
@override
Widget build(BuildContext context, ref) {
@ -51,35 +50,29 @@ class GenrePlaylistsPage extends HookConsumerWidget {
)
: null,
extendBodyBehindAppBar: true,
body: CustomScrollView(
body: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(category.icons!.first.url!),
alignment: Alignment.topCenter,
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.5),
BlendMode.darken,
),
repeat: ImageRepeat.noRepeat,
matchTextDirection: true,
),
),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
automaticallyImplyLeading: DesktopTools.platform.isMobile,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
pinned: true,
floating: false,
title: const Text(""),
backgroundColor: Colors.brown.withOpacity(0.7),
backgroundColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
background: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(
category.icons!.first.url!,
),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: const ColoredBox(color: Colors.transparent),
),
),
centerTitle: DesktopTools.platform.isDesktop,
title: Text(
category.name!,
@ -169,6 +162,7 @@ class GenrePlaylistsPage extends HookConsumerWidget {
const SliverGap(20),
],
),
),
);
}
}

View File

@ -15,7 +15,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/services/queries/queries.dart';
class GenrePage extends HookConsumerWidget {
const GenrePage({Key? key}) : super(key: key);
const GenrePage({super.key});
@override
Widget build(BuildContext context, ref) {

View File

@ -1,6 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';

View File

@ -27,7 +27,7 @@ class LibraryPage extends HookConsumerWidget {
leading: ThemedButtonsTabBar(
tabs: [
Tab(text: " ${context.l10n.playlists} "),
Tab(text: " ${context.l10n.tracks} "),
Tab(text: " ${context.l10n.local_tracks} "),
Tab(
child: Badge(
isLabelVisible: downloadingCount > 0,

View File

@ -2,8 +2,11 @@ import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
@ -45,6 +48,8 @@ class PlaylistPage extends HookConsumerWidget {
],
);
final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!);
return InheritedTrackView(
collectionId: playlist.id!,
image: TypeConversionUtils.image_X_UrlString(
@ -72,9 +77,20 @@ class PlaylistPage extends HookConsumerWidget {
shareUrl: playlist.externalUrls?.spotify ?? "",
onHeart: () async {
if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) {
return;
return false;
}
final confirmed = isUserPlaylist
? await showPromptDialog(
context: context,
title: context.l10n.delete_playlist,
message: context.l10n.delete_playlist_confirmation,
)
: true;
if (confirmed) {
await togglePlaylistLike.mutate(isLikedQuery.data!);
return isUserPlaylist;
}
return null;
},
child: const TrackView(),
);

View File

@ -162,7 +162,15 @@ class RootApp extends HookConsumerWidget {
}
}
return Scaffold(
return WillPopScope(
onWillPop: () async {
if (rootPaths[location] != 0) {
onSelectIndexChanged(0);
return false;
}
return true;
},
child: Scaffold(
body: Sidebar(
selectedIndex: rootPaths[location],
onSelectedIndexChanged: onSelectIndexChanged,
@ -195,6 +203,7 @@ class RootApp extends HookConsumerWidget {
),
],
),
),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
@ -10,7 +11,11 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
class SettingsAppearanceSection extends HookConsumerWidget {
const SettingsAppearanceSection({Key? key}) : super(key: key);
final bool isGettingStarted;
const SettingsAppearanceSection({
Key? key,
this.isGettingStarted = false,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
@ -24,9 +29,7 @@ class SettingsAppearanceSection extends HookConsumerWidget {
});
}, []);
return SectionCardWithHeading(
heading: context.l10n.appearance,
children: [
final children = [
AdaptiveSelectTile<LayoutMode>(
secondary: const Icon(SpotubeIcons.dashboard),
title: Text(context.l10n.layout_mode),
@ -104,7 +107,23 @@ class SettingsAppearanceSection extends HookConsumerWidget {
value: preferences.albumColorSync,
onChanged: preferencesNotifier.setAlbumColorSync,
),
];
if (isGettingStarted) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final child in children) ...[
child,
const Gap(16),
],
],
);
}
return SectionCardWithHeading(
heading: context.l10n.appearance,
children: children,
);
}
}

View File

@ -12,7 +12,7 @@ import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class SettingsLanguageRegionSection extends HookConsumerWidget {
const SettingsLanguageRegionSection({Key? key}) : super(key: key);
const SettingsLanguageRegionSection({super.key});
@override
Widget build(BuildContext context, ref) {

View File

@ -144,8 +144,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}
} catch (e, stackTrace) {
// Removing tracks that were not found to avoid queue interruption
// TODO: Add a flag to enable/disable skip not found tracks
if (e is TrackNotFoundException) {
if (e is TrackNotFoundError) {
final oldTrack =
mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull;
await removeTrack(oldTrack!.id!);

View File

@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:catcher_2/catcher_2.dart';
import 'package:collection/collection.dart';
import 'package:media_kit/media_kit.dart';
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:audio_session/audio_session.dart';
// ignore: implementation_imports
import 'package:spotube/services/audio_player/playback_state.dart';
@ -14,6 +17,13 @@ class MkPlayerWithState extends Player {
final StreamController<bool> _shuffleStream;
final StreamController<PlaylistMode> _loopModeStream;
static const String EXTRA_PACKAGE_NAME = "android.media.extra.PACKAGE_NAME";
static const String EXTRA_AUDIO_SESSION = "android.media.extra.AUDIO_SESSION";
static const String ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION =
"android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION";
static const String ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION =
"android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION";
late final List<StreamSubscription> _subscriptions;
bool _shuffled;
@ -21,6 +31,9 @@ class MkPlayerWithState extends Player {
Playlist? _playlist;
List<Media>? _tempMedias;
int _androidAudioSessionId = 0;
String _packageName = "";
AndroidAudioManager? _androidAudioManager;
MkPlayerWithState({super.configuration})
: _playerStateStream = StreamController.broadcast(),
@ -64,6 +77,34 @@ class MkPlayerWithState extends Player {
Catcher2.reportCheckedError('[MediaKitError] \n$event', null);
}),
];
PackageInfo.fromPlatform().then((packageInfo) {
_packageName = packageInfo.packageName;
});
if (DesktopTools.platform.isAndroid) {
_androidAudioManager = AndroidAudioManager();
AudioSession.instance.then((s) async {
_androidAudioSessionId =
await _androidAudioManager!.generateAudioSessionId();
notifyAudioSessionUpdate(true);
nativePlayer.setProperty(
"audiotrack-session-id", _androidAudioSessionId.toString());
nativePlayer.setProperty("ao", "audiotrack,opensles,");
});
}
}
Future<void> notifyAudioSessionUpdate(bool active) async {
if (DesktopTools.platform.isAndroid) {
sendBroadcast(BroadcastMessage(
name: active
? ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
: ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION,
data: {
EXTRA_AUDIO_SESSION: _androidAudioSessionId,
EXTRA_PACKAGE_NAME: _packageName
}));
}
}
bool get shuffled => _shuffled;
@ -140,10 +181,11 @@ class MkPlayerWithState extends Player {
}
@override
Future<void> dispose() {
Future<void> dispose() async {
for (var element in _subscriptions) {
element.cancel();
}
await notifyAudioSessionUpdate(false);
return super.dispose();
}

View File

@ -175,59 +175,4 @@ class CustomSpotifyEndpoints {
);
return SpotifyFriends.fromJson(jsonDecode(res.body));
}
Future<Artist> artist({required String id}) async {
final pathQuery = "$_baseUrl/artists/$id";
final res = await _client.get(
Uri.parse(pathQuery),
headers: {
"content-type": "application/json",
if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken",
"accept": "application/json",
},
);
final data = jsonDecode(res.body);
return Artist.fromJson(_purifyArtistResponse(data));
}
Future<List<Artist>> relatedArtists({required String id}) async {
final pathQuery = "$_baseUrl/artists/$id/related-artists";
final res = await _client.get(
Uri.parse(pathQuery),
headers: {
"content-type": "application/json",
if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken",
"accept": "application/json",
},
);
final data = jsonDecode(res.body);
return List.castFrom<dynamic, Artist>(
data["artists"]
.map((artist) => Artist.fromJson(_purifyArtistResponse(artist)))
.toList(),
);
}
Map<String, dynamic> _purifyArtistResponse(Map<String, dynamic> data) {
if (data["popularity"] != null) {
data["popularity"] = data["popularity"].toInt();
}
if (data["followers"]?["total"] != null) {
data["followers"]["total"] = data["followers"]["total"].toInt();
}
if (data["images"] != null) {
data["images"] = data["images"].map((e) {
e["height"] = e["height"].toInt();
e["width"] = e["width"].toInt();
return e;
}).toList();
}
return data;
}
}

View File

@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
abstract class KVStoreService {
static SharedPreferences? _sharedPreferences;
static SharedPreferences get sharedPreferences => _sharedPreferences!;
static Future<void> initialize() async {
_sharedPreferences = await SharedPreferences.getInstance();
}
static bool get doneGettingStarted =>
sharedPreferences.getBool('doneGettingStarted') ?? false;
static set doneGettingStarted(bool value) =>
sharedPreferences.setBool('doneGettingStarted', value);
}

View File

@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/wikipedia/wikipedia.dart';
import 'package:wikipedia_api/wikipedia_api.dart';
@ -16,10 +15,9 @@ class ArtistQueries {
WidgetRef ref,
String artist,
) {
final customSpotify = ref.watch(customSpotifyEndpointProvider);
return useSpotifyQuery<Artist, dynamic>(
"artist-profile/$artist",
(spotify) => customSpotify.artist(id: artist),
(spotify) => spotify.artists.get(artist),
ref: ref,
);
}
@ -127,11 +125,10 @@ class ArtistQueries {
WidgetRef ref,
String artist,
) {
final customSpotify = ref.watch(customSpotifyEndpointProvider);
return useSpotifyQuery<Iterable<Artist>, dynamic>(
"artist-related-artist-query/$artist",
(spotify) {
return customSpotify.relatedArtists(id: artist);
return spotify.artists.relatedArtists(artist);
},
ref: ref,
);

View File

@ -0,0 +1,19 @@
part of './song_link.dart';
@freezed
class SongLink with _$SongLink {
const factory SongLink({
required String displayName,
required String linkId,
required String platform,
required bool show,
required String? uniqueId,
required String? country,
required String? url,
required String? nativeAppUriMobile,
required String? nativeAppUriDesktop,
}) = _SongLink;
factory SongLink.fromJson(Map<String, dynamic> json) =>
_$SongLinkFromJson(json);
}

View File

@ -0,0 +1,54 @@
library song_link;
import 'dart:convert';
import 'package:catcher_2/catcher_2.dart';
import 'package:dio/dio.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:html/parser.dart';
part 'model.dart';
part 'song_link.freezed.dart';
part 'song_link.g.dart';
abstract class SongLinkService {
static final dio = Dio();
static Future<List<SongLink>> links(String spotifyId) async {
try {
final res = await dio.get(
"https://song.link/s/$spotifyId",
options: Options(
headers: {
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
},
responseType: ResponseType.plain,
),
);
final document = parse(res.data);
final script = document.getElementById("__NEXT_DATA__")?.text;
if (script == null) {
return <SongLink>[];
}
final pageProps = jsonDecode(script) as Map<String, dynamic>;
final songLinks = pageProps["props"]?["pageProps"]?["pageData"]
?["sections"]
?.firstWhere(
(section) => section?["sectionId"] == "section|auto|links|listen",
)?["links"] as List?;
return songLinks?.map((link) => SongLink.fromJson(link)).toList() ??
<SongLink>[];
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
return <SongLink>[];
}
}
}

View File

@ -0,0 +1,320 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'song_link.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
SongLink _$SongLinkFromJson(Map<String, dynamic> json) {
return _SongLink.fromJson(json);
}
/// @nodoc
mixin _$SongLink {
String get displayName => throw _privateConstructorUsedError;
String get linkId => throw _privateConstructorUsedError;
String get platform => throw _privateConstructorUsedError;
bool get show => throw _privateConstructorUsedError;
String? get uniqueId => throw _privateConstructorUsedError;
String? get country => throw _privateConstructorUsedError;
String? get url => throw _privateConstructorUsedError;
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SongLinkCopyWith<SongLink> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SongLinkCopyWith<$Res> {
factory $SongLinkCopyWith(SongLink value, $Res Function(SongLink) then) =
_$SongLinkCopyWithImpl<$Res, SongLink>;
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
implements $SongLinkCopyWith<$Res> {
_$SongLinkCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_value.copyWith(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SongLinkImplCopyWith<$Res>
implements $SongLinkCopyWith<$Res> {
factory _$$SongLinkImplCopyWith(
_$SongLinkImpl value, $Res Function(_$SongLinkImpl) then) =
__$$SongLinkImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class __$$SongLinkImplCopyWithImpl<$Res>
extends _$SongLinkCopyWithImpl<$Res, _$SongLinkImpl>
implements _$$SongLinkImplCopyWith<$Res> {
__$$SongLinkImplCopyWithImpl(
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_$SongLinkImpl(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SongLinkImpl implements _SongLink {
const _$SongLinkImpl(
{required this.displayName,
required this.linkId,
required this.platform,
required this.show,
required this.uniqueId,
required this.country,
required this.url,
required this.nativeAppUriMobile,
required this.nativeAppUriDesktop});
factory _$SongLinkImpl.fromJson(Map<String, dynamic> json) =>
_$$SongLinkImplFromJson(json);
@override
final String displayName;
@override
final String linkId;
@override
final String platform;
@override
final bool show;
@override
final String? uniqueId;
@override
final String? country;
@override
final String? url;
@override
final String? nativeAppUriMobile;
@override
final String? nativeAppUriDesktop;
@override
String toString() {
return 'SongLink(displayName: $displayName, linkId: $linkId, platform: $platform, show: $show, uniqueId: $uniqueId, country: $country, url: $url, nativeAppUriMobile: $nativeAppUriMobile, nativeAppUriDesktop: $nativeAppUriDesktop)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SongLinkImpl &&
(identical(other.displayName, displayName) ||
other.displayName == displayName) &&
(identical(other.linkId, linkId) || other.linkId == linkId) &&
(identical(other.platform, platform) ||
other.platform == platform) &&
(identical(other.show, show) || other.show == show) &&
(identical(other.uniqueId, uniqueId) ||
other.uniqueId == uniqueId) &&
(identical(other.country, country) || other.country == country) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.nativeAppUriMobile, nativeAppUriMobile) ||
other.nativeAppUriMobile == nativeAppUriMobile) &&
(identical(other.nativeAppUriDesktop, nativeAppUriDesktop) ||
other.nativeAppUriDesktop == nativeAppUriDesktop));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
__$$SongLinkImplCopyWithImpl<_$SongLinkImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SongLinkImplToJson(
this,
);
}
}
abstract class _SongLink implements SongLink {
const factory _SongLink(
{required final String displayName,
required final String linkId,
required final String platform,
required final bool show,
required final String? uniqueId,
required final String? country,
required final String? url,
required final String? nativeAppUriMobile,
required final String? nativeAppUriDesktop}) = _$SongLinkImpl;
factory _SongLink.fromJson(Map<String, dynamic> json) =
_$SongLinkImpl.fromJson;
@override
String get displayName;
@override
String get linkId;
@override
String get platform;
@override
bool get show;
@override
String? get uniqueId;
@override
String? get country;
@override
String? get url;
@override
String? get nativeAppUriMobile;
@override
String? get nativeAppUriDesktop;
@override
@JsonKey(ignore: true)
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'song_link.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SongLinkImpl _$$SongLinkImplFromJson(Map<String, dynamic> json) =>
_$SongLinkImpl(
displayName: json['displayName'] as String,
linkId: json['linkId'] as String,
platform: json['platform'] as String,
show: json['show'] as bool,
uniqueId: json['uniqueId'] as String?,
country: json['country'] as String?,
url: json['url'] as String?,
nativeAppUriMobile: json['nativeAppUriMobile'] as String?,
nativeAppUriDesktop: json['nativeAppUriDesktop'] as String?,
);
Map<String, dynamic> _$$SongLinkImplToJson(_$SongLinkImpl instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'linkId': instance.linkId,
'platform': instance.platform,
'show': instance.show,
'uniqueId': instance.uniqueId,
'country': instance.country,
'url': instance.url,
'nativeAppUriMobile': instance.nativeAppUriMobile,
'nativeAppUriDesktop': instance.nativeAppUriDesktop,
};

View File

@ -1,7 +1,12 @@
import 'package:spotify/spotify.dart';
class TrackNotFoundException implements Exception {
factory TrackNotFoundException(Track track) {
throw Exception("Failed to find any results for ${track.name}");
class TrackNotFoundError extends Error {
final Track track;
TrackNotFoundError(this.track);
@override
String toString() {
return '[TrackNotFoundError] ${track.name} - ${track.artists?.map((e) => e.name).join(", ")}';
}
}

View File

@ -1,15 +1,21 @@
import 'dart:io';
import 'package:http/http.dart';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
abstract class SourcedTrack extends Track {
final SourceMap source;
@ -101,9 +107,8 @@ abstract class SourcedTrack extends Track {
required Track track,
required Ref ref,
}) async {
try {
final preferences = ref.read(userPreferencesProvider);
try {
return switch (preferences.audioSource) {
AudioSource.piped =>
await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref),
@ -112,8 +117,35 @@ abstract class SourcedTrack extends Track {
AudioSource.jiosaavn =>
await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref),
};
} on TrackNotFoundError catch (_) {
return switch (preferences.audioSource) {
AudioSource.piped ||
AudioSource.youtube =>
await JioSaavnSourcedTrack.fetchFromTrack(
track: track,
ref: ref,
weakMatch: true,
),
AudioSource.jiosaavn =>
await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref),
};
} on HttpClientClosedException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref);
} catch (e) {
return YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref);
if (e is DioException || e is ClientException || e is SocketException) {
if (preferences.audioSource == AudioSource.jiosaavn) {
return await JioSaavnSourcedTrack.fetchFromTrack(
track: track,
ref: ref,
weakMatch: true,
);
}
return await JioSaavnSourcedTrack.fetchFromTrack(
track: track,
ref: ref,
);
}
rethrow;
}
}

View File

@ -37,15 +37,17 @@ class JioSaavnSourcedTrack extends SourcedTrack {
static Future<SourcedTrack> fetchFromTrack({
required Track track,
required Ref ref,
bool weakMatch = false,
}) async {
final cachedSource = await SourceMatch.box.get(track.id);
if (cachedSource == null ||
cachedSource.sourceType != SourceType.jiosaavn) {
final siblings = await fetchSiblings(ref: ref, track: track);
final siblings =
await fetchSiblings(ref: ref, track: track, weakMatch: weakMatch);
if (siblings.isEmpty) {
throw TrackNotFoundException(track);
throw TrackNotFoundError(track);
}
await SourceMatch.box.put(
@ -119,6 +121,7 @@ class JioSaavnSourcedTrack extends SourcedTrack {
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required Ref ref,
bool weakMatch = false,
}) async {
final query = SourcedTrack.getSearchTerm(track);
@ -126,9 +129,12 @@ class JioSaavnSourcedTrack extends SourcedTrack {
await jiosaavnClient.search.songs(query, limit: 20);
final trackArtistNames = track.artists?.map((ar) => ar.name).toList();
return results
final matchedResults = results
.where(
(s) {
s.name?.unescapeHtml().contains(track.name!) ?? false;
final sameName = s.name?.unescapeHtml() == track.name;
final artistNames = [
s.primaryArtists,
@ -139,12 +145,27 @@ class JioSaavnSourcedTrack extends SourcedTrack {
(artist) =>
trackArtistNames?.any((ar) => artist == ar) ?? false,
);
if (weakMatch) {
final containsName =
s.name?.unescapeHtml().contains(track.name!) ?? false;
final containsPrimaryArtist = s.primaryArtists
.unescapeHtml()
.contains(trackArtistNames?.first ?? "");
return containsName && containsPrimaryArtist;
}
return sameName && sameArtists;
},
)
.map(toSiblingType)
.toList();
if (weakMatch && matchedResults.isEmpty) {
return results.map(toSiblingType).toList();
}
return matchedResults;
}
@override

View File

@ -55,7 +55,7 @@ class PipedSourcedTrack extends SourcedTrack {
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, track: track);
if (siblings.isEmpty) {
throw TrackNotFoundException(track);
throw TrackNotFoundError(track);
}
await SourceMatch.box.put(
@ -157,16 +157,20 @@ class PipedSourcedTrack extends SourcedTrack {
}) async {
final pipedClient = ref.read(pipedProvider);
final preference = ref.read(userPreferencesProvider);
final query = SourcedTrack.getSearchTerm(track);
final PipedSearchResult(items: searchResults) = await pipedClient.search(
"$query - Topic",
query,
preference.searchMode == SearchMode.youtube
? PipedFilter.video
? PipedFilter.videos
: PipedFilter.musicSongs,
);
final isYouTubeMusic = preference.searchMode == SearchMode.youtubeMusic;
// when falling back to piped API make sure to use the YouTube mode
final isYouTubeMusic = preference.audioSource != AudioSource.piped
? false
: preference.searchMode == SearchMode.youtubeMusic;
if (isYouTubeMusic) {
final artists = (track.artists ?? [])

View File

@ -1,7 +1,9 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/services/song_link/song_link.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
@ -48,7 +50,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
final siblings = await fetchSiblings(ref: ref, track: track);
if (siblings.isEmpty) {
throw TrackNotFoundException(track);
throw TrackNotFoundError(track);
}
await SourceMatch.box.put(
@ -70,8 +72,13 @@ class YoutubeSourcedTrack extends SourcedTrack {
);
}
final item = await youtubeClient.videos.get(cachedSource.sourceId);
final manifest = await youtubeClient.videos.streamsClient.getManifest(
final manifest = await youtubeClient.videos.streamsClient
.getManifest(
cachedSource.sourceId,
)
.timeout(
const Duration(seconds: 5),
onTimeout: () => throw ClientException("Timeout"),
);
return YoutubeSourcedTrack(
ref: ref,
@ -125,7 +132,10 @@ class YoutubeSourcedTrack extends SourcedTrack {
SourceMap? sourceMap;
if (index == 0) {
final manifest =
await youtubeClient.videos.streamsClient.getManifest(item.id);
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
const Duration(seconds: 5),
onTimeout: () => throw ClientException("Timeout"),
);
sourceMap = toSourceMap(manifest);
}
@ -207,6 +217,20 @@ class YoutubeSourcedTrack extends SourcedTrack {
required Track track,
required Ref ref,
}) async {
final links = await SongLinkService.links(track.id!);
final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube");
if (ytLink?.url != null) {
return [
await toSiblingType(
0,
YoutubeVideoInfo.fromVideo(
await youtubeClient.videos.get(ytLink!.url!),
),
)
];
}
final query = SourcedTrack.getSearchTerm(track);
final searchResults = await youtubeClient.search.search(
@ -243,8 +267,12 @@ class YoutubeSourcedTrack extends SourcedTrack {
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
final manifest =
await youtubeClient.videos.streamsClient.getManifest(newSourceInfo.id);
final manifest = await youtubeClient.videos.streamsClient
.getManifest(newSourceInfo.id)
.timeout(
const Duration(seconds: 5),
onTimeout: () => throw ClientException("Timeout"),
);
await SourceMatch.box.put(
id!,

View File

@ -119,7 +119,9 @@ abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
Future<void> _load() async {
final json = await box.get(cacheKey);
if (json != null) {
if (json != null ||
(json is Map && json.entries.isNotEmpty) ||
(json is List && json.isNotEmpty)) {
state = await fromJson(castNestedJson(json));
}
}

View File

@ -53,9 +53,9 @@ abstract class ServiceUtils {
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
.toLowerCase()
.replaceAll(RegExp(" *\\[[^\\]]*]"), '')
.replaceAll(RegExp("feat.|ft."), '')
.replaceAll(RegExp("\\s+"), ' ')
.replaceAll(RegExp(r"\s*\[[^\]]*]"), ' ')
.replaceAll(RegExp(r"\sfeat\.|\sft\."), ' ')
.replaceAll(RegExp(r"\s+"), ' ')
.trim();
}
@ -292,24 +292,24 @@ abstract class ServiceUtils {
return List<T>.from(tracks)
..sort((a, b) {
switch (sortBy) {
case SortBy.album:
return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0;
case SortBy.artist:
return a.artists?.first.name
?.compareTo(b.artists?.first.name ?? "") ??
0;
case SortBy.ascending:
return a.name?.compareTo(b.name ?? "") ?? 0;
case SortBy.oldest:
final aDate = parseSpotifyAlbumDate(a.album);
final bDate = parseSpotifyAlbumDate(b.album);
return aDate.compareTo(bDate);
case SortBy.descending:
return b.name?.compareTo(a.name ?? "") ?? 0;
case SortBy.newest:
final aDate = parseSpotifyAlbumDate(a.album);
final bDate = parseSpotifyAlbumDate(b.album);
return bDate.compareTo(aDate);
case SortBy.descending:
return b.name?.compareTo(a.name ?? "") ?? 0;
case SortBy.oldest:
final aDate = parseSpotifyAlbumDate(a.album);
final bDate = parseSpotifyAlbumDate(b.album);
return aDate.compareTo(bDate);
case SortBy.duration:
return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0;
case SortBy.artist:
return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0;
case SortBy.album:
return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0;
default:
return 0;
}

View File

@ -6,3 +6,4 @@ Icon=/usr/share/icons/spotube/spotube-logo.png
Comment=A music streaming app combining the power of Spotify & YouTube
Terminal=false
Categories=Audio;Music;Player;AudioVideo;
MimeType=x-scheme-handler/spotify;

View File

@ -143,4 +143,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
COCOAPODS: 1.14.3
COCOAPODS: 1.15.2

View File

@ -582,6 +582,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = 88NVGSJ5N3;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Spotube;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";

View File

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links:
dependency: "direct main"
description:
@ -149,10 +157,10 @@ packages:
dependency: "direct main"
description:
name: audio_session
sha256: "8a2bc5e30520e18f3fb0e366793d78057fb64cd5287862c76af0c8771f2a52ad"
sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f"
url: "https://pub.dev"
source: hosted
version: "0.1.16"
version: "0.1.18"
auto_size_text:
dependency: "direct main"
description:
@ -470,10 +478,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "417e2a6f9d83ab396ec38ff4ea5da6c254da71e4db765ad737a42af6930140b7"
sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8"
url: "https://pub.dev"
source: hosted
version: "5.3.3"
version: "5.4.1"
disable_battery_optimization:
dependency: "direct main"
description:
@ -680,6 +688,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.9"
flutter_broadcasts:
dependency: "direct main"
description:
name: flutter_broadcasts
sha256: "9e76eeeda4a9faef63e3b08af5664c79219a2eabffc8ce95296858ea70423b1e"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
flutter_cache_manager:
dependency: "direct main"
description:
@ -818,10 +834,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04
sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7
url: "https://pub.dev"
source: hosted
version: "2.0.3"
version: "3.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
@ -839,10 +855,10 @@ packages:
dependency: "direct main"
description:
name: flutter_native_splash
sha256: "91004565166dbbc7a85e7e99b84124a287839830ca957cfe45004793fe6fe69f"
sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
version: "2.3.10"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@ -855,10 +871,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: e667e406a74d67715f1fa0bd941d9ded49aff72f3a9f4440a36aece4e8d457a7
sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.10"
flutter_rust_bridge:
dependency: transitive
description:
@ -1014,10 +1030,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a"
sha256: c5fa45fa502ee880839e3b2152d987c44abae26d064a2376d4aad434cf0f7b15
url: "https://pub.dev"
source: hosted
version: "13.0.1"
version: "12.1.3"
google_fonts:
dependency: "direct main"
description:
@ -1078,10 +1094,10 @@ packages:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "69dcb88acbc68c81fc27ec15a89a4e24b7812c83c13a6307a1a9366ada758541"
sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.10"
html:
dependency: "direct main"
description:
@ -1102,10 +1118,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525"
sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.2.1"
http_multi_server:
dependency: transitive
description:
@ -1291,10 +1307,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "3.0.0"
local_notifier:
dependency: transitive
description:
@ -1611,18 +1627,19 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "5.4.0"
version: "6.0.2"
piped_client:
dependency: "direct main"
description:
name: piped_client
sha256: "8b96e1f9d8533c1da7eff7fbbd4bf188256fc76a20900d378b52be09418ea771"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
path: "."
ref: HEAD
resolved-ref: "64631732eefe3d93889756dc2e4ff5c8523ed763"
url: "https://github.com/KRTirtho/piped_client.git"
source: git
version: "0.1.1"
platform:
dependency: transitive
description:
@ -1715,10 +1732,10 @@ packages:
dependency: transitive
description:
name: puppeteer
sha256: "59e723cc5b69537159a7c34efd645dc08a6a1ac4647d7d7823606802c0f93cdb"
sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8
url: "https://pub.dev"
source: hosted
version: "3.2.0"
version: "3.6.0"
quiver:
dependency: transitive
description:
@ -1731,10 +1748,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "494bf2cfb4df30000273d3052bdb1cc1de738574c6b678f0beb146ea56f5e208"
sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.5.0"
rxdart:
dependency: transitive
description:
@ -1945,10 +1962,10 @@ packages:
dependency: "direct main"
description:
name: spotify
sha256: e967c5e295792e9d38f4c5e9e60d7c2868ed9cb2a8fac2a67c75303f8395e374
sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de"
url: "https://pub.dev"
source: hosted
version: "0.12.0"
version: "0.13.3"
sqflite:
dependency: transitive
description:
@ -2261,6 +2278,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
web:
dependency: transitive
description:
name: web
sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
web_socket_channel:
dependency: transitive
description:
@ -2330,10 +2355,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84"
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.5.0"
yaml:
dependency: transitive
description:
@ -2351,5 +2376,5 @@ packages:
source: hosted
version: "2.0.2"
sdks:
dart: ">=3.2.0 <4.0.0"
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.13.0"

View File

@ -16,7 +16,7 @@ dependencies:
args: ^2.3.2
async: ^2.9.0
audio_service: ^0.18.9
audio_session: ^0.1.13
audio_session: ^0.1.18
auto_size_text: ^3.0.0
buttons_tabbar: ^1.3.6
cached_network_image: ^3.3.0
@ -27,7 +27,7 @@ dependencies:
dbus: ^0.7.8
device_info_plus: ^9.0.3
device_preview: ^1.1.0
dio: ^5.3.2
dio: ^5.4.1
disable_battery_optimization: ^1.1.0+1
duration: ^3.0.12
envied: ^0.3.0
@ -49,19 +49,19 @@ dependencies:
flutter_inappwebview: ^5.7.2+3
flutter_localizations:
sdk: flutter
flutter_native_splash: ^2.3.3
flutter_riverpod: ^2.4.3
flutter_native_splash: ^2.3.10
flutter_riverpod: ^2.4.10
flutter_secure_storage: ^9.0.0
flutter_svg: ^1.1.6
form_validator: ^2.1.1
fuzzywuzzy: ^1.1.6
go_router: ^13.0.1
go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869
google_fonts: ^6.1.0
hive: ^2.2.3
hive_flutter: ^1.1.0
hooks_riverpod: ^2.4.3
html: ^0.15.1
http: ^1.1.0
http: ^1.2.0
image_picker: ^1.0.4
intl: ^0.18.0
introduction_screen: ^3.0.2
@ -76,7 +76,9 @@ dependencies:
path: ^1.8.0
path_provider: ^2.0.8
permission_handler: ^11.0.1
piped_client: ^0.1.0
piped_client:
git:
url: https://github.com/KRTirtho/piped_client.git
popover: ^0.2.6+3
scrobblenaut:
git:
@ -87,7 +89,6 @@ dependencies:
shared_preferences: ^2.2.2
skeleton_text: ^3.0.1
smtc_windows: ^0.1.1
spotify: ^0.12.0
stroke_text: ^0.0.2
system_theme: ^2.1.0
titlebar_buttons: ^1.0.0
@ -122,7 +123,9 @@ dependencies:
app_links: ^3.5.0
win32_registry: ^1.1.2
flutter_sharing_intent: ^1.1.0
flutter_broadcasts: ^0.4.0
freezed_annotation: ^2.4.1
spotify: ^0.13.3
dev_dependencies:
build_runner: ^2.3.2
@ -130,7 +133,7 @@ dev_dependencies:
flutter_distributor: ^0.0.2
flutter_gen_runner: ^5.1.0+1
flutter_launcher_icons: ^0.13.1
flutter_lints: ^2.0.1
flutter_lints: ^3.0.1
flutter_test:
sdk: flutter
integration_test:
@ -142,7 +145,6 @@ dev_dependencies:
freezed: ^2.4.6
dependency_overrides:
http: ^1.1.0
system_tray: 2.0.2
flutter:
@ -151,6 +153,7 @@ flutter:
assets:
- assets/
- assets/tutorial/
- assets/logos/
- LICENSE
flutter_launcher_icons:

View File

@ -1,127 +1,574 @@
{
"ar": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"bn": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"ca": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"de": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"es": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"fa": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"fr": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"hi": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"it": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"ja": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"ne": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"nl": [
"sort_duration",
"audio_source",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"pl": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"pt": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"ru": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"tr": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"uk": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"vi": [
"sort_duration",
"friends",
"no_lyrics_available",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
],
"zh": [
"sort_duration",
"start_a_radio",
"how_to_start_radio",
"replace_queue_question",
"endless_playback"
"endless_playback",
"delete_playlist",
"delete_playlist_confirmation",
"local_tracks",
"song_link",
"skip_this_nonsense",
"freedom_of_music",
"freedom_of_music_palm",
"get_started",
"youtube_source_description",
"piped_source_description",
"jiosaavn_source_description",
"highest_quality",
"select_audio_source",
"endless_playback_description",
"choose_your_region",
"choose_your_region_description",
"choose_your_language",
"help_project_grow",
"help_project_grow_description",
"contribute_on_github",
"donate_on_open_collective",
"browse_anonymously"
]
}

View File

@ -29,15 +29,83 @@
<title>spotube</title>
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" type="text/css" href="splash/style.css">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<script src="splash/splash.js"></script>
<style id="splash-screen-style">
html {
height: 100%
}
body {
margin: 0;
min-height: 100%;
background-color: #ffffff;
background-image: url("splash/img/light-background.png");
background-size: 100% 100%;
}
.center {
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.contain {
display:block;
width:100%; height:100%;
object-fit: contain;
}
.stretch {
display:block;
width:100%; height:100%;
}
.cover {
display:block;
width:100%; height:100%;
object-fit: cover;
}
.bottom {
position: absolute;
bottom: 0;
left: 50%;
-ms-transform: translate(-50%, 0);
transform: translate(-50%, 0);
}
.bottomLeft {
position: absolute;
bottom: 0;
left: 0;
}
.bottomRight {
position: absolute;
bottom: 0;
right: 0;
}
</style>
<script id="splash-screen-script">
function removeSplashFromWeb() {
document.getElementById("splash")?.remove();
document.getElementById("splash-branding")?.remove();
document.body.style.background = "transparent";
}
</script>
</head>
<body> <picture id="splash-branding">
<body>
<picture id="splash-branding">
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" media="(prefers-color-scheme: light)">
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" media="(prefers-color-scheme: dark)">
<img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt="">
</picture> <picture id="splash">
</picture>
<picture id="splash">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

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