when a song is played in search, now its mix gets played instead

This commit is contained in:
Pato05 2024-01-24 18:55:25 +01:00
parent 8fd84c3929
commit faec2af805
No known key found for this signature in database
GPG key ID: ED4C6F9C3D574FB6
19 changed files with 492 additions and 256 deletions

View file

@ -47,5 +47,10 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -1,6 +1,6 @@
import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/paths.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:json_annotation/json_annotation.dart';
import 'dart:async'; import 'dart:async';
@ -8,46 +8,82 @@ part 'cache.g.dart';
late Cache cache; late Cache cache;
class CacheEntryAdapter extends TypeAdapter<CacheEntry> {
@override
final int typeId = 20;
@override
CacheEntry read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CacheEntry(
fields[0],
updatedAt: fields[1] as DateTime?,
);
}
@override
void write(BinaryWriter writer, CacheEntry obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.value)
..writeByte(1)
..write(obj.updatedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CacheEntryAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class CacheEntry<T> {
final T value;
final DateTime updatedAt;
CacheEntry(this.value, {DateTime? updatedAt})
: updatedAt = updatedAt ?? DateTime.now();
}
//Cache for miscellaneous things //Cache for miscellaneous things
@HiveType(typeId: 22) @HiveType(typeId: 22)
@JsonSerializable()
class Cache { class Cache {
static Future<LazyBox<Cache>> get _box => static Future<LazyBox<Cache>> get _box async =>
Hive.openLazyBox<Cache>('metacache'); Hive.openLazyBox<Cache>('metacache', path: await Paths.cacheDir());
//ID's of tracks that are in library //ID's of tracks that are in library
@HiveField(0, defaultValue: []) @HiveField(0, defaultValue: [])
@JsonKey(defaultValue: [])
List<String> libraryTracks = []; List<String> libraryTracks = [];
//Track ID of logged track, to prevent duplicates //Track ID of logged track, to prevent duplicates
@HiveField(1) @HiveField(1)
@JsonKey(includeToJson: false, includeFromJson: false)
String? loggedTrackId; String? loggedTrackId;
@HiveField(2) @HiveField(2)
@JsonKey(defaultValue: [])
List<Track> history = []; List<Track> history = [];
//All sorting cached //All sorting cached
@HiveField(3) @HiveField(3)
@JsonKey(defaultValue: [])
List<Sorting?> sorts = []; List<Sorting?> sorts = [];
//Sleep timer //Sleep timer
@JsonKey(includeToJson: false, includeFromJson: false)
DateTime? sleepTimerTime; DateTime? sleepTimerTime;
@JsonKey(includeToJson: false, includeFromJson: false)
// ignore: cancel_subscriptions // ignore: cancel_subscriptions
StreamSubscription? sleepTimer; StreamSubscription? sleepTimer;
//Search history //Search history
@HiveField(4) @HiveField(4)
@JsonKey(name: 'searchHistory2', defaultValue: []) List<DeezerMediaItem> searchHistory = [];
List<SearchHistoryItem> searchHistory = [];
//If download threads warning was shown //If download threads warning was shown
@HiveField(5) @HiveField(5)
@JsonKey(defaultValue: false)
bool threadsWarning = false; bool threadsWarning = false;
//Last time update check //Last time update check
@ -62,9 +98,17 @@ class Cache {
@HiveField(9, defaultValue: false) @HiveField(9, defaultValue: false)
bool canStreamLossless = false; bool canStreamLossless = false;
@JsonKey(includeToJson: false, includeFromJson: false)
bool wakelock = false; bool wakelock = false;
@HiveField(10, defaultValue: null)
CacheEntry<Map<String, Playlist>>? favoritePlaylists;
@HiveField(11, defaultValue: null)
CacheEntry<Map<String, Artist>>? favoriteArtists;
@HiveField(12, defaultValue: null)
CacheEntry<Map<String, Album>>? favoriteAlbums;
@HiveField(13, defaultValue: null)
CacheEntry<List<Track>>? favoriteTracks;
Cache(); Cache();
//Wrapper to test if track is favorite against cache //Wrapper to test if track is favorite against cache
@ -74,27 +118,34 @@ class Cache {
return libraryTracks.contains(t.id); return libraryTracks.contains(t.id);
} }
/// Add [item] to the corresponding favorite* cached item
void addFavorite(DeezerMediaItem item) {
switch (item) {
case final Track track:
favoriteTracks?.value.add(track);
libraryTracks.add(track.id);
break;
case final Album album:
favoriteAlbums?.value[album.id!] = album;
break;
case final Playlist playlist:
favoritePlaylists?.value[playlist.id] = playlist;
break;
case final Artist artist:
favoriteArtists?.value[artist.id] = artist;
break;
}
}
//Add to history //Add to history
void addToSearchHistory(DeezerMediaItem item) async { void addToSearchHistory(DeezerMediaItem item) async {
// Remove duplicate // Remove duplicate
int i = searchHistory.indexWhere((e) => e.data.id == item.id); int i = searchHistory.indexWhere((e) => e.id == item.id);
if (i != -1) { if (i != -1) {
searchHistory.removeAt(i); searchHistory.removeAt(i);
} }
if (item is Track) { searchHistory.add(item);
searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.track));
}
if (item is Album) {
searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.album));
}
if (item is Artist) {
searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.artist));
}
if (item is Playlist) {
searchHistory
.add(SearchHistoryItem(item, SearchHistoryItemType.playlist));
}
await save(); await save();
} }
@ -134,64 +185,9 @@ class Cache {
await box.clear(); await box.clear();
await box.put(0, this); await box.put(0, this);
} }
//JSON
factory Cache.fromJson(Map<String, dynamic> json) => _$CacheFromJson(json);
Map<String, dynamic> toJson() => _$CacheToJson(this);
//Search History JSON
// static List<SearchHistoryItem> _searchHistoryFromJson(List<dynamic>? json) {
// return (json ?? [])
// .map<SearchHistoryItem>((i) => _searchHistoryItemFromJson(i))
// .toList();
// }
// static SearchHistoryItem _searchHistoryItemFromJson(
// Map<String, dynamic> json) {
// SearchHistoryItemType type = SearchHistoryItemType.values[json['type']];
// dynamic data;
// switch (type) {
// case SearchHistoryItemType.TRACK:
// data = Track.fromJson(json['data']);
// break;
// case SearchHistoryItemType.ALBUM:
// data = Album.fromJson(json['data']);
// break;
// case SearchHistoryItemType.ARTIST:
// data = Artist.fromJson(json['data']);
// break;
// case SearchHistoryItemType.PLAYLIST:
// data = Playlist.fromJson(json['data']);
// break;
// }
// return SearchHistoryItem(data, type);
// }
}
@HiveType(typeId: 20)
@JsonSerializable()
class SearchHistoryItem {
@HiveField(0)
@JsonKey(
toJson: _searchHistoryItemTypeToJson,
fromJson: _searchHistoryItemTypeFromJson)
SearchHistoryItemType type;
@HiveField(1)
// TODO: make this type-safe
dynamic data;
SearchHistoryItem(this.data, this.type);
Map<String, dynamic> toJson() => _$SearchHistoryItemToJson(this);
factory SearchHistoryItem.fromJson(Map<String, dynamic> json) =>
_$SearchHistoryItemFromJson(json);
static int _searchHistoryItemTypeToJson(SearchHistoryItemType type) =>
type.index;
static SearchHistoryItemType _searchHistoryItemTypeFromJson(int index) =>
SearchHistoryItemType.values[index];
} }
@deprecated
@HiveType(typeId: 21) @HiveType(typeId: 21)
enum SearchHistoryItemType { enum SearchHistoryItemType {
@HiveField(0) @HiveField(0)

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart'; import 'package:cookie_jar/cookie_jar.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -44,8 +46,13 @@ class DeezerAPI {
cookieJar.delete(Uri.https('www.deezer.com')); cookieJar.delete(Uri.https('www.deezer.com'));
return; return;
} }
cookieJar cookieJar.saveFromResponse(Uri.https('www.deezer.com'), [
.saveFromResponse(Uri.https('www.deezer.com'), [Cookie('arl', arl)]); Cookie('arl', arl)
..domain = '.deezer.com'
..httpOnly = true
..sameSite = SameSite.none
..secure = true
]);
} }
String? token; String? token;
@ -89,9 +96,24 @@ class DeezerAPI {
dio.options.headers = headers; dio.options.headers = headers;
} }
Future<Map<dynamic, dynamic>> callPipeApi(
String operationName, String query, Map<String, dynamic> variables,
{CancelToken? cancelToken}) async {
final res = await dio.post('https://pipe.deezer.com/api',
data: jsonEncode({
'operationName': operationName,
'variables': variables,
'query': query,
}),
cancelToken: cancelToken);
return res.data;
}
//Call private API //Call private API
Future<Map<dynamic, dynamic>> callApi(String method, Future<Map<dynamic, dynamic>> callApi(String method,
{Map<dynamic, dynamic>? params, String? gatewayInput}) async { {Map<dynamic, dynamic>? params,
String? gatewayInput,
CancelToken? cancelToken}) async {
//Post //Post
final res = await dio.post('https://www.deezer.com/ajax/gw-light.php', final res = await dio.post('https://www.deezer.com/ajax/gw-light.php',
queryParameters: { queryParameters: {
@ -102,7 +124,8 @@ class DeezerAPI {
//Used for homepage //Used for homepage
if (gatewayInput != null) 'gateway_input': gatewayInput if (gatewayInput != null) 'gateway_input': gatewayInput
}, },
data: jsonEncode(params)); data: jsonEncode(params),
cancelToken: cancelToken);
final body = res.data; final body = res.data;
// In case of error "Invalid CSRF token" retrieve new one and retry the same call // In case of error "Invalid CSRF token" retrieve new one and retry the same call
@ -310,6 +333,17 @@ class DeezerAPI {
}).toList(growable: false); }).toList(growable: false);
} }
// TODO: Not working
Future<(String, DateTime)> getTrackToken(String trackId) async {
final data = await callPipeApi(
'TrackMediaToken',
"query TrackMediaToken(\$trackId: String!) {\n track(trackId: \$trackId) {\n media {\n token {\n payload\n expiresAt\n __typename\n }\n __typename\n }\n __typename\n }\n}",
{'trackId': trackId},
);
return data['data']['track']['media']['token'];
}
//Search //Search
Future<SearchResults> search(String? query) async { Future<SearchResults> search(String? query) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch', Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
@ -395,6 +429,9 @@ class DeezerAPI {
await callApi('artist.addFavorite', params: {'ART_ID': id}); await callApi('artist.addFavorite', params: {'ART_ID': id});
} }
Future<void> addFavoriteShow(String id) =>
callApi('show.addFavorite', params: {'SHOW_ID': id});
//Remove artist from favorites/library //Remove artist from favorites/library
Future removeArtist(String? id) async { Future removeArtist(String? id) async {
await callApi('artist.deleteFavorite', params: {'ART_ID': id}); await callApi('artist.deleteFavorite', params: {'ART_ID': id});
@ -430,7 +467,7 @@ class DeezerAPI {
//Get users playlists //Get users playlists
Future<List<Playlist>> getPlaylists() async { Future<List<Playlist>> getPlaylists() async {
Map data = await callApi('deezer.pageProfile', Map data = await callApi('deezer.pageProfile',
params: {'nb': 100, 'tab': 'playlists', 'user_id': userId}); params: {'nb': 2000, 'tab': 'playlists', 'user_id': userId});
return data['results']['TAB']['playlists']['data'] return data['results']['TAB']['playlists']['data']
.map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true)) .map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true))
.toList(); .toList();
@ -439,7 +476,7 @@ class DeezerAPI {
//Get favorite albums //Get favorite albums
Future<List<Album>> getAlbums() async { Future<List<Album>> getAlbums() async {
Map data = await callApi('deezer.pageProfile', Map data = await callApi('deezer.pageProfile',
params: {'nb': 50, 'tab': 'albums', 'user_id': userId}); params: {'nb': 2000, 'tab': 'albums', 'user_id': userId});
List albumList = data['results']['TAB']['albums']['data']; List albumList = data['results']['TAB']['albums']['data'];
List<Album> albums = albumList List<Album> albums = albumList
.map<Album>((json) => Album.fromPrivateJson(json, library: true)) .map<Album>((json) => Album.fromPrivateJson(json, library: true))
@ -448,15 +485,18 @@ class DeezerAPI {
} }
//Remove album from library //Remove album from library
Future removeAlbum(String? id) async { Future<void> removeAlbum(String? id) async {
await callApi('album.deleteFavorite', params: {'ALB_ID': id}); await callApi('album.deleteFavorite', params: {'ALB_ID': id});
} }
//Remove track from favorites //Remove track from favorites
Future removeFavorite(String id) async { Future<void> removeFavorite(String id) async {
await callApi('favorite_song.remove', params: {'SNG_ID': id}); await callApi('favorite_song.remove', params: {'SNG_ID': id});
} }
Future<void> removeFavoriteShow(String id) =>
callApi('show.deleteFavorite', params: {'SHOW_ID': id});
//Get favorite artists //Get favorite artists
Future<List<Artist>?> getArtists() async { Future<List<Artist>?> getArtists() async {
Map data = await callApi('deezer.pageProfile', Map data = await callApi('deezer.pageProfile',
@ -467,11 +507,13 @@ class DeezerAPI {
} }
//Get lyrics by track id //Get lyrics by track id
Future<Lyrics> lyrics(String? trackId) async { Future<Lyrics> lyrics(String? trackId, {CancelToken? cancelToken}) async {
Map data = await callApi('song.getLyrics', params: {'sng_id': trackId}); Map data = await callApi('song.getLyrics',
params: {'sng_id': trackId}, cancelToken: cancelToken);
if (data['error'] != null && data['error'].length > 0) { if (data['error'] != null && data['error'].length > 0) {
return Lyrics.error(); throw Exception('Deezer reported error: ${data['error']}');
} }
print(data);
return Lyrics.fromPrivateJson(data['results']); return Lyrics.fromPrivateJson(data['results']);
} }
@ -505,7 +547,8 @@ class DeezerAPI {
'show', 'show',
'smarttracklist', 'smarttracklist',
'track', 'track',
'user' 'user',
'external-link'
]; ];
Map data = await callApi('page.get', Map data = await callApi('page.get',
gatewayInput: jsonEncode({ gatewayInput: jsonEncode({
@ -663,9 +706,10 @@ class DeezerAPI {
.toList(); .toList();
} }
Future<List<String>?> searchSuggestions(String? query) async { Future<List<String>?> searchSuggestions(String? query,
Map data = {CancelToken? cancelToken}) async {
await callApi('search_getSuggestedQueries', params: {'QUERY': query}); Map data = await callApi('search_getSuggestedQueries',
params: {'QUERY': query}, cancelToken: cancelToken);
return (data['results']['SUGGESTION'] as List?) return (data['results']['SUGGESTION'] as List?)
?.map<String>((s) => s['QUERY'] as String) ?.map<String>((s) => s['QUERY'] as String)
.toList(); .toList();
@ -702,11 +746,24 @@ class DeezerAPI {
} }
//Get similar tracks for track with id [trackId] //Get similar tracks for track with id [trackId]
Future<List<Track>?> playMix(String? trackId) async { Future<List<Track>> playMix(String? trackId) async {
Map data = await callApi('song.getContextualTrackMix', params: { Map data = await callApi('song.getContextualTrackMix', params: {
'sng_ids': [trackId] 'sng_ids': [trackId]
}); });
return data['results']['data'] return data['results']['data']!
.map<Track>((t) => Track.fromPrivateJson(t))
.toList();
}
Future<List<Track>> getSearchTrackMix(String trackId,
[bool? startWithInputTrack = true]) async {
Map data = await callApi('song.getSearchTrackMix', params: {
'sng_id': trackId,
if (startWithInputTrack != null)
'start_with_input_track': startWithInputTrack,
});
return data['results']['data']!
.map<Track>((t) => Track.fromPrivateJson(t)) .map<Track>((t) => Track.fromPrivateJson(t))
.toList(); .toList();
} }
@ -725,3 +782,9 @@ class DeezerAPI {
.toList(); .toList();
} }
} }
class PipeAPI {
PipeAPI._();
Future<void> getTrackToken(String trackId) async {}
}

View file

@ -92,7 +92,7 @@ class Track extends DeezerMediaItem {
} }
//MediaItem //MediaItem
Future<MediaItem> toMediaItem() async { MediaItem toMediaItem() {
return MediaItem( return MediaItem(
title: title!, title: title!,
album: album!.title!, album: album!.title!,
@ -641,7 +641,7 @@ class DeezerImageDetails extends ImageDetails {
@override @override
String get full => size(1000, 1000); String get full => size(1000, 1000);
@override @override
String get thumb => size(140, 140); String get thumb => size(264, 264);
String size(int width, int height, String size(int width, int height,
{int num = 80, String id = '000000', String format = 'jpg'}) => {int num = 80, String id = '000000', String format = 'jpg'}) =>
@ -957,18 +957,18 @@ class HomePageSection {
//JSON //JSON
static HomePageSection? fromPrivateJson(Map<dynamic, dynamic> json) { static HomePageSection? fromPrivateJson(Map<dynamic, dynamic> json) {
final layout = { final layout = const <String, HomePageSectionLayout>{
'horizontal-grid': HomePageSectionLayout.ROW, 'horizontal-grid': HomePageSectionLayout.row,
'filterable-grid': HomePageSectionLayout.ROW, 'filterable-grid': HomePageSectionLayout.row,
'grid-preview-two': HomePageSectionLayout.ROW, 'grid-preview-two': HomePageSectionLayout.row,
'grid': HomePageSectionLayout.GRID 'grid': HomePageSectionLayout.grid,
'slideshow': HomePageSectionLayout.slideshow,
}[json['layout'] ?? '']; }[json['layout'] ?? ''];
if (layout == null) { if (layout == null) {
_logger.warning('UNKNOWN LAYOUT: ${json['layout']}'); _logger.warning('UNKNOWN LAYOUT: ${json['layout']}');
_logger.warning('LAYOUT DATA:');
_logger.warning(json);
return null; return null;
} }
_logger.fine('LAYOUT: $layout');
final items = <HomePageItem>[]; final items = <HomePageItem>[];
for (var i in (json['items'] ?? [])) { for (var i in (json['items'] ?? [])) {
HomePageItem? hpi = HomePageItem.fromPrivateJson(i); HomePageItem? hpi = HomePageItem.fromPrivateJson(i);
@ -1020,7 +1020,7 @@ class HomePageItem {
case 'channel': case 'channel':
return HomePageItem( return HomePageItem(
type: HomePageItemType.CHANNEL, type: HomePageItemType.CHANNEL,
value: DeezerChannel.fromPrivateJson(json)); value: DeezerChannel.fromPrivateJson(json, false));
case 'album': case 'album':
return HomePageItem( return HomePageItem(
type: HomePageItemType.ALBUM, type: HomePageItemType.ALBUM,
@ -1029,6 +1029,10 @@ class HomePageItem {
return HomePageItem( return HomePageItem(
type: HomePageItemType.SHOW, type: HomePageItemType.SHOW,
value: Show.fromPrivateJson(json['data'])); value: Show.fromPrivateJson(json['data']));
case 'external-link':
return HomePageItem(
type: HomePageItemType.EXTERNAL_LINK,
value: DeezerChannel.fromPrivateJson(json, true));
default: default:
return null; return null;
} }
@ -1060,6 +1064,10 @@ class HomePageItem {
return HomePageItem( return HomePageItem(
type: HomePageItemType.SHOW, type: HomePageItemType.SHOW,
value: Show.fromPrivateJson(json['value'])); value: Show.fromPrivateJson(json['value']));
case 'EXTERNAL_LINK':
return HomePageItem(
type: HomePageItemType.EXTERNAL_LINK,
value: DeezerChannel.fromJson(json['value']));
default: default:
throw Exception('Unexpected type $t for HomePageItem'); throw Exception('Unexpected type $t for HomePageItem');
} }
@ -1090,15 +1098,21 @@ class DeezerChannel {
@HiveField(5, defaultValue: null) @HiveField(5, defaultValue: null)
final DeezerImageDetails? picture; final DeezerImageDetails? picture;
@JsonKey(defaultValue: false)
@HiveField(6, defaultValue: false)
final bool isExternalLink;
const DeezerChannel( const DeezerChannel(
{this.id, {this.id,
this.title, this.title,
this.backgroundColor = Colors.blue, this.backgroundColor = Colors.blue,
this.target, this.target,
this.logo, this.logo,
this.picture}); this.picture,
this.isExternalLink = false});
factory DeezerChannel.fromPrivateJson(Map<dynamic, dynamic> json) => factory DeezerChannel.fromPrivateJson(
Map<dynamic, dynamic> json, bool isExternalLink) =>
DeezerChannel( DeezerChannel(
id: json['id'], id: json['id'],
title: json['title'], title: json['title'],
@ -1112,7 +1126,8 @@ class DeezerChannel {
: null, : null,
picture: json.containsKey('pictures') && json['pictures'].length > 0 picture: json.containsKey('pictures') && json['pictures'].length > 0
? DeezerImageDetails.fromPrivateJson(json['pictures'][0]) ? DeezerImageDetails.fromPrivateJson(json['pictures'][0])
: null); : null,
isExternalLink: isExternalLink);
factory DeezerChannel.fromJson(Map<String, dynamic> json) => factory DeezerChannel.fromJson(Map<String, dynamic> json) =>
_$DeezerChannelFromJson(json); _$DeezerChannelFromJson(json);
@ -1136,14 +1151,21 @@ enum HomePageItemType {
ALBUM, ALBUM,
@HiveField(5) @HiveField(5)
SHOW, SHOW,
@HiveField(6)
EXTERNAL_LINK,
} }
@HiveType(typeId: 3) @HiveType(typeId: 3)
enum HomePageSectionLayout { enum HomePageSectionLayout {
@HiveField(0) @HiveField(0)
ROW, row,
@HiveField(1) @HiveField(1)
GRID, grid,
/// ROW but bigger
@HiveField(2)
slideshow,
} }
enum RepeatType { NONE, LIST, TRACK } enum RepeatType { NONE, LIST, TRACK }

View file

@ -338,15 +338,15 @@ class DownloadManager {
} }
//Get all offline available tracks //Get all offline available tracks
Future<List<Track?>> allOfflineTracks() async { Future<List<Track>> allOfflineTracks() async {
if (!isSupported) return []; if (!isSupported) return [];
List rawTracks = List rawTracks =
await db.query('Tracks', where: 'offline == 1', columns: ['id']); await db.query('Tracks', where: 'offline == 1', columns: ['id']);
List<Track?> out = []; List<Track> out = [];
//Load track meta individually //Load track meta individually
for (Map rawTrack in rawTracks as Iterable<Map<dynamic, dynamic>>) { for (Map rawTrack in rawTracks as Iterable<Map<dynamic, dynamic>>) {
out.add(await getOfflineTrack(rawTrack['id'])); out.add((await getOfflineTrack(rawTrack['id']))!);
} }
return out; return out;
} }

View file

@ -215,7 +215,7 @@ class DeezerImageDetails {
} }
} }
@collection @embedded
class Lyrics { class Lyrics {
late final String lyricsId; late final String lyricsId;
late final String writers; late final String writers;

View file

@ -893,11 +893,13 @@ class AudioPlayerTask extends BaseAudioHandler {
case 'mix': case 'mix':
tracks = await _deezerAPI.playMix(queueSource!.id); tracks = await _deezerAPI.playMix(queueSource!.id);
// Deduplicate tracks with the same id // Deduplicate tracks with the same id
List<String> queueIds = queue.value.map((e) => e.id).toList(); // List<String> queueIds = queue.value.map((e) => e.id).toList();
tracks?.removeWhere((track) => queueIds.contains(track.id)); // tracks?.removeWhere((track) => queueIds.contains(track.id));
break; break;
case 'smarttracklist': case 'smarttracklist':
tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks; tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks;
case 'searchMix':
tracks = await _deezerAPI.getSearchTrackMix(queueSource!.id!, null);
default: default:
return; return;
// print(queueSource.toJson()); // print(queueSource.toJson());
@ -908,8 +910,8 @@ class AudioPlayerTask extends BaseAudioHandler {
'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})'); 'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})');
} }
final mi = await Future.wait( final mi =
tracks.map<Future<MediaItem>>((t) => t.toMediaItem())); tracks.map<MediaItem>((t) => t.toMediaItem()).toList(growable: false);
await addQueueItems(mi); await addQueueItems(mi);
} }

View file

@ -185,12 +185,12 @@ class PlayerHelper {
} }
//Replace queue, play specified track id //Replace queue, play specified track id
Future<void> _loadQueuePlay(List<MediaItem> queue, String? trackId) async { Future<void> _loadQueuePlay(List<MediaItem> queue, int? index) async {
await settings.updateAudioServiceQuality(); await settings.updateAudioServiceQuality();
await audioHandler.customAction('setIndex', { if (index != null) {
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId) await audioHandler.customAction('setIndex', {'index': index});
}); }
await audioHandler.updateQueue(queue); await audioHandler.updateQueue(queue);
// if (queue[0].id != trackId) // if (queue[0].id != trackId)
// await AudioService.skipToQueueItem(trackId); // await AudioService.skipToQueueItem(trackId);
@ -206,7 +206,7 @@ class PlayerHelper {
//Play mix by track //Play mix by track
Future playMix(String trackId, String trackTitle) async { Future playMix(String trackId, String trackTitle) async {
List<Track> tracks = (await deezerAPI.playMix(trackId))!; List<Track> tracks = (await deezerAPI.playMix(trackId))!;
playFromTrackList( await playFromTrackList(
tracks, tracks,
tracks[0].id, tracks[0].id,
QueueSource( QueueSource(
@ -215,6 +215,35 @@ class PlayerHelper {
source: 'mix')); source: 'mix'));
} }
Future<void> playSearchMixDeferred(Track track) async {
final playFuture = playFromTrackList(
[track],
null,
QueueSource(
id: track.id,
text: "${'Mix based on'.i18n} ${track.title}",
source: 'searchMix'));
List<Track> tracks = await deezerAPI.getSearchTrackMix(track.id, false);
// discard first track (if it is the searched track)
if (tracks[0].id == track.id) tracks.removeAt(0);
await playFuture; // avoid race conditions
// update queue with mix
await audioHandler.addQueueItems(
tracks.map((e) => e.toMediaItem()).toList(growable: false));
}
Future<void> playSearchMix(String trackId, String trackTitle) async {
List<Track> tracks = await deezerAPI.getSearchTrackMix(trackId, true);
await playFromTrackList(
tracks,
null, // we can avoid passing it, as the index is 0
QueueSource(
id: trackId,
text: "${'Mix based on'.i18n} $trackTitle",
source: 'searchMix'));
}
//Play from artist top tracks //Play from artist top tracks
Future playFromTopTracks( Future playFromTopTracks(
List<Track> tracks, String trackId, Artist artist) async { List<Track> tracks, String trackId, Artist artist) async {
@ -249,17 +278,17 @@ class PlayerHelper {
} }
//Load tracks as queue, play track id, set queue source //Load tracks as queue, play track id, set queue source
Future playFromTrackList( Future<void> playFromTrackList(
List<Track?> tracks, String? trackId, QueueSource queueSource) async { List<Track> tracks, String? trackId, QueueSource queueSource) async {
final queue = await Future.wait(tracks final queue =
.map<Future<MediaItem>>((track) => track!.toMediaItem()) tracks.map<MediaItem>((track) => track!.toMediaItem()).toList();
.toList());
await setQueueSource(queueSource); await setQueueSource(queueSource);
await _loadQueuePlay(queue, trackId); await _loadQueuePlay(
queue, trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId));
} }
//Load smart track list as queue, start from beginning //Load smart track list as queue, start from beginning
Future playFromSmartTrackList(SmartTrackList stl) async { Future<void> playFromSmartTrackList(SmartTrackList stl) async {
//Load from API if no tracks //Load from API if no tracks
if (stl.tracks == null || stl.tracks!.isEmpty) { if (stl.tracks == null || stl.tracks!.isEmpty) {
if (settings.offlineMode) { if (settings.offlineMode) {

View file

@ -76,7 +76,7 @@ void main() async {
..registerAdapter(SortingAdapter()) ..registerAdapter(SortingAdapter())
..registerAdapter(SortTypeAdapter()) ..registerAdapter(SortTypeAdapter())
..registerAdapter(SortSourceTypesAdapter()) ..registerAdapter(SortSourceTypesAdapter())
..registerAdapter(SearchHistoryItemAdapter()) ..registerAdapter(CacheEntryAdapter())
..registerAdapter(SearchHistoryItemTypeAdapter()) ..registerAdapter(SearchHistoryItemTypeAdapter())
..registerAdapter(CacheAdapter()) ..registerAdapter(CacheAdapter())
..registerAdapter(ColorAdapter()) ..registerAdapter(ColorAdapter())
@ -92,7 +92,7 @@ void main() async {
..registerAdapter(QueueSourceAdapter()) ..registerAdapter(QueueSourceAdapter())
..registerAdapter(HomePageAdapter()) ..registerAdapter(HomePageAdapter())
..registerAdapter(NavigationRailAppearanceAdapter()) ..registerAdapter(NavigationRailAppearanceAdapter())
..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); ..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); // not working?
Hive.init(await Paths.dataDirectory()); Hive.init(await Paths.dataDirectory());

View file

@ -356,6 +356,7 @@ class _ArtistDetailsState extends State<ArtistDetails> {
maxHeight: MediaQuery.of(context).size.height / 3), maxHeight: MediaQuery.of(context).size.height / 3),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Flexible( Flexible(
child: ZoomableImage( child: ZoomableImage(
@ -368,7 +369,7 @@ class _ArtistDetailsState extends State<ArtistDetails> {
MediaQuery.of(context).size.width / 16, 60.0)), MediaQuery.of(context).size.width / 16, 60.0)),
Expanded( Expanded(
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
@ -847,6 +848,8 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
setState(() { setState(() {
playlist = p; playlist = p;
}); });
// update cache
cache.favoritePlaylists?.value[playlist!.id] = p;
//Load tracks //Load tracks
_load(); _load();
}).catchError((e) { }).catchError((e) {
@ -872,7 +875,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
children: <Widget>[ children: <Widget>[
const SizedBox(height: 4.0), const SizedBox(height: 4.0),
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints.tight( constraints: BoxConstraints.loose(
Size.fromHeight(MediaQuery.of(context).size.height / 3)), Size.fromHeight(MediaQuery.of(context).size.height / 3)),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(

View file

@ -0,0 +1,51 @@
import 'package:cookie_jar/cookie_jar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as fwv;
import 'package:freezer/api/deezer.dart';
import 'package:freezer/settings.dart';
class ExternalLinkRoute extends StatelessWidget {
final String title;
final String target;
const ExternalLinkRoute(
{required this.title, required this.target, super.key});
Uri _resolveTarget(String target) {
print('target: $target');
if (target == 'story/mdy' || target == '/story/mdy') {
// my deezer year redirect to iframe URL
return Uri.parse(
'https://mydeezerstory.deezer.com/inapp?campaign=mdy&lang=${settings.deezerLanguage}');
}
// resolve relative to www.deezer.com
return Uri.https('www.deezer.com', '/us').resolve(target);
}
Future<Map<String, String>> _resolveHeaders(Uri uri) async {
List<Cookie> cookies = await deezerAPI.cookieJar.loadForRequest(uri);
print(cookies);
return {'Cookie': cookies.join(';')};
}
@override
Widget build(BuildContext context) {
Uri uriTarget = _resolveTarget(target);
return Scaffold(
appBar: AppBar(title: Text(title)),
body: fwv.InAppWebView(
onWebViewCreated: (controller) async {
final uri = _resolveTarget(target);
final headers = await _resolveHeaders(uri);
controller.addWebMessageListener(fwv.WebMessageListener(
jsObjectName: 'jsObjectName',
onPostMessage: (message, sourceOrigin, isMainFrame, replyProxy) =>
print('message: $message, sourceOrigin: $sourceOrigin'),
));
controller.loadUrl(
urlRequest: fwv.URLRequest(url: uri, headers: headers));
},
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/api/player/audio_handler.dart';
import 'package:freezer/main.dart'; import 'package:freezer/main.dart';
import 'package:freezer/ui/error.dart'; import 'package:freezer/ui/error.dart';
import 'package:freezer/ui/external_link_route.dart';
import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/menu.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
import 'tiles.dart'; import 'tiles.dart';
@ -43,7 +44,17 @@ class HomeScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( final actualScrollConfiguration = ScrollConfiguration.of(context);
return ScrollConfiguration(
behavior: actualScrollConfiguration.copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad
},
),
child: SafeArea(
child: Scaffold( child: Scaffold(
body: NestedScrollView( body: NestedScrollView(
floatHeaderSlivers: true, floatHeaderSlivers: true,
@ -54,6 +65,7 @@ class HomeScreen extends StatelessWidget {
body: const HomePageWidget(cacheable: true), body: const HomePageWidget(cacheable: true),
), ),
), ),
),
); );
} }
} }
@ -189,9 +201,10 @@ class _HomePageWidgetState extends State<HomePageWidget> {
Widget getSectionChild(HomePageSection section) { Widget getSectionChild(HomePageSection section) {
switch (section.layout) { switch (section.layout) {
case HomePageSectionLayout.GRID: case HomePageSectionLayout.grid:
return HomePageGridSection(section); return HomePageGridSection(section);
case HomePageSectionLayout.ROW: case HomePageSectionLayout.slideshow:
case HomePageSectionLayout.row:
default: default:
return HomepageRowSection(section); return HomepageRowSection(section);
} }
@ -205,17 +218,7 @@ class _HomePageWidgetState extends State<HomePageWidget> {
sections = _homePage!.sections; sections = _homePage!.sections;
} }
final actualScrollConfiguration = ScrollConfiguration.of(context); return RefreshIndicator(
return ScrollConfiguration(
behavior: actualScrollConfiguration.copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.trackpad
},
),
child: RefreshIndicator(
key: _indicatorKey, key: _indicatorKey,
onRefresh: _load, onRefresh: _load,
child: _homePage == null child: _homePage == null
@ -231,7 +234,6 @@ class _HomePageWidgetState extends State<HomePageWidget> {
itemCount: sections!.length, itemCount: sections!.length,
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
), ),
),
); );
} }
} }
@ -356,7 +358,8 @@ class HomePageGridSection extends StatelessWidget {
class HomePageItemWidget extends StatelessWidget { class HomePageItemWidget extends StatelessWidget {
final HomePageItem item; final HomePageItem item;
const HomePageItemWidget(this.item, {super.key}); final Size? itemSize;
const HomePageItemWidget(this.item, {super.key, this.itemSize});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -415,6 +418,19 @@ class HomePageItemWidget extends StatelessWidget {
); );
}, },
); );
case HomePageItemType.EXTERNAL_LINK:
return ChannelTile(
item.value,
onTap: () {
final channel = item.value as DeezerChannel;
Navigator.of(context).pushRoute(
builder: (context) => ExternalLinkRoute(
target: channel.target!,
title: channel.title ?? '',
),
);
},
);
case HomePageItemType.SHOW: case HomePageItemType.SHOW:
return ShowCard( return ShowCard(
item.value, item.value,

View file

@ -237,7 +237,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
bool _loadingTracks = false; bool _loadingTracks = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
List<Track> tracks = []; List<Track> tracks = [];
List<Track?> allTracks = []; List<Track> allTracks = [];
int? trackCount; int? trackCount;
Sorting? _sort = Sorting(sourceType: SortSourceTypes.TRACKS); Sorting? _sort = Sorting(sourceType: SortSourceTypes.TRACKS);
@ -377,7 +377,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
} }
Future _loadAllOffline() async { Future _loadAllOffline() async {
List<Track?> tracks = await downloadManager.allOfflineTracks(); List<Track> tracks = await downloadManager.allOfflineTracks();
setState(() { setState(() {
allTracks = tracks; allTracks = tracks;
}); });
@ -955,10 +955,33 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
} }
Future _load() async { Future _load() async {
if (cache.favoritePlaylists != null) {
setState(() => _playlists =
cache.favoritePlaylists!.value.values.toList(growable: false));
if (DateTime.now().difference(cache.favoritePlaylists!.updatedAt) <
const Duration(hours: 1)) return;
}
if (!settings.offlineMode) { if (!settings.offlineMode) {
try { try {
final List<Playlist> playlists = await deezerAPI.getPlaylists(); final List<Playlist> playlists = await deezerAPI.getPlaylists();
setState(() => _playlists = playlists); setState(() => _playlists = playlists);
if (cache.favoritePlaylists == null) {
cache.favoritePlaylists =
CacheEntry({for (final p in playlists) p.id: p});
} else {
// update non-destructively
final oldEntry = cache.favoritePlaylists!.value;
final newEntry = <String, Playlist>{};
for (final playlist in playlists) {
if (oldEntry.containsKey(playlist.id)) {
newEntry[playlist.id] = oldEntry[playlist.id]!;
} else {
newEntry[playlist.id] = playlist;
}
}
}
await cache.save();
return; return;
} catch (e) {} } catch (e) {}
} }

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
@ -48,13 +49,14 @@ class _LyricsWidgetState extends State<LyricsWidget> {
late StreamSubscription _mediaItemSub; late StreamSubscription _mediaItemSub;
late StreamSubscription _playbackStateSub; late StreamSubscription _playbackStateSub;
int? _currentIndex = -1; int? _currentIndex = -1;
Duration _nextPosition = Duration.zero; Duration _nextOffset = Duration.zero;
Duration _currentOffset = Duration.zero;
final ScrollController _controller = ScrollController(); final ScrollController _controller = ScrollController();
final double height = 90; final double height = 90;
BoxConstraints? _widgetConstraints; BoxConstraints? _widgetConstraints;
Lyrics? _lyrics; Lyrics? _lyrics;
bool _loading = true; bool _loading = true;
CancelableOperation<Lyrics>? _lyricsCancelable; CancelToken? _lyricsCancelToken;
Object? _error; Object? _error;
bool _freeScroll = false; bool _freeScroll = false;
@ -63,7 +65,10 @@ class _LyricsWidgetState extends State<LyricsWidget> {
Future<void> _loadForId(String trackId) async { Future<void> _loadForId(String trackId) async {
// cancel current request, if applicable // cancel current request, if applicable
await _lyricsCancelable?.cancel(); _lyricsCancelToken?.cancel();
_currentIndex = -1;
_currentOffset = Duration.zero;
_nextOffset = Duration.zero;
//Fetch //Fetch
if (_loading == false && _lyrics != null) { if (_loading == false && _lyrics != null) {
@ -75,10 +80,9 @@ class _LyricsWidgetState extends State<LyricsWidget> {
} }
try { try {
_lyricsCancelable = _lyricsCancelToken = CancelToken();
CancelableOperation.fromFuture(deezerAPI.lyrics(trackId)); final lyrics =
final lyrics = await _lyricsCancelable!.valueOrCancellation(null); await deezerAPI.lyrics(trackId, cancelToken: _lyricsCancelToken);
if (lyrics == null) return;
_syncedLyrics = lyrics.sync; _syncedLyrics = lyrics.sync;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -86,9 +90,10 @@ class _LyricsWidgetState extends State<LyricsWidget> {
_lyrics = lyrics; _lyrics = lyrics;
}); });
_nextPosition = Duration.zero;
SchedulerBinding.instance.addPostFrameCallback( SchedulerBinding.instance.addPostFrameCallback(
(_) => _updatePosition(audioHandler.playbackState.value.position)); (_) => _updatePosition(audioHandler.playbackState.value.position));
} on DioException catch (e) {
if (e.type != DioExceptionType.cancel) rethrow;
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -126,21 +131,22 @@ class _LyricsWidgetState extends State<LyricsWidget> {
void _updatePosition(Duration position) { void _updatePosition(Duration position) {
if (_loading) return; if (_loading) return;
if (!_syncedLyrics) return; if (!_syncedLyrics) return;
if (position < _nextPosition) return; if (position < _nextOffset && position > _currentOffset) return;
_currentIndex = _currentIndex =
_lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position); _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
//Scroll to current lyric
if (_currentIndex! < 0) return; if (_currentIndex! < 0) return;
//Update current lyric index
if (_currentIndex! < _lyrics!.lyrics!.length) { if (_currentIndex! < _lyrics!.lyrics!.length - 1) {
// update nextPosition // update nextOffset
_nextPosition = _lyrics!.lyrics![_currentIndex! + 1].offset!; _nextOffset = _lyrics!.lyrics![_currentIndex! + 1].offset!;
} else { } else {
// dummy position so that the before-hand condition always returns false // dummy position so that the before-hand condition always returns false
_nextPosition = const Duration(days: 69); _nextOffset = const Duration(days: 69);
} }
_currentOffset = _lyrics!.lyrics![_currentIndex!].offset!;
setState(() => _currentIndex); setState(() => _currentIndex);
if (_freeScroll) return; if (_freeScroll) return;
_scrollToLyric(); _scrollToLyric();
@ -153,9 +159,6 @@ class _LyricsWidgetState extends State<LyricsWidget> {
// if (settings.lyricsVisualizer) playerHelper.startVisualizer(); // if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen(_updatePosition); _playbackStateSub = AudioService.position.listen(_updatePosition);
}); });
if (audioHandler.mediaItem.value != null) {
_loadForId(audioHandler.mediaItem.value!.id);
}
/// Track change = ~exit~ reload lyrics /// Track change = ~exit~ reload lyrics
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) { _mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {

View file

@ -389,7 +389,13 @@ class MenuSheet {
Text('Play mix'.i18n), Text('Play mix'.i18n),
icon: const Icon(Icons.online_prediction), icon: const Icon(Icons.online_prediction),
onTap: () async { onTap: () async {
playerHelper.playMix(track.id, track.title!); // I couldn't find this API request within the Deezer app, but the
// same button uses the getSearchTrackMix API call, so let's use that
// instead.
// playerHelper.playMix(track.id, track.title!);
playerHelper.playSearchMix(track.id, track.title!);
}, },
); );

View file

@ -46,7 +46,12 @@ class PlayerBar extends StatelessWidget {
initialData: audioHandler.mediaItem.valueOrNull, initialData: audioHandler.mediaItem.valueOrNull,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.data == null) { if (snapshot.data == null) {
return Material( // lazy way to prevent dragging up
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragEnd: (_) {},
onVerticalDragUpdate: (_) {},
child: Material(
child: ListTile( child: ListTile(
dense: true, dense: true,
visualDensity: VisualDensity.standard, visualDensity: VisualDensity.standard,
@ -59,6 +64,7 @@ class PlayerBar extends StatelessWidget {
), ),
title: Text('Nothing is currently playing'.i18n), title: Text('Nothing is currently playing'.i18n),
), ),
),
); );
} }
final currentMediaItem = snapshot.data!; final currentMediaItem = snapshot.data!;
@ -72,6 +78,7 @@ class PlayerBar extends StatelessWidget {
? Hero(tag: currentMediaItem.id, child: image) ? Hero(tag: currentMediaItem.id, child: image)
: image; : image;
return Material( return Material(
type: MaterialType.transparency,
child: ListTile( child: ListTile(
dense: true, dense: true,
tileColor: _backgroundColor, tileColor: _backgroundColor,

View file

@ -333,9 +333,11 @@ class PlayerScreenDesktop extends StatelessWidget {
showQueueButton: false, showQueueButton: false,
), ),
), ),
ConstrainedBox( Flexible(
child: ConstrainedBox(
constraints: BoxConstraints.loose(const Size.square(500)), constraints: BoxConstraints.loose(const Size.square(500)),
child: const BigAlbumArt()), child: const BigAlbumArt()),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0), padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 18.sp), child: PlayerTextSubtext(textSize: 18.sp),
@ -435,7 +437,6 @@ class _FitOrScrollTextState extends State<FitOrScrollText> {
); );
textPainter.layout(maxWidth: constraints.maxWidth); textPainter.layout(maxWidth: constraints.maxWidth);
print(textPainter.didExceedMaxLines);
return !(textPainter.didExceedMaxLines || return !(textPainter.didExceedMaxLines ||
textPainter.height > constraints.maxHeight || textPainter.height > constraints.maxHeight ||
@ -458,7 +459,7 @@ class _FitOrScrollTextState extends State<FitOrScrollText> {
startPadding: 0.0, startPadding: 0.0,
accelerationDuration: const Duration(seconds: 1), accelerationDuration: const Duration(seconds: 1),
pauseAfterRound: const Duration(seconds: 2), pauseAfterRound: const Duration(seconds: 2),
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end,
fadingEdgeEndFraction: 0.05, fadingEdgeEndFraction: 0.05,
fadingEdgeStartFraction: 0.05, fadingEdgeStartFraction: 0.05,
); );
@ -483,12 +484,15 @@ class PlayerTextSubtext extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[ children: <Widget>[
FitOrScrollText( SizedBox(
height: 1.5 * textSize,
child: FitOrScrollText(
key: Key(currentMediaItem.displayTitle!), key: Key(currentMediaItem.displayTitle!),
text: currentMediaItem.displayTitle!, text: currentMediaItem.displayTitle!,
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
fontSize: textSize, fontWeight: FontWeight.bold)), fontSize: textSize, fontWeight: FontWeight.bold)),
),
// child: currentMediaItem.displayTitle!.length >= 26 // child: currentMediaItem.displayTitle!.length >= 26
// ? Marquee( // ? Marquee(
// key: Key(currentMediaItem.displayTitle!), // key: Key(currentMediaItem.displayTitle!),
@ -511,7 +515,6 @@ class PlayerTextSubtext extends StatelessWidget {
// style: TextStyle( // style: TextStyle(
// fontSize: textSize, fontWeight: FontWeight.bold), // fontSize: textSize, fontWeight: FontWeight.bold),
// )), // )),
const SizedBox(height: 2.0),
Text( Text(
currentMediaItem.displaySubtitle ?? '', currentMediaItem.displaySubtitle ?? '',
maxLines: 1, maxLines: 1,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
@ -59,6 +60,7 @@ class _SearchScreenState extends State<SearchScreen> {
final _suggestions = ListNotifier<String>([]); final _suggestions = ListNotifier<String>([]);
final _showingSuggestions = ValueNotifier(false); final _showingSuggestions = ValueNotifier(false);
final _loading = ValueNotifier(false); final _loading = ValueNotifier(false);
CancelToken? _searchCancelToken;
Timer? _searchTimer; Timer? _searchTimer;
final _focus = FocusNode(); final _focus = FocusNode();
final _textFieldFocusNode = FocusNode(); final _textFieldFocusNode = FocusNode();
@ -121,16 +123,24 @@ class _SearchScreenState extends State<SearchScreen> {
_controller.text.length < 2 || _controller.text.length < 2 ||
_controller.text.startsWith('http')) return; _controller.text.startsWith('http')) return;
_loading.value = true; _loading.value = true;
_searchCancelToken?.cancel();
//Load //Load
List<String>? sugg; final List<String>? suggestions;
try { try {
sugg = await deezerAPI.searchSuggestions(_controller.text); _searchCancelToken = CancelToken();
suggestions = await deezerAPI.searchSuggestions(_controller.text,
cancelToken: _searchCancelToken);
} on DioException catch (e) {
if (e.type != DioExceptionType.cancel) rethrow;
return;
} catch (e) { } catch (e) {
print(e); print(e);
return;
} }
_loading.value = false;
if (sugg != null) _suggestions.value = sugg; _loading.value = false;
if (suggestions != null) _suggestions.value = suggestions;
} }
Widget _removeHistoryItemWidget(int index) { Widget _removeHistoryItemWidget(int index) {
@ -193,8 +203,8 @@ class _SearchScreenState extends State<SearchScreen> {
if (query.isEmpty) { if (query.isEmpty) {
_suggestions.clear(); _suggestions.clear();
} else { } else {
_searchTimer ??= Timer( _searchTimer ??=
const Duration(milliseconds: 300), () { Timer(const Duration(milliseconds: 1), () {
_searchTimer = null; _searchTimer = null;
_loadSuggestions(); _loadSuggestions();
}); });
@ -274,16 +284,13 @@ class _SearchScreenState extends State<SearchScreen> {
), ),
...List.generate(min(cache.searchHistory.length, 10), ...List.generate(min(cache.searchHistory.length, 10),
(int i) { (int i) {
final data = cache.searchHistory[i].data; switch (cache.searchHistory[i]) {
switch (cache.searchHistory[i].type) { case final Track data:
case SearchHistoryItemType.track:
return TrackTile.fromTrack( return TrackTile.fromTrack(
data, data,
onTap: () { onTap: () {
List<Track?> queue = cache.searchHistory final queue = cache.searchHistory
.where((h) => .whereType<Track>()
h.type == SearchHistoryItemType.track)
.map<Track>((t) => t.data)
.toList(); .toList();
playerHelper.playFromTrackList( playerHelper.playFromTrackList(
queue, queue,
@ -297,7 +304,7 @@ class _SearchScreenState extends State<SearchScreen> {
.defaultTrackMenu(data, details: details), .defaultTrackMenu(data, details: details),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
case SearchHistoryItemType.album: case final Album data:
return AlbumTile( return AlbumTile(
data, data,
onTap: () { onTap: () {
@ -308,7 +315,7 @@ class _SearchScreenState extends State<SearchScreen> {
.defaultAlbumMenu(data, details: details), .defaultAlbumMenu(data, details: details),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
case SearchHistoryItemType.artist: case final Artist data:
return ArtistHorizontalTile( return ArtistHorizontalTile(
data, data,
onTap: () { onTap: () {
@ -320,7 +327,7 @@ class _SearchScreenState extends State<SearchScreen> {
MenuSheet(context).defaultArtistMenu(data), MenuSheet(context).defaultArtistMenu(data),
trailing: _removeHistoryItemWidget(i), trailing: _removeHistoryItemWidget(i),
); );
case SearchHistoryItemType.playlist: case final Playlist data:
return PlaylistTile( return PlaylistTile(
data, data,
onTap: () { onTap: () {
@ -480,13 +487,7 @@ class SearchResultsScreen extends StatelessWidget {
.getRange(0, min(results.tracks!.length, 3))) .getRange(0, min(results.tracks!.length, 3)))
TrackTile.fromTrack(track, onTap: () { TrackTile.fromTrack(track, onTap: () {
cache.addToSearchHistory(track); cache.addToSearchHistory(track);
playerHelper.playFromTrackList( playerHelper.playSearchMixDeferred(track);
results.tracks!,
track.id,
QueueSource(
text: 'Search'.i18n,
id: query,
source: 'search'));
}, onSecondary: (details) { }, onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultTrackMenu(track, details: details); m.defaultTrackMenu(track, details: details);
@ -495,12 +496,8 @@ class SearchResultsScreen extends StatelessWidget {
title: Text('Show all tracks'.i18n), title: Text('Show all tracks'.i18n),
onTap: () { onTap: () {
Navigator.of(context).pushRoute( Navigator.of(context).pushRoute(
builder: (context) => TrackListScreen( builder: (context) =>
results.tracks, TrackListScreen(results.tracks, null));
QueueSource(
id: query,
source: 'search',
text: 'Search'.i18n)));
}, },
), ),
const FreezerDivider(), const FreezerDivider(),
@ -699,8 +696,8 @@ class SearchResultsScreen extends StatelessWidget {
//List all tracks //List all tracks
class TrackListScreen extends StatelessWidget { class TrackListScreen extends StatelessWidget {
final QueueSource queueSource; final QueueSource? queueSource;
final List<Track?>? tracks; final List<Track>? tracks;
const TrackListScreen(this.tracks, this.queueSource, {super.key}); const TrackListScreen(this.tracks, this.queueSource, {super.key});
@ -711,11 +708,16 @@ class TrackListScreen extends StatelessWidget {
body: ListView.builder( body: ListView.builder(
itemCount: tracks!.length, itemCount: tracks!.length,
itemBuilder: (BuildContext context, int i) { itemBuilder: (BuildContext context, int i) {
Track t = tracks![i]!; Track t = tracks![i];
return TrackTile.fromTrack( return TrackTile.fromTrack(
t, t,
onTap: () { onTap: () {
playerHelper.playFromTrackList(tracks!, t.id, queueSource); if (queueSource == null) {
playerHelper.playSearchMixDeferred(t);
return;
}
playerHelper.playFromTrackList(tracks!, t.id, queueSource!);
}, },
onSecondary: (details) { onSecondary: (details) {
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);

View file

@ -28,5 +28,10 @@
<string>MainMenu</string> <string>MainMenu</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict> </dict>
</plist> </plist>