mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
chore: remove redundant comments and boilerplate across codebase
Strip doc comments, section dividers, HTML comments, and Flutter template boilerplate that add no informational value. No logic or behavior changes.
This commit is contained in:
parent
b5973c45a2
commit
2143de3aa7
33 changed files with 2 additions and 194 deletions
|
|
@ -1,12 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find APE tag footer at the end of file.
|
|
||||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -255,7 +254,6 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
||||||
|
|
||||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
// Check if there's also a header (tagSize only covers items + footer)
|
|
||||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
totalSize := tagSize
|
totalSize := tagSize
|
||||||
if hasHeader {
|
if hasHeader {
|
||||||
|
|
@ -511,7 +509,6 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||||
// deletion: the caller sends an empty value which is not serialized into
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
// newItems, but the old value must still be dropped.
|
// newItems, but the old value must still be dropped.
|
||||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
// Build a set of keys being updated (upper-case for case-insensitive match)
|
|
||||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
for k := range overrideKeys {
|
for k := range overrideKeys {
|
||||||
combined[strings.ToUpper(k)] = struct{}{}
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
|
|
@ -539,7 +536,6 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try footer at end of file
|
|
||||||
footer := make([]byte, apeTagHeaderSize)
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -2529,7 +2529,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||||
lower := strings.ToLower(req.FilePath)
|
lower := strings.ToLower(req.FilePath)
|
||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
|
|
||||||
// Download cover art to temp file
|
|
||||||
var coverTempPath string
|
var coverTempPath string
|
||||||
var coverDataBytes []byte
|
var coverDataBytes []byte
|
||||||
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
||||||
|
|
@ -2590,7 +2589,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch lyrics
|
|
||||||
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
durationSec := float64(req.DurationMs) / 1000.0
|
durationSec := float64(req.DurationMs) / 1000.0
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,6 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
// Create a mock extension with limited network permissions
|
|
||||||
ext := &loadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
|
|
@ -253,7 +252,6 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stringifyJSON failed: %v", err)
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
}
|
}
|
||||||
// JSON output may vary in order, just check it's valid
|
|
||||||
if result.String() == "" {
|
if result.String() == "" {
|
||||||
t.Error("Expected non-empty JSON string")
|
t.Error("Expected non-empty JSON string")
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +422,6 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
// Create extension with limited network permissions
|
|
||||||
ext := &loadedExtension{
|
ext := &loadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// App version and info constants
|
|
||||||
/// Update version here only - all other files will reference this
|
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '4.5.0';
|
static const String version = '4.5.0';
|
||||||
static const String buildNumber = '127';
|
static const String buildNumber = '127';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|
||||||
static const String appName = 'SpotiFLAC Mobile';
|
static const String appName = 'SpotiFLAC Mobile';
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
export 'package:spotiflac_android/l10n/app_localizations.dart';
|
export 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Extension to easily access AppLocalizations from BuildContext
|
|
||||||
extension AppLocalizationsX on BuildContext {
|
extension AppLocalizationsX on BuildContext {
|
||||||
/// Get the AppLocalizations instance
|
|
||||||
/// Usage: context.l10n.navHome
|
|
||||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Storage keys for theme settings persistence
|
|
||||||
const String kThemeModeKey = 'theme_mode';
|
const String kThemeModeKey = 'theme_mode';
|
||||||
const String kUseDynamicColorKey = 'use_dynamic_color';
|
const String kUseDynamicColorKey = 'use_dynamic_color';
|
||||||
const String kSeedColorKey = 'seed_color';
|
const String kSeedColorKey = 'seed_color';
|
||||||
|
|
@ -13,7 +12,7 @@ class ThemeSettings {
|
||||||
final ThemeMode themeMode;
|
final ThemeMode themeMode;
|
||||||
final bool useDynamicColor;
|
final bool useDynamicColor;
|
||||||
final int seedColorValue;
|
final int seedColorValue;
|
||||||
final bool useAmoled; // Pure black background for OLED screens
|
final bool useAmoled;
|
||||||
|
|
||||||
const ThemeSettings({
|
const ThemeSettings({
|
||||||
this.themeMode = ThemeMode.system,
|
this.themeMode = ThemeMode.system,
|
||||||
|
|
|
||||||
|
|
@ -3598,11 +3598,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Album ReplayGain: accumulate per-track data, compute & write album gain
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Build a stable key for grouping tracks by album.
|
|
||||||
String _albumRgKey(Track track) {
|
String _albumRgKey(Track track) {
|
||||||
if (track.albumId != null && track.albumId!.isNotEmpty) {
|
if (track.albumId != null && track.albumId!.isNotEmpty) {
|
||||||
return 'id:${track.albumId}';
|
return 'id:${track.albumId}';
|
||||||
|
|
@ -3773,7 +3768,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
_log.w('SAF write-back failed for album RG: $filePath');
|
_log.w('SAF write-back failed for album RG: $filePath');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp file regardless of SAF result.
|
|
||||||
try {
|
try {
|
||||||
final tmp = File(tempPath!);
|
final tmp = File(tempPath!);
|
||||||
if (await tmp.exists()) await tmp.delete();
|
if (await tmp.exists()) await tmp.delete();
|
||||||
|
|
@ -4017,7 +4011,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
final isM4a = format == 'm4a';
|
final isM4a = format == 'm4a';
|
||||||
final isMp3 = format == 'mp3';
|
final isMp3 = format == 'mp3';
|
||||||
|
|
||||||
// ── Cover download ──────────────────────────────────────────────
|
|
||||||
String? coverPath;
|
String? coverPath;
|
||||||
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
||||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
|
|
@ -4055,7 +4048,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ── Metadata map ────────────────────────────────────────────────
|
|
||||||
final metadata = <String, String>{
|
final metadata = <String, String>{
|
||||||
'TITLE': track.name,
|
'TITLE': track.name,
|
||||||
'ARTIST': track.artistName,
|
'ARTIST': track.artistName,
|
||||||
|
|
@ -4099,7 +4091,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
metadata['COMPOSER'] = track.composer!;
|
metadata['COMPOSER'] = track.composer!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lyrics ──────────────────────────────────────────────────────
|
|
||||||
final lyricsMode = settings.lyricsMode;
|
final lyricsMode = settings.lyricsMode;
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final skipLyrics = _shouldSkipLyrics(
|
final skipLyrics = _shouldSkipLyrics(
|
||||||
|
|
@ -4160,7 +4151,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
|
|
||||||
ReplayGainResult? scannedReplayGain;
|
ReplayGainResult? scannedReplayGain;
|
||||||
|
|
||||||
// ── ReplayGain (MP3/Opus/M4A: scan before FFmpeg, add to metadata) ─
|
|
||||||
if (settings.embedReplayGain && !isFlac) {
|
if (settings.embedReplayGain && !isFlac) {
|
||||||
try {
|
try {
|
||||||
final rgResult = await FFmpegService.scanReplayGain(filePath);
|
final rgResult = await FFmpegService.scanReplayGain(filePath);
|
||||||
|
|
@ -4178,7 +4168,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FFmpeg embed (format-specific) ──────────────────────────────
|
|
||||||
final validCover = coverPath != null && await File(coverPath).exists()
|
final validCover = coverPath != null && await File(coverPath).exists()
|
||||||
? coverPath
|
? coverPath
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -4232,7 +4221,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FLAC post-processing ────────────────────────────────────────
|
|
||||||
if (isFlac) {
|
if (isFlac) {
|
||||||
if (settings.artistTagMode == artistTagModeSplitVorbis) {
|
if (settings.artistTagMode == artistTagModeSplitVorbis) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -7991,10 +7979,6 @@ final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
||||||
return ref.watch(downloadQueueProvider.select((s) => s.lookup));
|
return ref.watch(downloadQueueProvider.select((s) => s.lookup));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Album ReplayGain helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _AlbumRgTrackEntry {
|
class _AlbumRgTrackEntry {
|
||||||
String filePath;
|
String filePath;
|
||||||
final String trackId;
|
final String trackId;
|
||||||
|
|
|
||||||
|
|
@ -1753,7 +1753,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build YT Music "Quick picks" style swipeable pages section
|
|
||||||
Widget _buildYTMusicQuickPicksSection(
|
Widget _buildYTMusicQuickPicksSection(
|
||||||
ExploreSection section,
|
ExploreSection section,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ class _LibraryTracksFolderScreenState
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if [url] is a local file path rather than a network URL.
|
|
||||||
bool _isCoverLocalPath(String url) {
|
bool _isCoverLocalPath(String url) {
|
||||||
return !url.startsWith('http://') && !url.startsWith('https://');
|
return !url.startsWith('http://') && !url.startsWith('https://');
|
||||||
}
|
}
|
||||||
|
|
@ -1301,7 +1300,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a cover image widget that handles both network URLs and local file paths.
|
|
||||||
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
|
Widget _buildTrackCover(BuildContext context, String coverUrl, double size) {
|
||||||
final isLocal =
|
final isLocal =
|
||||||
!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
|
!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://');
|
||||||
|
|
|
||||||
|
|
@ -2162,7 +2162,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
.createPlaylist(playlistName);
|
.createPlaylist(playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a playlist cover thumbnail (custom cover > first track cover > icon fallback).
|
|
||||||
/// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view
|
/// Pass a finite [size] (e.g. 56) for list view, or `null` for grid view
|
||||||
/// where the widget should expand to fill its parent.
|
/// where the widget should expand to fill its parent.
|
||||||
Widget _buildPlaylistCover(
|
Widget _buildPlaylistCover(
|
||||||
|
|
@ -2987,7 +2986,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the visible collection entries, hiding Wishlist/Loved when empty.
|
|
||||||
List<_CollectionEntry> _getVisibleCollectionEntries(
|
List<_CollectionEntry> _getVisibleCollectionEntries(
|
||||||
LibraryCollectionsState collectionState,
|
LibraryCollectionsState collectionState,
|
||||||
) {
|
) {
|
||||||
|
|
@ -4483,7 +4481,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show batch convert bottom sheet for selected tracks
|
|
||||||
Future<void> _showBatchConvertSheet(
|
Future<void> _showBatchConvertSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<UnifiedLibraryItem> allItems,
|
List<UnifiedLibraryItem> allItems,
|
||||||
|
|
@ -5629,7 +5626,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build cover image widget for unified library item.
|
|
||||||
/// When [size] is provided, renders at fixed dimensions (list mode).
|
/// When [size] is provided, renders at fixed dimensions (list mode).
|
||||||
/// When [size] is null, fills the parent container (grid mode).
|
/// When [size] is null, fills the parent container (grid mode).
|
||||||
Widget _buildUnifiedCoverImage(
|
Widget _buildUnifiedCoverImage(
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ class AppSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Updates ────────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||||
),
|
),
|
||||||
|
|
@ -97,7 +96,6 @@ class AppSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Data ───────────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||||
),
|
),
|
||||||
|
|
@ -122,7 +120,6 @@ class AppSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Debug ──────────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,6 @@ class _LanguageSelector extends StatelessWidget {
|
||||||
('zh_TW', '繁體中文', Icons.language),
|
('zh_TW', '繁體中文', Icons.language),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get only languages that meet the translation threshold.
|
|
||||||
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
|
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
|
||||||
List<(String, String, IconData)> get _languages {
|
List<(String, String, IconData)> get _languages {
|
||||||
return _allLanguages.where((lang) {
|
return _allLanguages.where((lang) {
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,6 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
const donorNames = <String>[];
|
const donorNames = <String>[];
|
||||||
|
|
||||||
// Match SettingsGroup color logic
|
|
||||||
final cardColor = isDark
|
final cardColor = isDark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
Colors.white.withValues(alpha: 0.08),
|
Colors.white.withValues(alpha: 0.08),
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Service ────────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||||
),
|
),
|
||||||
|
|
@ -91,7 +90,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Audio Quality ──────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionAudioQuality,
|
title: context.l10n.sectionAudioQuality,
|
||||||
|
|
@ -117,7 +115,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Network & Performance ──────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionPerformance,
|
title: context.l10n.sectionPerformance,
|
||||||
|
|
@ -176,7 +173,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Fallback & Search ──────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionSearchSource,
|
title: context.l10n.sectionSearchSource,
|
||||||
|
|
@ -211,7 +207,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Misc ───────────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||||
),
|
),
|
||||||
|
|
@ -611,8 +606,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private widgets (reused from original) ─────────────────────────────────
|
|
||||||
|
|
||||||
class _BetaBadge extends StatelessWidget {
|
class _BetaBadge extends StatelessWidget {
|
||||||
const _BetaBadge();
|
const _BetaBadge();
|
||||||
|
|
||||||
|
|
@ -896,7 +889,6 @@ class _ConcurrentChip extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imported from options_settings_page — search source selectors
|
|
||||||
class _MetadataSourceSelector extends ConsumerWidget {
|
class _MetadataSourceSelector extends ConsumerWidget {
|
||||||
const _MetadataSourceSelector();
|
const _MetadataSourceSelector();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||||
return _getFriendlyErrorMessage(firstError);
|
return _getFriendlyErrorMessage(firstError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse error message to be more user-friendly
|
|
||||||
String _getFriendlyErrorMessage(String? error) {
|
String _getFriendlyErrorMessage(String? error) {
|
||||||
if (error == null) return context.l10n.snackbarFailedToInstall;
|
if (error == null) return context.l10n.snackbarFailedToInstall;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Download Location ──────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.setupDownloadLocationTitle,
|
title: context.l10n.setupDownloadLocationTitle,
|
||||||
|
|
@ -159,7 +158,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Filename Formats ───────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionFileSettings,
|
title: context.l10n.sectionFileSettings,
|
||||||
|
|
@ -199,7 +197,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Folder Structure ───────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.downloadFolderOrganization,
|
title: context.l10n.downloadFolderOrganization,
|
||||||
|
|
@ -318,7 +315,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Storage Access (Android 13+) ───────────────────────────
|
|
||||||
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
|
|
@ -379,8 +375,6 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
String _getAlbumFolderStructureLabel(String structure) {
|
String _getAlbumFolderStructureLabel(String structure) {
|
||||||
switch (structure) {
|
switch (structure) {
|
||||||
case 'album_only':
|
case 'album_only':
|
||||||
|
|
|
||||||
|
|
@ -556,7 +556,6 @@ class _LogEntryTile extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summary card showing detected issues in logs
|
|
||||||
class _LogSummaryCard extends StatelessWidget {
|
class _LogSummaryCard extends StatelessWidget {
|
||||||
final List<LogEntry> logs;
|
final List<LogEntry> logs;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,6 @@ class _DisabledProviderItem extends StatelessWidget {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Empty space aligned with numbered badge
|
|
||||||
const SizedBox(width: 28),
|
const SizedBox(width: 28),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Icon(info.icon, color: colorScheme.outline),
|
Icon(info.icon, color: colorScheme.outline),
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Lyrics Embedding ───────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
|
||||||
),
|
),
|
||||||
|
|
@ -112,7 +111,6 @@ class LyricsSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Provider Options ───────────────────────────────────────
|
|
||||||
if (settings.embedMetadata && settings.embedLyrics) ...[
|
if (settings.embedMetadata && settings.embedLyrics) ...[
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ class MetadataSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Embedding ──────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||||
),
|
),
|
||||||
|
|
@ -116,7 +115,6 @@ class MetadataSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Providers ─────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionMetadataProviders,
|
title: context.l10n.sectionMetadataProviders,
|
||||||
|
|
@ -141,7 +139,6 @@ class MetadataSettingsPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Deduplication ──────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
child: SettingsSectionHeader(
|
||||||
title: context.l10n.sectionDuplicates,
|
title: context.l10n.sectionDuplicates,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,6 @@ class SettingsTab extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Group 1: Appearance & Content ──────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
@ -96,7 +95,6 @@ class SettingsTab extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Group 2: Download ──────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
@ -139,7 +137,6 @@ class SettingsTab extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Group 3: App ───────────────────────────────────────────────
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
|
||||||
|
|
@ -717,7 +717,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Language data (native names, always readable regardless of current locale) ---
|
|
||||||
static const _allLanguages = [
|
static const _allLanguages = [
|
||||||
('system', 'System Default', Icons.phone_android),
|
('system', 'System Default', Icons.phone_android),
|
||||||
('en', 'English', Icons.language),
|
('en', 'English', Icons.language),
|
||||||
|
|
@ -757,7 +756,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||||
// Match _StepLayout sizing exactly
|
|
||||||
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
|
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
|
||||||
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
|
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
|
||||||
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
||||||
|
|
@ -766,7 +764,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Header: identical to _StepLayout (same padding, spacing, styles)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -805,7 +802,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Language list (scrollable action area)
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 80),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 80),
|
||||||
|
|
|
||||||
|
|
@ -1060,7 +1060,6 @@ class FFmpegService {
|
||||||
/// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS)
|
/// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS)
|
||||||
/// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL).
|
/// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL).
|
||||||
///
|
///
|
||||||
/// Returns a [ReplayGainResult] on success, or null if the scan fails.
|
|
||||||
static Future<ReplayGainResult?> scanReplayGain(String filePath) async {
|
static Future<ReplayGainResult?> scanReplayGain(String filePath) async {
|
||||||
// -nostats suppresses the interactive progress line.
|
// -nostats suppresses the interactive progress line.
|
||||||
// ebur128=peak=true prints integrated loudness + true peak.
|
// ebur128=peak=true prints integrated loudness + true peak.
|
||||||
|
|
@ -1079,7 +1078,6 @@ class FFmpegService {
|
||||||
// because -f null always "fails" on some FFmpeg builds.
|
// because -f null always "fails" on some FFmpeg builds.
|
||||||
final output = result.output;
|
final output = result.output;
|
||||||
|
|
||||||
// Parse integrated loudness: "I: -14.0 LUFS"
|
|
||||||
final integratedMatch = RegExp(
|
final integratedMatch = RegExp(
|
||||||
r'I:\s+(-?\d+\.?\d*)\s+LUFS',
|
r'I:\s+(-?\d+\.?\d*)\s+LUFS',
|
||||||
).allMatches(output);
|
).allMatches(output);
|
||||||
|
|
@ -1205,7 +1203,6 @@ class FFmpegService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup temp file on failure
|
|
||||||
try {
|
try {
|
||||||
final tempFile = File(tempOutput);
|
final tempFile = File(tempOutput);
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
|
@ -1332,7 +1329,6 @@ class FFmpegService {
|
||||||
output.contains('incorrect codec parameters')) {
|
output.contains('incorrect codec parameters')) {
|
||||||
_log.w('MP3 copy failed (codec mismatch), re-encoding with libmp3lame');
|
_log.w('MP3 copy failed (codec mismatch), re-encoding with libmp3lame');
|
||||||
|
|
||||||
// Clean up failed temp file
|
|
||||||
try {
|
try {
|
||||||
final tempFile = File(tempOutput);
|
final tempFile = File(tempOutput);
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
|
@ -1353,7 +1349,6 @@ class FFmpegService {
|
||||||
return await _finalizeMp3Embed(mp3Path, reencodeOutput);
|
return await _finalizeMp3Embed(mp3Path, reencodeOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up re-encode temp file
|
|
||||||
try {
|
try {
|
||||||
final tempFile = File(reencodeOutput);
|
final tempFile = File(reencodeOutput);
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
|
@ -1363,7 +1358,6 @@ class FFmpegService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up temp file for other failures
|
|
||||||
try {
|
try {
|
||||||
final tempFile = File(tempOutput);
|
final tempFile = File(tempOutput);
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
if (await tempFile.exists()) await tempFile.delete();
|
||||||
|
|
@ -1375,7 +1369,6 @@ class FFmpegService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build and execute FFmpeg arguments for MP3 metadata embedding.
|
|
||||||
static Future<FFmpegResult> _runMp3Embed({
|
static Future<FFmpegResult> _runMp3Embed({
|
||||||
required String mp3Path,
|
required String mp3Path,
|
||||||
required String tempOutput,
|
required String tempOutput,
|
||||||
|
|
@ -1775,7 +1768,6 @@ class FFmpegService {
|
||||||
/// Unified audio format conversion with full metadata + cover preservation.
|
/// Unified audio format conversion with full metadata + cover preservation.
|
||||||
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
|
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
|
||||||
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
|
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
|
||||||
/// Returns the new file path on success, null on failure.
|
|
||||||
static Future<String?> convertAudioFormat({
|
static Future<String?> convertAudioFormat({
|
||||||
required String inputPath,
|
required String inputPath,
|
||||||
required String targetFormat,
|
required String targetFormat,
|
||||||
|
|
@ -1881,7 +1873,6 @@ class FFmpegService {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert any audio format to ALAC (Apple Lossless) in an M4A container.
|
|
||||||
/// Metadata and cover art are embedded in a single FFmpeg pass.
|
/// Metadata and cover art are embedded in a single FFmpeg pass.
|
||||||
static Future<String?> _convertToAlac({
|
static Future<String?> _convertToAlac({
|
||||||
required String inputPath,
|
required String inputPath,
|
||||||
|
|
@ -1954,7 +1945,6 @@ class FFmpegService {
|
||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert any audio format to FLAC with metadata and cover art preservation.
|
|
||||||
static Future<String?> _convertToFlac({
|
static Future<String?> _convertToFlac({
|
||||||
required String inputPath,
|
required String inputPath,
|
||||||
required Map<String, String> metadata,
|
required Map<String, String> metadata,
|
||||||
|
|
@ -2359,7 +2349,6 @@ class FFmpegService {
|
||||||
/// [outputDir] is where individual track files will be saved
|
/// [outputDir] is where individual track files will be saved
|
||||||
/// [tracks] is the list of track split info from the Go CUE parser
|
/// [tracks] is the list of track split info from the Go CUE parser
|
||||||
/// [albumMetadata] contains album-level metadata (artist, album, genre, date)
|
/// [albumMetadata] contains album-level metadata (artist, album, genre, date)
|
||||||
/// Returns list of output file paths on success, null on failure.
|
|
||||||
static Future<List<String>?> splitCueToTracks({
|
static Future<List<String>?> splitCueToTracks({
|
||||||
required String audioPath,
|
required String audioPath,
|
||||||
required String outputDir,
|
required String outputDir,
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ class UpdateChecker {
|
||||||
static const String _allReleasesApiUrl =
|
static const String _allReleasesApiUrl =
|
||||||
'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||||
|
|
||||||
/// Check for updates based on channel preference
|
|
||||||
/// [channel] can be 'stable' or 'preview'
|
|
||||||
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final void Function(String quality, String service) onSelect;
|
final void Function(String quality, String service) onSelect;
|
||||||
final String? recommendedService; // Service to show as "(Recommended)"
|
final String? recommendedService;
|
||||||
|
|
||||||
const DownloadServicePicker({
|
const DownloadServicePicker({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── M3 AMOLED surface ramp ── */
|
|
||||||
:root {
|
:root {
|
||||||
--green: #1DB954;
|
--green: #1DB954;
|
||||||
--green-dim: #1aa34a;
|
--green-dim: #1aa34a;
|
||||||
|
|
@ -209,7 +208,6 @@
|
||||||
|
|
||||||
.sidebar-toggle { display: none; }
|
.sidebar-toggle { display: none; }
|
||||||
|
|
||||||
/* ── MOBILE MENU BAR ── */
|
|
||||||
.docs-menu-bar {
|
.docs-menu-bar {
|
||||||
display: none;
|
display: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|
@ -232,7 +230,6 @@
|
||||||
.docs-menu-bar button:hover { color: var(--text); }
|
.docs-menu-bar button:hover { color: var(--text); }
|
||||||
.docs-menu-bar button svg { width: 16px; height: 16px; fill: currentColor; }
|
.docs-menu-bar button svg { width: 16px; height: 16px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── DOCS DRAWER ── */
|
|
||||||
.docs-drawer-overlay {
|
.docs-drawer-overlay {
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
background: rgba(0,0,0,.5); z-index: 200;
|
background: rgba(0,0,0,.5); z-index: 200;
|
||||||
|
|
@ -436,7 +433,6 @@
|
||||||
.docs-content table { display: block; overflow-x: auto; white-space: nowrap; }
|
.docs-content table { display: block; overflow-x: auto; white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SEARCH MODAL ── */
|
|
||||||
.search-trigger {
|
.search-trigger {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
background: rgba(255,255,255,.06);
|
background: rgba(255,255,255,.06);
|
||||||
|
|
@ -580,7 +576,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu bar -->
|
|
||||||
<div class="docs-menu-bar">
|
<div class="docs-menu-bar">
|
||||||
<button onclick="openDrawer()">
|
<button onclick="openDrawer()">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||||
|
|
@ -593,7 +588,6 @@
|
||||||
<button onclick="window.scrollTo({top:0,behavior:'smooth'})">Return to top</button>
|
<button onclick="window.scrollTo({top:0,behavior:'smooth'})">Return to top</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drawer overlay + sidebar -->
|
|
||||||
<div class="docs-drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
|
<div class="docs-drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
|
||||||
<div class="docs-drawer" id="docsDrawer">
|
<div class="docs-drawer" id="docsDrawer">
|
||||||
<div class="docs-drawer-header">
|
<div class="docs-drawer-header">
|
||||||
|
|
@ -5516,7 +5510,6 @@ registerExtension({
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search modal -->
|
|
||||||
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
||||||
<div class="search-modal">
|
<div class="search-modal">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
|
|
@ -5659,7 +5652,6 @@ window.addEventListener('hashchange', () => setTimeout(updateActiveState, 10));
|
||||||
|
|
||||||
updateActiveState();
|
updateActiveState();
|
||||||
|
|
||||||
/* ── SEARCH ── */
|
|
||||||
(function() {
|
(function() {
|
||||||
const overlay = document.getElementById('searchOverlay');
|
const overlay = document.getElementById('searchOverlay');
|
||||||
const input = document.getElementById('searchInput');
|
const input = document.getElementById('searchInput');
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
<link rel="icon" href="icon.png" type="image/png">
|
<link rel="icon" href="icon.png" type="image/png">
|
||||||
|
|
||||||
<!-- Google Sans Flex -->
|
|
||||||
<style>
|
<style>
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||||
|
|
@ -18,7 +17,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── M3 AMOLED surface ramp ── */
|
|
||||||
:root {
|
:root {
|
||||||
--green: #1DB954;
|
--green: #1DB954;
|
||||||
--green-dim: #1aa34a;
|
--green-dim: #1aa34a;
|
||||||
|
|
@ -47,7 +45,6 @@
|
||||||
a { color: var(--green); text-decoration: none; }
|
a { color: var(--green); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── NAV ── */
|
|
||||||
nav {
|
nav {
|
||||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
background: rgba(18,18,18,.78);
|
background: rgba(18,18,18,.78);
|
||||||
|
|
@ -85,14 +82,12 @@
|
||||||
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── PAGE HEADER ── */
|
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 100px 24px 40px; text-align: center;
|
padding: 100px 24px 40px; text-align: center;
|
||||||
}
|
}
|
||||||
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
||||||
.page-header p { color: var(--text-dim); font-size: 1rem; }
|
.page-header p { color: var(--text-dim); font-size: 1rem; }
|
||||||
|
|
||||||
/* ── LATEST HERO ── */
|
|
||||||
.latest-hero {
|
.latest-hero {
|
||||||
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 40px;
|
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 40px;
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +133,6 @@
|
||||||
}
|
}
|
||||||
.latest-changelog.show { display: block; }
|
.latest-changelog.show { display: block; }
|
||||||
|
|
||||||
/* ── OLDER RELEASES ── */
|
|
||||||
.older-section {
|
.older-section {
|
||||||
max-width: var(--max-w); margin: 0 auto; padding: 40px 24px 80px;
|
max-width: var(--max-w); margin: 0 auto; padding: 40px 24px 80px;
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +141,6 @@
|
||||||
margin-bottom: 16px; padding-bottom: 12px;
|
margin-bottom: 16px; padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── RELEASE CARDS ── */
|
|
||||||
.release-card {
|
.release-card {
|
||||||
background: var(--bg-card); border-radius: 16px;
|
background: var(--bg-card); border-radius: 16px;
|
||||||
margin-bottom: 8px; transition: background .2s;
|
margin-bottom: 8px; transition: background .2s;
|
||||||
|
|
@ -178,7 +171,6 @@
|
||||||
.release-asset:hover { background: rgba(29,185,84,.15); text-decoration: none; }
|
.release-asset:hover { background: rgba(29,185,84,.15); text-decoration: none; }
|
||||||
.release-asset-size { color: var(--text-dim); font-size: .72rem; }
|
.release-asset-size { color: var(--text-dim); font-size: .72rem; }
|
||||||
|
|
||||||
/* ── CHANGELOG BODY ── */
|
|
||||||
.release-body {
|
.release-body {
|
||||||
font-size: .85rem; color: var(--text-dim); line-height: 1.7;
|
font-size: .85rem; color: var(--text-dim); line-height: 1.7;
|
||||||
max-height: 400px; overflow-y: auto;
|
max-height: 400px; overflow-y: auto;
|
||||||
|
|
@ -193,7 +185,6 @@
|
||||||
.release-body code { background: var(--bg-card-hover); padding: 2px 6px; border-radius: 4px; font-size: .8rem; }
|
.release-body code { background: var(--bg-card-hover); padding: 2px 6px; border-radius: 4px; font-size: .8rem; }
|
||||||
.release-body a { color: var(--green); }
|
.release-body a { color: var(--green); }
|
||||||
|
|
||||||
/* ── FOOTER ── */
|
|
||||||
footer {
|
footer {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 40px 24px; text-align: center;
|
padding: 40px 24px; text-align: center;
|
||||||
|
|
@ -204,7 +195,6 @@
|
||||||
.footer-links a:hover { color: var(--text); }
|
.footer-links a:hover { color: var(--text); }
|
||||||
.footer-copy { color: #555; font-size: .8rem; }
|
.footer-copy { color: #555; font-size: .8rem; }
|
||||||
|
|
||||||
/* ── LOADING ── */
|
|
||||||
.loading { text-align: center; color: var(--text-dim); padding: 60px 0; }
|
.loading { text-align: center; color: var(--text-dim); padding: 60px 0; }
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 32px; height: 32px; margin: 0 auto 12px;
|
width: 32px; height: 32px; margin: 0 auto 12px;
|
||||||
|
|
@ -217,7 +207,6 @@
|
||||||
color: var(--text-dim); font-size: .9rem;
|
color: var(--text-dim); font-size: .9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── MOBILE MENU ── */
|
|
||||||
.nav-burger {
|
.nav-burger {
|
||||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
|
|
@ -285,7 +274,6 @@
|
||||||
}
|
}
|
||||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── MOBILE ── */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.nav-burger { display: flex; }
|
.nav-burger { display: flex; }
|
||||||
|
|
@ -302,7 +290,6 @@
|
||||||
|
|
||||||
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── SEARCH MODAL ── */
|
|
||||||
.search-overlay {
|
.search-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||||
z-index: 300; opacity: 0; pointer-events: none;
|
z-index: 300; opacity: 0; pointer-events: none;
|
||||||
|
|
@ -402,7 +389,6 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- MOBILE MENU -->
|
|
||||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||||
<div class="mobile-menu" id="mobileMenu">
|
<div class="mobile-menu" id="mobileMenu">
|
||||||
<a href="index#features">Features</a>
|
<a href="index#features">Features</a>
|
||||||
|
|
@ -442,7 +428,6 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEARCH MODAL -->
|
|
||||||
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
||||||
<div class="search-modal">
|
<div class="search-modal">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
|
|
@ -595,7 +580,6 @@ document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
/* ── DOCS SEARCH ── */
|
|
||||||
(function() {
|
(function() {
|
||||||
var overlay = document.getElementById('searchOverlay');
|
var overlay = document.getElementById('searchOverlay');
|
||||||
var input = document.getElementById('searchInput');
|
var input = document.getElementById('searchInput');
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
<meta name="description" content="Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.">
|
<meta name="description" content="Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.">
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
|
||||||
<meta property="og:title" content="SpotiFLAC Mobile">
|
<meta property="og:title" content="SpotiFLAC Mobile">
|
||||||
<meta property="og:description" content="Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.">
|
<meta property="og:description" content="Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.">
|
||||||
<meta property="og:image" content="icon.png">
|
<meta property="og:image" content="icon.png">
|
||||||
|
|
@ -15,7 +14,6 @@
|
||||||
|
|
||||||
<link rel="icon" href="icon.png" type="image/png">
|
<link rel="icon" href="icon.png" type="image/png">
|
||||||
|
|
||||||
<!-- Google Sans Flex -->
|
|
||||||
<style>
|
<style>
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||||
|
|
@ -25,7 +23,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── M3 AMOLED surface ramp ── */
|
|
||||||
:root {
|
:root {
|
||||||
--green: #1DB954;
|
--green: #1DB954;
|
||||||
--green-dim: #1aa34a;
|
--green-dim: #1aa34a;
|
||||||
|
|
@ -54,7 +51,6 @@
|
||||||
a { color: var(--green); text-decoration: none; }
|
a { color: var(--green); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── NAV ── */
|
|
||||||
nav {
|
nav {
|
||||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
background: rgba(18,18,18,.78);
|
background: rgba(18,18,18,.78);
|
||||||
|
|
@ -92,7 +88,6 @@
|
||||||
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── HERO ── */
|
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
|
@ -121,13 +116,11 @@
|
||||||
.btn-secondary { background: var(--bg-card); color: var(--text); }
|
.btn-secondary { background: var(--bg-card); color: var(--text); }
|
||||||
.btn-secondary:hover { background: var(--bg-card-hover); text-decoration: none; }
|
.btn-secondary:hover { background: var(--bg-card-hover); text-decoration: none; }
|
||||||
|
|
||||||
/* ── SECTIONS ── */
|
|
||||||
section { padding: 80px 24px; }
|
section { padding: 80px 24px; }
|
||||||
.section-inner { max-width: var(--max-w); margin: auto; }
|
.section-inner { max-width: var(--max-w); margin: auto; }
|
||||||
.section-title { font-size: 1.8rem; font-weight: 700; text-align: center; margin-bottom: 12px; }
|
.section-title { font-size: 1.8rem; font-weight: 700; text-align: center; margin-bottom: 12px; }
|
||||||
.section-sub { text-align: center; color: var(--text-dim); max-width: 560px; margin: 0 auto 48px; }
|
.section-sub { text-align: center; color: var(--text-dim); max-width: 560px; margin: 0 auto 48px; }
|
||||||
|
|
||||||
/* ── FEATURES ── */
|
|
||||||
.features-grid {
|
.features-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -146,7 +139,6 @@
|
||||||
.feature-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
.feature-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||||
.feature-card p { color: var(--text-dim); font-size: .9rem; }
|
.feature-card p { color: var(--text-dim); font-size: .9rem; }
|
||||||
|
|
||||||
/* ── FAQ ── */
|
|
||||||
.faq-list { max-width: 700px; margin: auto; display: flex; flex-direction: column; gap: 8px; }
|
.faq-list { max-width: 700px; margin: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||||
.faq-item {
|
.faq-item {
|
||||||
background: var(--bg-card); border-radius: 16px;
|
background: var(--bg-card); border-radius: 16px;
|
||||||
|
|
@ -161,7 +153,6 @@
|
||||||
.faq-item[open] summary::after { content: "\2212"; }
|
.faq-item[open] summary::after { content: "\2212"; }
|
||||||
.faq-item .faq-answer { padding: 0 20px 18px; color: var(--text-dim); font-size: .92rem; line-height: 1.7; }
|
.faq-item .faq-answer { padding: 0 20px 18px; color: var(--text-dim); font-size: .92rem; line-height: 1.7; }
|
||||||
|
|
||||||
/* ── FOOTER ── */
|
|
||||||
footer {
|
footer {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 40px 24px; text-align: center;
|
padding: 40px 24px; text-align: center;
|
||||||
|
|
@ -172,7 +163,6 @@
|
||||||
.footer-links a:hover { color: var(--text); }
|
.footer-links a:hover { color: var(--text); }
|
||||||
.footer-copy { color: #555; font-size: .8rem; }
|
.footer-copy { color: #555; font-size: .8rem; }
|
||||||
|
|
||||||
/* ── MOBILE MENU ── */
|
|
||||||
.nav-burger {
|
.nav-burger {
|
||||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
|
|
@ -240,7 +230,6 @@
|
||||||
}
|
}
|
||||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── MOBILE ── */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.nav-burger { display: flex; }
|
.nav-burger { display: flex; }
|
||||||
|
|
@ -248,7 +237,6 @@
|
||||||
section { padding: 60px 16px; }
|
section { padding: 60px 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── HERO MOCKUPS ── */
|
|
||||||
.hero-mockups {
|
.hero-mockups {
|
||||||
display: flex; gap: 20px; justify-content: center; align-items: flex-end;
|
display: flex; gap: 20px; justify-content: center; align-items: flex-end;
|
||||||
margin-top: 48px; perspective: 800px;
|
margin-top: 48px; perspective: 800px;
|
||||||
|
|
@ -277,10 +265,8 @@
|
||||||
.phone-frame.phone-center { width: 200px; }
|
.phone-frame.phone-center { width: 200px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SVG ICONS ── */
|
|
||||||
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
.icon-svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── SEARCH MODAL ── */
|
|
||||||
.search-overlay {
|
.search-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||||
z-index: 300; opacity: 0; pointer-events: none;
|
z-index: 300; opacity: 0; pointer-events: none;
|
||||||
|
|
@ -353,7 +339,6 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- NAV -->
|
|
||||||
<nav>
|
<nav>
|
||||||
<div class="nav-inner">
|
<div class="nav-inner">
|
||||||
<a class="nav-brand" href="#">
|
<a class="nav-brand" href="#">
|
||||||
|
|
@ -381,7 +366,6 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- MOBILE MENU -->
|
|
||||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||||
<div class="mobile-menu" id="mobileMenu">
|
<div class="mobile-menu" id="mobileMenu">
|
||||||
<a href="#features">Features</a>
|
<a href="#features">Features</a>
|
||||||
|
|
@ -401,7 +385,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HERO -->
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>Spoti<span>FLAC</span> Mobile</h1>
|
<h1>Spoti<span>FLAC</span> Mobile</h1>
|
||||||
<p>Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.</p>
|
<p>Mobile music utility built with Flutter and Go. High-quality audio management for your personal library.</p>
|
||||||
|
|
@ -433,7 +416,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- FEATURES -->
|
|
||||||
<section id="features">
|
<section id="features">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<h2 class="section-title">Features</h2>
|
<h2 class="section-title">Features</h2>
|
||||||
|
|
@ -486,7 +468,6 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<!-- FAQ -->
|
|
||||||
<section id="faq">
|
<section id="faq">
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<h2 class="section-title">FAQ</h2>
|
<h2 class="section-title">FAQ</h2>
|
||||||
|
|
@ -524,7 +505,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- SEARCH MODAL -->
|
|
||||||
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
||||||
<div class="search-modal">
|
<div class="search-modal">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
|
|
@ -543,7 +523,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
|
|
@ -573,7 +552,6 @@ document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
/* ── DOCS SEARCH ── */
|
|
||||||
(function() {
|
(function() {
|
||||||
var overlay = document.getElementById('searchOverlay');
|
var overlay = document.getElementById('searchOverlay');
|
||||||
var input = document.getElementById('searchInput');
|
var input = document.getElementById('searchInput');
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
<link rel="icon" href="icon.png" type="image/png">
|
<link rel="icon" href="icon.png" type="image/png">
|
||||||
|
|
||||||
<!-- Google Sans Flex -->
|
|
||||||
<style>
|
<style>
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 400; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-400-normal.woff2) format('woff2'); }
|
||||||
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
@font-face { font-family: 'Google Sans Flex'; font-style: normal; font-display: swap; font-weight: 500; src: url(https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex@latest/latin-500-normal.woff2) format('woff2'); }
|
||||||
|
|
@ -18,7 +17,6 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── M3 AMOLED surface ramp ── */
|
|
||||||
:root {
|
:root {
|
||||||
--green: #1DB954;
|
--green: #1DB954;
|
||||||
--green-dim: #1aa34a;
|
--green-dim: #1aa34a;
|
||||||
|
|
@ -47,7 +45,6 @@
|
||||||
a { color: var(--green); text-decoration: none; }
|
a { color: var(--green); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ── NAV ── */
|
|
||||||
nav {
|
nav {
|
||||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
background: rgba(18,18,18,.78);
|
background: rgba(18,18,18,.78);
|
||||||
|
|
@ -85,14 +82,12 @@
|
||||||
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
font-family: inherit; color: #555; line-height: 1.4; margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── PAGE HEADER ── */
|
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 100px 24px 40px; text-align: center;
|
padding: 100px 24px 40px; text-align: center;
|
||||||
}
|
}
|
||||||
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
.page-header h1 { font-size: 2rem; font-weight: 800; margin-bottom: 8px; }
|
||||||
.page-header p { color: var(--text-dim); font-size: 1rem; max-width: 560px; margin: 0 auto; }
|
.page-header p { color: var(--text-dim); font-size: 1rem; max-width: 560px; margin: 0 auto; }
|
||||||
|
|
||||||
/* ── SECTIONS ── */
|
|
||||||
section { padding: 40px 24px 60px; }
|
section { padding: 40px 24px 60px; }
|
||||||
.section-inner { max-width: var(--max-w); margin: auto; }
|
.section-inner { max-width: var(--max-w); margin: auto; }
|
||||||
.section-label {
|
.section-label {
|
||||||
|
|
@ -102,7 +97,6 @@
|
||||||
.section-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
|
.section-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
|
||||||
.section-sub { color: var(--text-dim); font-size: .95rem; margin-bottom: 32px; max-width: 600px; }
|
.section-sub { color: var(--text-dim); font-size: .95rem; margin-bottom: 32px; max-width: 600px; }
|
||||||
|
|
||||||
/* ── INFRA CARDS ── */
|
|
||||||
.infra-grid {
|
.infra-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -132,7 +126,6 @@
|
||||||
.infra-link:hover { color: var(--text); text-decoration: none; }
|
.infra-link:hover { color: var(--text); text-decoration: none; }
|
||||||
.infra-link svg { width: 13px; height: 13px; fill: currentColor; }
|
.infra-link svg { width: 13px; height: 13px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── FOOTER ── */
|
|
||||||
footer {
|
footer {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
padding: 40px 24px; text-align: center;
|
padding: 40px 24px; text-align: center;
|
||||||
|
|
@ -143,7 +136,6 @@
|
||||||
.footer-links a:hover { color: var(--text); }
|
.footer-links a:hover { color: var(--text); }
|
||||||
.footer-copy { color: #555; font-size: .8rem; }
|
.footer-copy { color: #555; font-size: .8rem; }
|
||||||
|
|
||||||
/* ── DISCLAIMER ── */
|
|
||||||
.disclaimer {
|
.disclaimer {
|
||||||
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 60px;
|
max-width: var(--max-w); margin: 0 auto; padding: 0 24px 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -155,7 +147,6 @@
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── MOBILE MENU ── */
|
|
||||||
.nav-burger {
|
.nav-burger {
|
||||||
display: none; width: 40px; height: 40px; border-radius: 12px;
|
display: none; width: 40px; height: 40px; border-radius: 12px;
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
|
|
@ -223,7 +214,6 @@
|
||||||
}
|
}
|
||||||
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
.mobile-menu .mobile-icons a svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
|
|
||||||
/* ── MOBILE ── */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.nav-links { display: none; }
|
.nav-links { display: none; }
|
||||||
.nav-burger { display: flex; }
|
.nav-burger { display: flex; }
|
||||||
|
|
@ -234,7 +224,6 @@
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style>
|
<style>
|
||||||
/* ── SEARCH MODAL ── */
|
|
||||||
.search-overlay {
|
.search-overlay {
|
||||||
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
position: fixed; inset: 0; background: rgba(0,0,0,.6);
|
||||||
z-index: 300; opacity: 0; pointer-events: none;
|
z-index: 300; opacity: 0; pointer-events: none;
|
||||||
|
|
@ -333,7 +322,6 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- MOBILE MENU -->
|
|
||||||
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
<div class="mobile-overlay" id="mobileOverlay" onclick="toggleMenu()"></div>
|
||||||
<div class="mobile-menu" id="mobileMenu">
|
<div class="mobile-menu" id="mobileMenu">
|
||||||
<a href="index#features">Features</a>
|
<a href="index#features">Features</a>
|
||||||
|
|
@ -358,7 +346,6 @@
|
||||||
<p>The behind-the-scenes APIs and tools that power SpotiFLAC Mobile. We appreciate every one of them.</p>
|
<p>The behind-the-scenes APIs and tools that power SpotiFLAC Mobile. We appreciate every one of them.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- INFRASTRUCTURE -->
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-inner">
|
<div class="section-inner">
|
||||||
<div class="section-label">Infrastructure</div>
|
<div class="section-label">Infrastructure</div>
|
||||||
|
|
@ -367,9 +354,7 @@
|
||||||
|
|
||||||
<div class="infra-grid">
|
<div class="infra-grid">
|
||||||
|
|
||||||
<!-- === TRACK LINKING === -->
|
|
||||||
|
|
||||||
<!-- Odesli / song.link (no GitHub — globe) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(99,102,241,.1); color: #6366f1;">
|
<div class="infra-icon" style="background: rgba(99,102,241,.1); color: #6366f1;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||||
|
|
@ -384,7 +369,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- I Don't Have Spotify (GitHub) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||||
|
|
@ -399,7 +383,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LRCLIB (GitHub) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||||
|
|
@ -414,7 +397,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Paxsenix (lyrics proxy) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(59,130,246,.1); color: #3b82f6;">
|
<div class="infra-icon" style="background: rgba(59,130,246,.1); color: #3b82f6;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2c2.4 0 4.6 1.1 6 3 1.4 1.9 1.8 4.3 1.2 6.6-.7 2.2-2.3 4-4.4 5v2.4h-6V17c-2.1-1-3.7-2.8-4.4-5C3.8 9.3 4.2 6.9 5.6 5 7 3.1 9.2 2 11.6 2H12zm-1 18h2v2h-2v-2zm-.2-5h2.4c1.9-.7 3.3-2.2 3.9-4.1.5-1.7.2-3.5-.8-4.9-1-1.4-2.6-2.2-4.3-2.2H12c-1.7 0-3.3.8-4.3 2.2-1 1.4-1.3 3.2-.8 4.9.6 1.9 2 3.4 3.9 4.1z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 2c2.4 0 4.6 1.1 6 3 1.4 1.9 1.8 4.3 1.2 6.6-.7 2.2-2.3 4-4.4 5v2.4h-6V17c-2.1-1-3.7-2.8-4.4-5C3.8 9.3 4.2 6.9 5.6 5 7 3.1 9.2 2 11.6 2H12zm-1 18h2v2h-2v-2zm-.2-5h2.4c1.9-.7 3.3-2.2 3.9-4.1.5-1.7.2-3.5-.8-4.9-1-1.4-2.6-2.2-4.3-2.2H12c-1.7 0-3.3.8-4.3 2.2-1 1.4-1.3 3.2-.8 4.9.6 1.9 2 3.4 3.9 4.1z"/></svg>
|
||||||
|
|
@ -429,9 +411,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- === QOBUZ & DEEZER API (Ruubiiiii) === -->
|
|
||||||
|
|
||||||
<!-- Ruubiiiii / MusicDL (GitHub) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||||
|
|
@ -446,9 +426,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- === YOUTUBE AUDIO === -->
|
|
||||||
|
|
||||||
<!-- Cobalt (GitHub) -->
|
|
||||||
<div class="infra-card">
|
<div class="infra-card">
|
||||||
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
<div class="infra-icon" style="background: rgba(255,255,255,.08); color: #e8e8e8;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.4-1.3-1.8-1.3-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.3.5-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg>
|
||||||
|
|
@ -467,12 +445,10 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- DISCLAIMER -->
|
|
||||||
<div class="disclaimer">
|
<div class="disclaimer">
|
||||||
<p>SpotiFLAC Mobile is not affiliated with, endorsed by, or connected to any of the services listed above. All trademarks and logos belong to their respective owners. This page is meant to acknowledge and appreciate the platforms that make this project possible.</p>
|
<p>SpotiFLAC Mobile is not affiliated with, endorsed by, or connected to any of the services listed above. All trademarks and logos belong to their respective owners. This page is meant to acknowledge and appreciate the platforms that make this project possible.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEARCH MODAL -->
|
|
||||||
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
<div class="search-overlay" id="searchOverlay" onclick="if(event.target===this)closeSearch()">
|
||||||
<div class="search-modal">
|
<div class="search-modal">
|
||||||
<div class="search-header">
|
<div class="search-header">
|
||||||
|
|
@ -491,7 +467,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FOOTER -->
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
|
|
@ -518,7 +493,6 @@ document.getElementById('mobileMenu').addEventListener('click', function(e) {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
/* ── DOCS SEARCH ── */
|
|
||||||
(function() {
|
(function() {
|
||||||
var overlay = document.getElementById('searchOverlay');
|
var overlay = document.getElementById('searchOverlay');
|
||||||
var input = document.getElementById('searchInput');
|
var input = document.getElementById('searchInput');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue