mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-16 05:29:15 +02:00
feat(backend): add IDHS as fallback link resolver when SongLink fails
This commit is contained in:
+8
-11
@@ -4,10 +4,14 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Lossy Format Support**: Download in MP3 or Opus format with configurable quality
|
||||
- New "Enable Lossy Option" toggle in Settings > Download > Audio Quality
|
||||
- Choose between MP3 (320kbps) or Opus (128kbps) format
|
||||
- Downloads FLAC first, then converts using FFmpeg
|
||||
- **IDHS Fallback**: Added I Don't Have Spotify (IDHS) as fallback link resolver when SongLink fails
|
||||
- Automatically tries IDHS when SongLink returns errors or rate limits
|
||||
- Supports both Spotify→other platforms and Deezer→other platforms lookups
|
||||
- Rate limited to 8 requests/minute to respect API limits
|
||||
- **Lossy Bitrate Options**: More quality choices for lossy format downloads
|
||||
- MP3: 320kbps (Best), 256kbps, 192kbps, 128kbps
|
||||
- Opus: 128kbps (Best), 96kbps, 64kbps
|
||||
- Replaces the simple MP3/Opus toggle with a unified quality picker
|
||||
- **Search Filters**: Filter search results by type (Tracks, Artists, Albums, Playlists)
|
||||
- Works with both default Deezer search and extension search providers
|
||||
- Filter chips appear below search bar when results are shown
|
||||
@@ -18,19 +22,12 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **FFmpeg Plugin Migration**: Replaced custom FFmpeg AAR with `ffmpeg_kit_flutter_new_audio` plugin
|
||||
- Unified FFmpeg implementation for both Android and iOS
|
||||
- Removed custom FFmpeg MethodChannel from MainActivity
|
||||
- Simplified build process (no more custom AAR in android/app/libs/)
|
||||
- **Amazon Download API**: Switched to AfkarXYZ API for improved reliability
|
||||
- **Qobuz Download API**: Added Jumo API as fallback with quality fallback support
|
||||
- **Search Results**: Reduced artist limit from 5 to 2 for cleaner results
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MP3/Lossy Download 403 Error**: Fixed 403 Forbidden when selecting lossy quality
|
||||
- Now downloads FLAC first, then converts to selected lossy format
|
||||
- Tidal/Qobuz APIs don't support direct MP3 quality parameter
|
||||
- **Opus Cover Art**: Fixed cover art not being embedded in Opus files
|
||||
- **Deezer Pagination**: Fixed albums/playlists with >25 tracks only showing first 25
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IDHSClient is a client for I Don't Have Spotify API
|
||||
// Used as fallback when SongLink fails or is rate limited
|
||||
type IDHSClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
globalIDHSClient *IDHSClient
|
||||
idhsClientOnce sync.Once
|
||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||
)
|
||||
|
||||
// IDHSSearchRequest represents the request body for IDHS API
|
||||
type IDHSSearchRequest struct {
|
||||
Link string `json:"link"`
|
||||
Adapters []string `json:"adapters,omitempty"`
|
||||
}
|
||||
|
||||
// IDHSSearchResponse represents the response from IDHS API
|
||||
type IDHSSearchResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // song, album, artist, podcast, show
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Audio string `json:"audio,omitempty"`
|
||||
Source string `json:"source"`
|
||||
UniversalLink string `json:"universalLink"`
|
||||
Links []IDHSLink `json:"links"`
|
||||
}
|
||||
|
||||
// IDHSLink represents a link to a streaming platform
|
||||
type IDHSLink struct {
|
||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||
URL string `json:"url"`
|
||||
IsVerified bool `json:"isVerified,omitempty"`
|
||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||
}
|
||||
|
||||
// NewIDHSClient creates a new IDHS client
|
||||
func NewIDHSClient() *IDHSClient {
|
||||
idhsClientOnce.Do(func() {
|
||||
globalIDHSClient = &IDHSClient{
|
||||
client: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalIDHSClient
|
||||
}
|
||||
|
||||
// Search converts a music link to links on other platforms
|
||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||
idhsRateLimiter.WaitForSlot()
|
||||
|
||||
reqBody := IDHSSearchRequest{
|
||||
Link: link,
|
||||
Adapters: adapters,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("invalid link or missing parameters")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("IDHS rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode == 500 {
|
||||
return nil, fmt.Errorf("IDHS processing failed")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result IDHSSearchResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"tidal", "deezer"}
|
||||
|
||||
result, err := c.Search(spotifyURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
case "deezer":
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = link.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
|
||||
spotifyTrackID, availability.Tidal, availability.Deezer)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"spotify", "tidal"}
|
||||
|
||||
result, err := c.Search(deezerURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "spotify":
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
|
||||
deezerTrackID, availability.SpotifyID, availability.Tidal)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
+54
-17
@@ -46,7 +46,30 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("spotify track ID is empty")
|
||||
}
|
||||
|
||||
|
||||
// Try SongLink first
|
||||
availability, err := s.checkTrackAvailabilitySongLink(spotifyTrackID)
|
||||
if err != nil {
|
||||
// Fallback to IDHS if SongLink fails
|
||||
LogWarn("SongLink", "SongLink failed, trying IDHS fallback: %v", err)
|
||||
idhsClient := NewIDHSClient()
|
||||
availability, err = idhsClient.GetAvailabilityFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||
}
|
||||
LogInfo("SongLink", "IDHS fallback successful for %s", spotifyTrackID)
|
||||
}
|
||||
|
||||
// Check Qobuz availability separately via ISRC
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkTrackAvailabilitySongLink is the original SongLink implementation
|
||||
func (s *SongLinkClient) checkTrackAvailabilitySongLink(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
@@ -115,10 +138,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -191,11 +210,11 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("track not found on Deezer")
|
||||
}
|
||||
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
@@ -268,11 +287,11 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("album not found on Deezer")
|
||||
}
|
||||
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
@@ -281,7 +300,25 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
if deezerTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer track ID is empty")
|
||||
}
|
||||
|
||||
|
||||
// Try SongLink first
|
||||
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||
if err != nil {
|
||||
// Fallback to IDHS if SongLink fails
|
||||
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||
idhsClient := NewIDHSClient()
|
||||
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||
}
|
||||
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
@@ -369,7 +406,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
if entityID == "" {
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
|
||||
// Use global rate limiter
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
@@ -464,11 +501,11 @@ func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
return "", fmt.Errorf("track not found on Spotify")
|
||||
}
|
||||
|
||||
|
||||
return availability.SpotifyID, nil
|
||||
}
|
||||
|
||||
@@ -478,11 +515,11 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Tidal || availability.TidalURL == "" {
|
||||
return "", fmt.Errorf("track not found on Tidal")
|
||||
}
|
||||
|
||||
|
||||
return availability.TidalURL, nil
|
||||
}
|
||||
|
||||
@@ -491,10 +528,10 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not found on Amazon Music")
|
||||
}
|
||||
|
||||
|
||||
return availability.AmazonURL, nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ class AppSettings {
|
||||
final String locale;
|
||||
final bool enableLossyOption;
|
||||
final String lossyFormat;
|
||||
final String lossyBitrate; // e.g., 'mp3_320', 'mp3_256', 'mp3_192', 'mp3_128', 'opus_128', 'opus_96', 'opus_64'
|
||||
final String lyricsMode;
|
||||
|
||||
const AppSettings({
|
||||
@@ -65,6 +66,7 @@ class AppSettings {
|
||||
this.locale = 'system',
|
||||
this.enableLossyOption = false,
|
||||
this.lossyFormat = 'mp3',
|
||||
this.lossyBitrate = 'mp3_320',
|
||||
this.lyricsMode = 'embed',
|
||||
});
|
||||
|
||||
@@ -99,6 +101,7 @@ class AppSettings {
|
||||
String? locale,
|
||||
bool? enableLossyOption,
|
||||
String? lossyFormat,
|
||||
String? lossyBitrate,
|
||||
String? lyricsMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
@@ -131,6 +134,7 @@ class AppSettings {
|
||||
locale: locale ?? this.locale,
|
||||
enableLossyOption: enableLossyOption ?? this.enableLossyOption,
|
||||
lossyFormat: lossyFormat ?? this.lossyFormat,
|
||||
lossyBitrate: lossyBitrate ?? this.lossyBitrate,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
enableLossyOption: json['enableLossyOption'] as bool? ?? false,
|
||||
lossyFormat: json['lossyFormat'] as String? ?? 'mp3',
|
||||
lossyBitrate: json['lossyBitrate'] as String? ?? 'mp3_320',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
);
|
||||
|
||||
@@ -72,5 +73,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'locale': instance.locale,
|
||||
'enableLossyOption': instance.enableLossyOption,
|
||||
'lossyFormat': instance.lossyFormat,
|
||||
'lossyBitrate': instance.lossyBitrate,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
};
|
||||
|
||||
@@ -2111,7 +2111,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file');
|
||||
} else {
|
||||
final lossyFormat = settings.lossyFormat;
|
||||
_log.i('Lossy quality selected, converting FLAC to $lossyFormat...');
|
||||
final lossyBitrate = settings.lossyBitrate;
|
||||
_log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
@@ -2122,13 +2123,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final convertedPath = await FFmpegService.convertFlacToLossy(
|
||||
filePath,
|
||||
format: lossyFormat,
|
||||
bitrate: lossyBitrate,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
filePath = convertedPath;
|
||||
actualQuality = lossyFormat == 'opus' ? 'Opus 128kbps' : 'MP3 320kbps';
|
||||
_log.i('Successfully converted to $lossyFormat: $convertedPath');
|
||||
// Extract bitrate for display (e.g., 'mp3_320' -> '320kbps')
|
||||
final bitrateDisplay = lossyBitrate.contains('_')
|
||||
? '${lossyBitrate.split('_').last}kbps'
|
||||
: (lossyFormat == 'opus' ? '128kbps' : '320kbps');
|
||||
actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay';
|
||||
_log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath');
|
||||
|
||||
// Embed metadata and cover for both MP3 and Opus
|
||||
_log.i('Embedding metadata to $lossyFormat...');
|
||||
|
||||
@@ -244,6 +244,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(lossyFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLossyBitrate(String bitrate) {
|
||||
// Extract format from bitrate (e.g., 'mp3_320' -> 'mp3')
|
||||
final format = bitrate.split('_').first;
|
||||
state = state.copyWith(lossyBitrate: bitrate, lossyFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -114,10 +114,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.lossyFormat,
|
||||
subtitle: settings.lossyFormat == 'opus'
|
||||
? 'Opus 128kbps'
|
||||
: 'MP3 320kbps',
|
||||
onTap: () => _showLossyFormatPicker(context, ref, settings.lossyFormat),
|
||||
subtitle: _getLossyBitrateLabel(settings.lossyBitrate),
|
||||
onTap: () => _showLossyBitratePicker(context, ref, settings.lossyBitrate),
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
@@ -733,7 +731,28 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showLossyFormatPicker(
|
||||
String _getLossyBitrateLabel(String bitrate) {
|
||||
switch (bitrate) {
|
||||
case 'mp3_320':
|
||||
return 'MP3 320kbps (Best)';
|
||||
case 'mp3_256':
|
||||
return 'MP3 256kbps';
|
||||
case 'mp3_192':
|
||||
return 'MP3 192kbps';
|
||||
case 'mp3_128':
|
||||
return 'MP3 128kbps';
|
||||
case 'opus_128':
|
||||
return 'Opus 128kbps (Best)';
|
||||
case 'opus_96':
|
||||
return 'Opus 96kbps';
|
||||
case 'opus_64':
|
||||
return 'Opus 64kbps';
|
||||
default:
|
||||
return 'MP3 320kbps';
|
||||
}
|
||||
}
|
||||
|
||||
void _showLossyBitratePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
@@ -742,54 +761,130 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.lossyFormat,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.lossyFormatDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.lossyFormat,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('MP3'),
|
||||
subtitle: Text(context.l10n.lossyFormatMp3Subtitle),
|
||||
trailing: current == 'mp3' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyFormat('mp3');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('Opus'),
|
||||
subtitle: Text(context.l10n.lossyFormatOpusSubtitle),
|
||||
trailing: current == 'opus' ? const Icon(Icons.check) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyFormat('opus');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.lossyFormatDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
// MP3 Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
|
||||
child: Text(
|
||||
'MP3',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('320kbps'),
|
||||
subtitle: const Text('Best quality, larger files'),
|
||||
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('256kbps'),
|
||||
subtitle: const Text('High quality'),
|
||||
trailing: current == 'mp3_256' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('192kbps'),
|
||||
subtitle: const Text('Good quality'),
|
||||
trailing: current == 'mp3_192' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_192');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: const Text('128kbps'),
|
||||
subtitle: const Text('Smaller files'),
|
||||
trailing: current == 'mp3_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('mp3_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const Divider(indent: 24, endIndent: 24),
|
||||
// Opus Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 4),
|
||||
child: Text(
|
||||
'Opus',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('128kbps'),
|
||||
subtitle: const Text('Best quality, efficient codec'),
|
||||
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('96kbps'),
|
||||
subtitle: const Text('Good quality'),
|
||||
trailing: current == 'opus_96' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_96');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: const Text('64kbps'),
|
||||
subtitle: const Text('Smallest files'),
|
||||
trailing: current == 'opus_64' ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLossyBitrate('opus_64');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -99,17 +99,30 @@ class FFmpegService {
|
||||
|
||||
/// Convert FLAC to lossy format based on format parameter
|
||||
/// format: 'mp3' or 'opus'
|
||||
/// bitrate: e.g., 'mp3_320', 'opus_128' - extracts the kbps value
|
||||
static Future<String?> convertFlacToLossy(
|
||||
String inputPath, {
|
||||
required String format,
|
||||
String? bitrate,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
// Extract bitrate value from format like 'mp3_320' -> '320k'
|
||||
String bitrateValue = '320k'; // default for mp3
|
||||
if (bitrate != null && bitrate.contains('_')) {
|
||||
final parts = bitrate.split('_');
|
||||
if (parts.length == 2) {
|
||||
bitrateValue = '${parts[1]}k';
|
||||
}
|
||||
}
|
||||
|
||||
switch (format.toLowerCase()) {
|
||||
case 'opus':
|
||||
return convertFlacToOpus(inputPath, deleteOriginal: deleteOriginal);
|
||||
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
|
||||
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
|
||||
case 'mp3':
|
||||
default:
|
||||
return convertFlacToMp3(inputPath, deleteOriginal: deleteOriginal);
|
||||
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
|
||||
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user