Compare commits

...

13 Commits

Author SHA1 Message Date
neonItem
19af8bff2a
Merge 21afee3cfb into d3edf07ac9 2025-04-08 18:29:14 +09:00
Kingkor Roy Tirtho
d3edf07ac9 fix: default accent color in orange but it shows blue in settings 2025-04-07 16:16:43 +06:00
Kingkor Roy Tirtho
8fc319d980 fix(mobile): dialogs in bottom sheet are not opening 2025-04-07 14:53:05 +06:00
Seungmin Kim
e986baa0aa
chore: revise filter for ISRC search (#2614) 2025-04-07 13:12:45 +06:00
neonItem
21afee3cfb
Fix dependencies on Debian Testing/Unstable (rolling release) systems 2025-03-22 23:54:28 +02:00
Kingkor Roy Tirtho
ba27dc70e4
Merge pull request #2550 from KRTirtho/dev
Release 4.0.2
2025-03-16 23:57:54 +06:00
Kingkor Roy Tirtho
723b6b1f38
Merge pull request #2524 from KRTirtho/dev
Release 4.0.1
2025-03-15 17:21:27 +06:00
Kingkor Roy Tirtho
464666c01a
Merge pull request #2410 from KRTirtho/dev
chore: update linux appdata screenshot
2025-03-07 20:22:32 +06:00
Kingkor Roy Tirtho
0e58cd0e99
Merge pull request #2408 from KRTirtho/dev
chore: add new images
2025-03-07 20:18:03 +06:00
Kingkor Roy Tirtho
d4f70f56e4
Merge pull request #2405 from KRTirtho/dev
Release 4.0.0
2025-03-07 18:05:55 +06:00
Kingkor Roy Tirtho
8c1337d1fc
Merge pull request #2118 from KRTirtho/dev
chore: release 3.9.0
2024-12-09 00:04:29 +06:00
Kingkor Roy Tirtho
94e704087f Merge branch 'dev' 2024-10-09 16:38:23 +06:00
Kingkor Roy Tirtho
8e287ab1e5
Merge pull request #1981 from KRTirtho/dev
Release 3.8.3
2024-10-09 15:39:31 +06:00
17 changed files with 3780 additions and 75 deletions

View File

@ -3,4 +3,17 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name="${applicationName}"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_en"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<!-- Disable Impeller -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application>
</manifest>

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,3 @@
import 'package:flutter/material.dart' show showModalBottomSheet;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
@ -26,7 +25,7 @@ class AdaptiveMenuButton<T> extends MenuButton {
/// An adaptive widget that shows a [PopupMenuButton] when screen size is above
/// or equal to 640px
/// In smaller screen, a [IconButton] with a [showModalBottomSheet] is shown
/// In smaller screen, a [IconButton] with a [openDrawer] is shown
class AdaptivePopSheetList<T> extends StatelessWidget {
final List<AdaptiveMenuButton<T>> Function(BuildContext context) items;
final Widget? icon;
@ -102,15 +101,13 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
return;
}
showModalBottomSheet(
await openDrawer(
context: context,
enableDrag: true,
draggable: true,
showDragHandle: true,
useRootNavigator: true,
shape: RoundedRectangleBorder(
borderRadius: context.theme.borderRadiusMd,
),
backgroundColor: context.theme.colorScheme.card,
position: OverlayPosition.bottom,
borderRadius: context.theme.borderRadiusMd,
transformBackdrop: false,
builder: (context) {
final children = childrenModified(context);
return ListView.builder(
@ -127,7 +124,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
onPressed: () {
data.onPressed?.call(context);
if (data.autoClose) {
Navigator.of(context).pop();
closeDrawer(context);
}
},
leading: data.leading,

View File

@ -74,6 +74,26 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
ref.watch(presentationStateProvider(options.collection).notifier);
final selectedTracks = state.selectedTracks;
Future<void> actionDownloadTracks({
required BuildContext context,
required List<Track> tracks,
required String action,
}) async {
final confirmed = audioSource == AudioSource.piped ||
(await showDialog<bool>(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
) ??
false);
if (confirmed != true) return;
downloader.batchAddToQueue(tracks);
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
}
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
@ -95,22 +115,12 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
(await showDialog<bool?>(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
) ??
false);
if (confirmed != true) return;
downloader.batchAddToQueue(tracks);
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
await actionDownloadTracks(
context: context,
tracks: tracks,
action: action,
);
break;
case "add-to-playlist":
{
if (context.mounted) {

View File

@ -57,7 +57,7 @@ class TrackPresentationTopSection extends HookConsumerWidget {
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.shuffle_playlist),
),
).call,
child: IconButton.secondary(
icon: isLoading
? const Center(
@ -73,7 +73,7 @@ class TrackPresentationTopSection extends HookConsumerWidget {
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_to_queue),
),
).call,
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
enabled: !isLoading && !isActive,
@ -126,7 +126,7 @@ class TrackPresentationTopSection extends HookConsumerWidget {
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.share),
),
).call,
child: IconButton.outline(
icon: const Icon(SpotubeIcons.share),
size: ButtonSize.small,

View File

@ -91,24 +91,14 @@ class TrackOptions extends HookConsumerWidget {
) {
/// showDialog doesn't work for some reason. So we have to
/// manually push a Dialog Route in the Navigator to get it working
Navigator.push(
context,
DialogRoute(
alignment: Alignment.bottomCenter,
transitionBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
context: context,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (context) {
return Center(
child: PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
),
);
},
),
showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
);
},
);
}
@ -338,6 +328,7 @@ class TrackOptions extends HookConsumerWidget {
}
},
icon: icon ?? const Icon(SpotubeIcons.moreHorizontal),
variance: ButtonVariance.outline,
headings: [
Basic(
leading: AspectRatio(

View File

@ -62,7 +62,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 4;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@ -87,6 +87,33 @@ class AppDatabase extends _$AppDatabase {
schema.preferencesTable.youtubeClientEngine,
);
},
from4To5: (m, schema) async {
final columnName = schema.preferencesTable.accentColorScheme
.escapedNameFor(SqlDialect.sqlite);
final columnNameOld =
'"${schema.preferencesTable.accentColorScheme.name}_old"';
final tableName = schema.preferencesTable.actualTableName;
await customStatement(
"ALTER TABLE $tableName "
"RENAME COLUMN $columnName to $columnNameOld",
);
await customStatement(
"ALTER TABLE $tableName "
"ADD COLUMN $columnName TEXT NOT NULL DEFAULT 'Orange:0xFFf97315'",
);
await customStatement(
"UPDATE $tableName "
"SET $columnName = $columnNameOld",
);
await customStatement(
"ALTER TABLE $tableName "
"DROP COLUMN $columnNameOld",
);
await customStatement(
"UPDATE $tableName "
"SET $columnName = 'Orange:0xFFf97315' WHERE $columnName = 'Blue:0xFF2196F3'",
);
},
),
);
}

View File

@ -666,7 +666,7 @@ class $PreferencesTableTable extends PreferencesTable
'accent_color_scheme', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant("Blue:0xFF2196F3"))
defaultValue: const Constant("Orange:0xFFf97315"))
.withConverter<SpotubeColor>(
$PreferencesTableTable.$converteraccentColorScheme);
static const VerificationMeta _layoutModeMeta =

View File

@ -2,7 +2,7 @@
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/services/sourced_track/enums.dart'; // ignore_for_file: type=lint,unused_import
@ -1188,10 +1188,232 @@ i1.GeneratedColumn<String> _column_54(String aliasedName) =>
i1.GeneratedColumn<String>('youtube_client_engine', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: Constant(YoutubeClientEngine.youtubeExplode.name));
final class Schema5 extends i0.VersionedSchema {
Schema5({required super.database}) : super(version: 5);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
authenticationTable,
blacklistTable,
preferencesTable,
scrobblerTable,
skipSegmentTable,
sourceMatchTable,
audioPlayerStateTable,
playlistTable,
playlistMediaTable,
historyTable,
lyricsTable,
uniqueBlacklist,
uniqTrackMatch,
];
late final Shape0 authenticationTable = Shape0(
source: i0.VersionedTable(
entityName: 'authentication_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
],
attachedDatabase: database,
),
alias: null);
late final Shape1 blacklistTable = Shape1(
source: i0.VersionedTable(
entityName: 'blacklist_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_4,
_column_5,
_column_6,
],
attachedDatabase: database,
),
alias: null);
late final Shape12 preferencesTable = Shape12(
source: i0.VersionedTable(
entityName: 'preferences_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
_column_13,
_column_14,
_column_15,
_column_55,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_23,
_column_24,
_column_25,
_column_26,
_column_54,
_column_27,
_column_28,
_column_29,
_column_30,
_column_31,
_column_53,
],
attachedDatabase: database,
),
alias: null);
late final Shape3 scrobblerTable = Shape3(
source: i0.VersionedTable(
entityName: 'scrobbler_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_32,
_column_33,
_column_34,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 skipSegmentTable = Shape4(
source: i0.VersionedTable(
entityName: 'skip_segment_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_35,
_column_36,
_column_37,
_column_32,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 sourceMatchTable = Shape5(
source: i0.VersionedTable(
entityName: 'source_match_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_37,
_column_38,
_column_39,
_column_32,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 audioPlayerStateTable = Shape6(
source: i0.VersionedTable(
entityName: 'audio_player_state_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_40,
_column_41,
_column_42,
_column_43,
],
attachedDatabase: database,
),
alias: null);
late final Shape7 playlistTable = Shape7(
source: i0.VersionedTable(
entityName: 'playlist_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_44,
_column_45,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 playlistMediaTable = Shape8(
source: i0.VersionedTable(
entityName: 'playlist_media_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_46,
_column_47,
_column_48,
_column_49,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 historyTable = Shape9(
source: i0.VersionedTable(
entityName: 'history_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_32,
_column_50,
_column_51,
_column_52,
],
attachedDatabase: database,
),
alias: null);
late final Shape10 lyricsTable = Shape10(
source: i0.VersionedTable(
entityName: 'lyrics_table',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_0,
_column_37,
_column_52,
],
attachedDatabase: database,
),
alias: null);
final i1.Index uniqueBlacklist = i1.Index('unique_blacklist',
'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
final i1.Index uniqTrackMatch = i1.Index('uniq_track_match',
'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)');
}
i1.GeneratedColumn<String> _column_55(String aliasedName) =>
i1.GeneratedColumn<String>('accent_color_scheme', aliasedName, false,
type: i1.DriftSqlType.string,
defaultValue: const Constant("Orange:0xFFf97315"));
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -1210,6 +1432,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from3To4(migrator, schema);
return 4;
case 4:
final schema = Schema5(database: database);
final migrator = i1.Migrator(database, schema);
await from4To5(migrator, schema);
return 5;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -1220,10 +1447,12 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
from2To3: from2To3,
from3To4: from3To4,
from4To5: from4To5,
));

View File

@ -79,7 +79,7 @@ class PreferencesTable extends Table {
TextColumn get closeBehavior => textEnum<CloseBehavior>()
.withDefault(Constant(CloseBehavior.close.name))();
TextColumn get accentColorScheme => text()
.withDefault(const Constant("Blue:0xFF2196F3"))
.withDefault(const Constant("Orange:0xFFf97315"))
.map(const SpotubeColorConverter())();
TextColumn get layoutMode =>
textEnum<LayoutMode>().withDefault(Constant(LayoutMode.adaptive.name))();
@ -130,7 +130,7 @@ class PreferencesTable extends Table {
systemTitleBar: false,
skipNonMusic: false,
closeBehavior: CloseBehavior.close,
accentColorScheme: SpotubeColor(Colors.blue.value, name: "Blue"),
accentColorScheme: SpotubeColor(Colors.orange.value, name: "Orange"),
layoutMode: LayoutMode.adaptive,
locale: const Locale("system", "system"),
market: Market.US,

View File

@ -132,7 +132,7 @@ class PlayerView extends HookConsumerWidget {
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.details),
),
).call,
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.info, size: 18),
onPressed: currentTrack == null

View File

@ -82,7 +82,7 @@ class PlayerActions extends HookConsumerWidget {
children: [
if (showQueue)
Tooltip(
tooltip: TooltipContainer(child: Text(context.l10n.queue)),
tooltip: TooltipContainer(child: Text(context.l10n.queue)).call,
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.queue),
enabled: playlist.activeTrack != null,
@ -119,7 +119,8 @@ class PlayerActions extends HookConsumerWidget {
if (!isLocalTrack)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.alternative_track_sources)),
child: Text(context.l10n.alternative_track_sources),
).call,
child: IconButton.ghost(
enabled: playlist.activeTrack != null,
icon: const Icon(SpotubeIcons.alternativeRoute),
@ -160,7 +161,8 @@ class PlayerActions extends HookConsumerWidget {
else
Tooltip(
tooltip:
TooltipContainer(child: Text(context.l10n.download_track)),
TooltipContainer(child: Text(context.l10n.download_track))
.call,
child: IconButton.ghost(
icon: Icon(
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,

View File

@ -90,9 +90,9 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
Future<void> reset() async {
final db = ref.read(databaseProvider);
final query = db.update(db.preferencesTable)..where((t) => t.id.equals(0));
final query = db.update(db.preferencesTable);
await query.replace(PreferencesTableCompanion.insert());
await query.replace(PreferencesTableCompanion.insert(id: const Value(0)));
}
static Future<String> getMusicCacheDir() async {

View File

@ -251,19 +251,18 @@ class YoutubeSourcedTrack extends SourcedTrack {
.map((YoutubeVideoInfo videoInfo) {
final ytWords = videoInfo.title
.toLowerCase()
.replaceAll(RegExp(r'[^a-zA-Z0-9\s]+'), '')
.split(RegExp(r'\s+'))
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
final spWords = track.name!
.toLowerCase()
.replaceAll(RegExp(r'\((.*)\)'), '')
.replaceAll(RegExp(r'[^a-zA-Z0-9\s]+'), '')
.split(RegExp(r'\s+'))
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
// Word match to filter out unrelated results
final matchCount =
ytWords.where((word) => spWords.contains(word)).length;
if (matchCount > spWords.length ~/ 2) {
// Single word and duration match with 3 second tolerance
if (ytWords.any((word) => spWords.contains(word)) &&
(videoInfo.duration - track.duration!)
.abs().inMilliseconds <= 3000) {
return videoInfo;
}
return null;

View File

@ -15,7 +15,7 @@ dependencies:
- gir1.2-appindicator3-0.1 | gir1.2-ayatanaappindicator3-0.1
- libsecret-1-0
- libnotify-bin
- libjsoncpp25
- libjsoncpp25 | libjsoncpp26
- libmpv1 | libmpv2
- xdg-user-dirs
- avahi-daemon

View File

@ -3,27 +3,30 @@
// ignore_for_file: type=lint
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v4.dart' as v4;
import 'schema_v3.dart' as v3;
import 'schema_v2.dart' as v2;
import 'schema_v5.dart' as v5;
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
import 'schema_v4.dart' as v4;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
switch (version) {
case 4:
return v4.DatabaseAtV4(db);
case 3:
return v3.DatabaseAtV3(db);
case 2:
return v2.DatabaseAtV2(db);
case 5:
return v5.DatabaseAtV5(db);
case 1:
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
case 4:
return v4.DatabaseAtV4(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4];
static const versions = const [1, 2, 3, 4, 5];
}

File diff suppressed because it is too large Load Diff