when a song is played in search, now its mix gets played instead
This commit is contained in:
parent
8fd84c3929
commit
faec2af805
|
@ -47,5 +47,10 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/paths.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
|
@ -8,46 +8,82 @@ part 'cache.g.dart';
|
|||
|
||||
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
|
||||
@HiveType(typeId: 22)
|
||||
@JsonSerializable()
|
||||
class Cache {
|
||||
static Future<LazyBox<Cache>> get _box =>
|
||||
Hive.openLazyBox<Cache>('metacache');
|
||||
static Future<LazyBox<Cache>> get _box async =>
|
||||
Hive.openLazyBox<Cache>('metacache', path: await Paths.cacheDir());
|
||||
//ID's of tracks that are in library
|
||||
@HiveField(0, defaultValue: [])
|
||||
@JsonKey(defaultValue: [])
|
||||
List<String> libraryTracks = [];
|
||||
|
||||
//Track ID of logged track, to prevent duplicates
|
||||
@HiveField(1)
|
||||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||||
String? loggedTrackId;
|
||||
|
||||
@HiveField(2)
|
||||
@JsonKey(defaultValue: [])
|
||||
List<Track> history = [];
|
||||
|
||||
//All sorting cached
|
||||
@HiveField(3)
|
||||
@JsonKey(defaultValue: [])
|
||||
List<Sorting?> sorts = [];
|
||||
|
||||
//Sleep timer
|
||||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||||
DateTime? sleepTimerTime;
|
||||
@JsonKey(includeToJson: false, includeFromJson: false)
|
||||
// ignore: cancel_subscriptions
|
||||
StreamSubscription? sleepTimer;
|
||||
|
||||
//Search history
|
||||
@HiveField(4)
|
||||
@JsonKey(name: 'searchHistory2', defaultValue: [])
|
||||
List<SearchHistoryItem> searchHistory = [];
|
||||
List<DeezerMediaItem> searchHistory = [];
|
||||
|
||||
//If download threads warning was shown
|
||||
@HiveField(5)
|
||||
@JsonKey(defaultValue: false)
|
||||
bool threadsWarning = false;
|
||||
|
||||
//Last time update check
|
||||
|
@ -62,9 +98,17 @@ class Cache {
|
|||
@HiveField(9, defaultValue: false)
|
||||
bool canStreamLossless = false;
|
||||
|
||||
@JsonKey(includeToJson: false, includeFromJson: 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();
|
||||
|
||||
//Wrapper to test if track is favorite against cache
|
||||
|
@ -74,27 +118,34 @@ class Cache {
|
|||
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
|
||||
void addToSearchHistory(DeezerMediaItem item) async {
|
||||
// Remove duplicate
|
||||
int i = searchHistory.indexWhere((e) => e.data.id == item.id);
|
||||
int i = searchHistory.indexWhere((e) => e.id == item.id);
|
||||
if (i != -1) {
|
||||
searchHistory.removeAt(i);
|
||||
}
|
||||
|
||||
if (item is Track) {
|
||||
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));
|
||||
}
|
||||
searchHistory.add(item);
|
||||
|
||||
await save();
|
||||
}
|
||||
|
@ -134,64 +185,9 @@ class Cache {
|
|||
await box.clear();
|
||||
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)
|
||||
enum SearchHistoryItemType {
|
||||
@HiveField(0)
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
@ -44,8 +46,13 @@ class DeezerAPI {
|
|||
cookieJar.delete(Uri.https('www.deezer.com'));
|
||||
return;
|
||||
}
|
||||
cookieJar
|
||||
.saveFromResponse(Uri.https('www.deezer.com'), [Cookie('arl', arl)]);
|
||||
cookieJar.saveFromResponse(Uri.https('www.deezer.com'), [
|
||||
Cookie('arl', arl)
|
||||
..domain = '.deezer.com'
|
||||
..httpOnly = true
|
||||
..sameSite = SameSite.none
|
||||
..secure = true
|
||||
]);
|
||||
}
|
||||
|
||||
String? token;
|
||||
|
@ -89,9 +96,24 @@ class DeezerAPI {
|
|||
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
|
||||
Future<Map<dynamic, dynamic>> callApi(String method,
|
||||
{Map<dynamic, dynamic>? params, String? gatewayInput}) async {
|
||||
{Map<dynamic, dynamic>? params,
|
||||
String? gatewayInput,
|
||||
CancelToken? cancelToken}) async {
|
||||
//Post
|
||||
final res = await dio.post('https://www.deezer.com/ajax/gw-light.php',
|
||||
queryParameters: {
|
||||
|
@ -102,7 +124,8 @@ class DeezerAPI {
|
|||
//Used for homepage
|
||||
if (gatewayInput != null) 'gateway_input': gatewayInput
|
||||
},
|
||||
data: jsonEncode(params));
|
||||
data: jsonEncode(params),
|
||||
cancelToken: cancelToken);
|
||||
final body = res.data;
|
||||
|
||||
// In case of error "Invalid CSRF token" retrieve new one and retry the same call
|
||||
|
@ -310,6 +333,17 @@ class DeezerAPI {
|
|||
}).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
|
||||
Future<SearchResults> search(String? query) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
|
||||
|
@ -395,6 +429,9 @@ class DeezerAPI {
|
|||
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
|
||||
Future removeArtist(String? id) async {
|
||||
await callApi('artist.deleteFavorite', params: {'ART_ID': id});
|
||||
|
@ -430,7 +467,7 @@ class DeezerAPI {
|
|||
//Get users playlists
|
||||
Future<List<Playlist>> getPlaylists() async {
|
||||
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']
|
||||
.map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true))
|
||||
.toList();
|
||||
|
@ -439,7 +476,7 @@ class DeezerAPI {
|
|||
//Get favorite albums
|
||||
Future<List<Album>> getAlbums() async {
|
||||
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<Album> albums = albumList
|
||||
.map<Album>((json) => Album.fromPrivateJson(json, library: true))
|
||||
|
@ -448,15 +485,18 @@ class DeezerAPI {
|
|||
}
|
||||
|
||||
//Remove album from library
|
||||
Future removeAlbum(String? id) async {
|
||||
Future<void> removeAlbum(String? id) async {
|
||||
await callApi('album.deleteFavorite', params: {'ALB_ID': id});
|
||||
}
|
||||
|
||||
//Remove track from favorites
|
||||
Future removeFavorite(String id) async {
|
||||
Future<void> removeFavorite(String id) async {
|
||||
await callApi('favorite_song.remove', params: {'SNG_ID': id});
|
||||
}
|
||||
|
||||
Future<void> removeFavoriteShow(String id) =>
|
||||
callApi('show.deleteFavorite', params: {'SHOW_ID': id});
|
||||
|
||||
//Get favorite artists
|
||||
Future<List<Artist>?> getArtists() async {
|
||||
Map data = await callApi('deezer.pageProfile',
|
||||
|
@ -467,11 +507,13 @@ class DeezerAPI {
|
|||
}
|
||||
|
||||
//Get lyrics by track id
|
||||
Future<Lyrics> lyrics(String? trackId) async {
|
||||
Map data = await callApi('song.getLyrics', params: {'sng_id': trackId});
|
||||
Future<Lyrics> lyrics(String? trackId, {CancelToken? cancelToken}) async {
|
||||
Map data = await callApi('song.getLyrics',
|
||||
params: {'sng_id': trackId}, cancelToken: cancelToken);
|
||||
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']);
|
||||
}
|
||||
|
||||
|
@ -505,7 +547,8 @@ class DeezerAPI {
|
|||
'show',
|
||||
'smarttracklist',
|
||||
'track',
|
||||
'user'
|
||||
'user',
|
||||
'external-link'
|
||||
];
|
||||
Map data = await callApi('page.get',
|
||||
gatewayInput: jsonEncode({
|
||||
|
@ -663,9 +706,10 @@ class DeezerAPI {
|
|||
.toList();
|
||||
}
|
||||
|
||||
Future<List<String>?> searchSuggestions(String? query) async {
|
||||
Map data =
|
||||
await callApi('search_getSuggestedQueries', params: {'QUERY': query});
|
||||
Future<List<String>?> searchSuggestions(String? query,
|
||||
{CancelToken? cancelToken}) async {
|
||||
Map data = await callApi('search_getSuggestedQueries',
|
||||
params: {'QUERY': query}, cancelToken: cancelToken);
|
||||
return (data['results']['SUGGESTION'] as List?)
|
||||
?.map<String>((s) => s['QUERY'] as String)
|
||||
.toList();
|
||||
|
@ -702,11 +746,24 @@ class DeezerAPI {
|
|||
}
|
||||
|
||||
//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: {
|
||||
'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))
|
||||
.toList();
|
||||
}
|
||||
|
@ -725,3 +782,9 @@ class DeezerAPI {
|
|||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class PipeAPI {
|
||||
PipeAPI._();
|
||||
|
||||
Future<void> getTrackToken(String trackId) async {}
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ class Track extends DeezerMediaItem {
|
|||
}
|
||||
|
||||
//MediaItem
|
||||
Future<MediaItem> toMediaItem() async {
|
||||
MediaItem toMediaItem() {
|
||||
return MediaItem(
|
||||
title: title!,
|
||||
album: album!.title!,
|
||||
|
@ -641,7 +641,7 @@ class DeezerImageDetails extends ImageDetails {
|
|||
@override
|
||||
String get full => size(1000, 1000);
|
||||
@override
|
||||
String get thumb => size(140, 140);
|
||||
String get thumb => size(264, 264);
|
||||
|
||||
String size(int width, int height,
|
||||
{int num = 80, String id = '000000', String format = 'jpg'}) =>
|
||||
|
@ -957,18 +957,18 @@ class HomePageSection {
|
|||
|
||||
//JSON
|
||||
static HomePageSection? fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
final layout = {
|
||||
'horizontal-grid': HomePageSectionLayout.ROW,
|
||||
'filterable-grid': HomePageSectionLayout.ROW,
|
||||
'grid-preview-two': HomePageSectionLayout.ROW,
|
||||
'grid': HomePageSectionLayout.GRID
|
||||
final layout = const <String, HomePageSectionLayout>{
|
||||
'horizontal-grid': HomePageSectionLayout.row,
|
||||
'filterable-grid': HomePageSectionLayout.row,
|
||||
'grid-preview-two': HomePageSectionLayout.row,
|
||||
'grid': HomePageSectionLayout.grid,
|
||||
'slideshow': HomePageSectionLayout.slideshow,
|
||||
}[json['layout'] ?? ''];
|
||||
if (layout == null) {
|
||||
_logger.warning('UNKNOWN LAYOUT: ${json['layout']}');
|
||||
_logger.warning('LAYOUT DATA:');
|
||||
_logger.warning(json);
|
||||
return null;
|
||||
}
|
||||
_logger.fine('LAYOUT: $layout');
|
||||
final items = <HomePageItem>[];
|
||||
for (var i in (json['items'] ?? [])) {
|
||||
HomePageItem? hpi = HomePageItem.fromPrivateJson(i);
|
||||
|
@ -1020,7 +1020,7 @@ class HomePageItem {
|
|||
case 'channel':
|
||||
return HomePageItem(
|
||||
type: HomePageItemType.CHANNEL,
|
||||
value: DeezerChannel.fromPrivateJson(json));
|
||||
value: DeezerChannel.fromPrivateJson(json, false));
|
||||
case 'album':
|
||||
return HomePageItem(
|
||||
type: HomePageItemType.ALBUM,
|
||||
|
@ -1029,6 +1029,10 @@ class HomePageItem {
|
|||
return HomePageItem(
|
||||
type: HomePageItemType.SHOW,
|
||||
value: Show.fromPrivateJson(json['data']));
|
||||
case 'external-link':
|
||||
return HomePageItem(
|
||||
type: HomePageItemType.EXTERNAL_LINK,
|
||||
value: DeezerChannel.fromPrivateJson(json, true));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -1060,6 +1064,10 @@ class HomePageItem {
|
|||
return HomePageItem(
|
||||
type: HomePageItemType.SHOW,
|
||||
value: Show.fromPrivateJson(json['value']));
|
||||
case 'EXTERNAL_LINK':
|
||||
return HomePageItem(
|
||||
type: HomePageItemType.EXTERNAL_LINK,
|
||||
value: DeezerChannel.fromJson(json['value']));
|
||||
default:
|
||||
throw Exception('Unexpected type $t for HomePageItem');
|
||||
}
|
||||
|
@ -1090,15 +1098,21 @@ class DeezerChannel {
|
|||
@HiveField(5, defaultValue: null)
|
||||
final DeezerImageDetails? picture;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
@HiveField(6, defaultValue: false)
|
||||
final bool isExternalLink;
|
||||
|
||||
const DeezerChannel(
|
||||
{this.id,
|
||||
this.title,
|
||||
this.backgroundColor = Colors.blue,
|
||||
this.target,
|
||||
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(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
|
@ -1112,7 +1126,8 @@ class DeezerChannel {
|
|||
: null,
|
||||
picture: json.containsKey('pictures') && json['pictures'].length > 0
|
||||
? DeezerImageDetails.fromPrivateJson(json['pictures'][0])
|
||||
: null);
|
||||
: null,
|
||||
isExternalLink: isExternalLink);
|
||||
|
||||
factory DeezerChannel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DeezerChannelFromJson(json);
|
||||
|
@ -1136,14 +1151,21 @@ enum HomePageItemType {
|
|||
ALBUM,
|
||||
@HiveField(5)
|
||||
SHOW,
|
||||
|
||||
@HiveField(6)
|
||||
EXTERNAL_LINK,
|
||||
}
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
enum HomePageSectionLayout {
|
||||
@HiveField(0)
|
||||
ROW,
|
||||
row,
|
||||
@HiveField(1)
|
||||
GRID,
|
||||
grid,
|
||||
|
||||
/// ROW but bigger
|
||||
@HiveField(2)
|
||||
slideshow,
|
||||
}
|
||||
|
||||
enum RepeatType { NONE, LIST, TRACK }
|
||||
|
|
|
@ -338,15 +338,15 @@ class DownloadManager {
|
|||
}
|
||||
|
||||
//Get all offline available tracks
|
||||
Future<List<Track?>> allOfflineTracks() async {
|
||||
Future<List<Track>> allOfflineTracks() async {
|
||||
if (!isSupported) return [];
|
||||
|
||||
List rawTracks =
|
||||
await db.query('Tracks', where: 'offline == 1', columns: ['id']);
|
||||
List<Track?> out = [];
|
||||
List<Track> out = [];
|
||||
//Load track meta individually
|
||||
for (Map rawTrack in rawTracks as Iterable<Map<dynamic, dynamic>>) {
|
||||
out.add(await getOfflineTrack(rawTrack['id']));
|
||||
out.add((await getOfflineTrack(rawTrack['id']))!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
@ -215,7 +215,7 @@ class DeezerImageDetails {
|
|||
}
|
||||
}
|
||||
|
||||
@collection
|
||||
@embedded
|
||||
class Lyrics {
|
||||
late final String lyricsId;
|
||||
late final String writers;
|
||||
|
|
|
@ -893,11 +893,13 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
case 'mix':
|
||||
tracks = await _deezerAPI.playMix(queueSource!.id);
|
||||
// Deduplicate tracks with the same id
|
||||
List<String> queueIds = queue.value.map((e) => e.id).toList();
|
||||
tracks?.removeWhere((track) => queueIds.contains(track.id));
|
||||
// List<String> queueIds = queue.value.map((e) => e.id).toList();
|
||||
// tracks?.removeWhere((track) => queueIds.contains(track.id));
|
||||
break;
|
||||
case 'smarttracklist':
|
||||
tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks;
|
||||
case 'searchMix':
|
||||
tracks = await _deezerAPI.getSearchTrackMix(queueSource!.id!, null);
|
||||
default:
|
||||
return;
|
||||
// print(queueSource.toJson());
|
||||
|
@ -908,8 +910,8 @@ class AudioPlayerTask extends BaseAudioHandler {
|
|||
'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})');
|
||||
}
|
||||
|
||||
final mi = await Future.wait(
|
||||
tracks.map<Future<MediaItem>>((t) => t.toMediaItem()));
|
||||
final mi =
|
||||
tracks.map<MediaItem>((t) => t.toMediaItem()).toList(growable: false);
|
||||
await addQueueItems(mi);
|
||||
}
|
||||
|
||||
|
|
|
@ -185,12 +185,12 @@ class PlayerHelper {
|
|||
}
|
||||
|
||||
//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 audioHandler.customAction('setIndex', {
|
||||
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)
|
||||
});
|
||||
if (index != null) {
|
||||
await audioHandler.customAction('setIndex', {'index': index});
|
||||
}
|
||||
await audioHandler.updateQueue(queue);
|
||||
// if (queue[0].id != trackId)
|
||||
// await AudioService.skipToQueueItem(trackId);
|
||||
|
@ -206,7 +206,7 @@ class PlayerHelper {
|
|||
//Play mix by track
|
||||
Future playMix(String trackId, String trackTitle) async {
|
||||
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
|
||||
playFromTrackList(
|
||||
await playFromTrackList(
|
||||
tracks,
|
||||
tracks[0].id,
|
||||
QueueSource(
|
||||
|
@ -215,6 +215,35 @@ class PlayerHelper {
|
|||
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
|
||||
Future playFromTopTracks(
|
||||
List<Track> tracks, String trackId, Artist artist) async {
|
||||
|
@ -249,17 +278,17 @@ class PlayerHelper {
|
|||
}
|
||||
|
||||
//Load tracks as queue, play track id, set queue source
|
||||
Future playFromTrackList(
|
||||
List<Track?> tracks, String? trackId, QueueSource queueSource) async {
|
||||
final queue = await Future.wait(tracks
|
||||
.map<Future<MediaItem>>((track) => track!.toMediaItem())
|
||||
.toList());
|
||||
Future<void> playFromTrackList(
|
||||
List<Track> tracks, String? trackId, QueueSource queueSource) async {
|
||||
final queue =
|
||||
tracks.map<MediaItem>((track) => track!.toMediaItem()).toList();
|
||||
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
|
||||
Future playFromSmartTrackList(SmartTrackList stl) async {
|
||||
Future<void> playFromSmartTrackList(SmartTrackList stl) async {
|
||||
//Load from API if no tracks
|
||||
if (stl.tracks == null || stl.tracks!.isEmpty) {
|
||||
if (settings.offlineMode) {
|
||||
|
|
|
@ -76,7 +76,7 @@ void main() async {
|
|||
..registerAdapter(SortingAdapter())
|
||||
..registerAdapter(SortTypeAdapter())
|
||||
..registerAdapter(SortSourceTypesAdapter())
|
||||
..registerAdapter(SearchHistoryItemAdapter())
|
||||
..registerAdapter(CacheEntryAdapter())
|
||||
..registerAdapter(SearchHistoryItemTypeAdapter())
|
||||
..registerAdapter(CacheAdapter())
|
||||
..registerAdapter(ColorAdapter())
|
||||
|
@ -92,7 +92,7 @@ void main() async {
|
|||
..registerAdapter(QueueSourceAdapter())
|
||||
..registerAdapter(HomePageAdapter())
|
||||
..registerAdapter(NavigationRailAppearanceAdapter())
|
||||
..registerAdapter(HiveCacheObjectAdapter(typeId: 35));
|
||||
..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); // not working?
|
||||
|
||||
Hive.init(await Paths.dataDirectory());
|
||||
|
||||
|
|
|
@ -356,6 +356,7 @@ class _ArtistDetailsState extends State<ArtistDetails> {
|
|||
maxHeight: MediaQuery.of(context).size.height / 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: ZoomableImage(
|
||||
|
@ -368,7 +369,7 @@ class _ArtistDetailsState extends State<ArtistDetails> {
|
|||
MediaQuery.of(context).size.width / 16, 60.0)),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
|
@ -847,6 +848,8 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
setState(() {
|
||||
playlist = p;
|
||||
});
|
||||
// update cache
|
||||
cache.favoritePlaylists?.value[playlist!.id] = p;
|
||||
//Load tracks
|
||||
_load();
|
||||
}).catchError((e) {
|
||||
|
@ -872,7 +875,7 @@ class _PlaylistDetailsState extends State<PlaylistDetails> {
|
|||
children: <Widget>[
|
||||
const SizedBox(height: 4.0),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.tight(
|
||||
constraints: BoxConstraints.loose(
|
||||
Size.fromHeight(MediaQuery.of(context).size.height / 3)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import 'package:freezer/api/definitions.dart';
|
|||
import 'package:freezer/api/player/audio_handler.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/external_link_route.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/translations.i18n.dart';
|
||||
import 'tiles.dart';
|
||||
|
@ -43,15 +44,26 @@ class HomeScreen extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
floatHeaderSlivers: true,
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverPersistentHeader(
|
||||
delegate: _SearchHeaderDelegate(), floating: true)
|
||||
],
|
||||
body: const HomePageWidget(cacheable: true),
|
||||
final actualScrollConfiguration = ScrollConfiguration.of(context);
|
||||
return ScrollConfiguration(
|
||||
behavior: actualScrollConfiguration.copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.trackpad
|
||||
},
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
floatHeaderSlivers: true,
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverPersistentHeader(
|
||||
delegate: _SearchHeaderDelegate(), floating: true)
|
||||
],
|
||||
body: const HomePageWidget(cacheable: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -189,9 +201,10 @@ class _HomePageWidgetState extends State<HomePageWidget> {
|
|||
|
||||
Widget getSectionChild(HomePageSection section) {
|
||||
switch (section.layout) {
|
||||
case HomePageSectionLayout.GRID:
|
||||
case HomePageSectionLayout.grid:
|
||||
return HomePageGridSection(section);
|
||||
case HomePageSectionLayout.ROW:
|
||||
case HomePageSectionLayout.slideshow:
|
||||
case HomePageSectionLayout.row:
|
||||
default:
|
||||
return HomepageRowSection(section);
|
||||
}
|
||||
|
@ -205,33 +218,22 @@ class _HomePageWidgetState extends State<HomePageWidget> {
|
|||
sections = _homePage!.sections;
|
||||
}
|
||||
|
||||
final actualScrollConfiguration = ScrollConfiguration.of(context);
|
||||
return ScrollConfiguration(
|
||||
behavior: actualScrollConfiguration.copyWith(
|
||||
dragDevices: {
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.trackpad
|
||||
},
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
key: _indicatorKey,
|
||||
onRefresh: _load,
|
||||
child: _homePage == null
|
||||
? const SizedBox.expand(child: SingleChildScrollView())
|
||||
: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: index == 0
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(top: 16.0),
|
||||
child: getSectionChild(sections![index]));
|
||||
},
|
||||
itemCount: sections!.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
),
|
||||
),
|
||||
return RefreshIndicator(
|
||||
key: _indicatorKey,
|
||||
onRefresh: _load,
|
||||
child: _homePage == null
|
||||
? const SizedBox.expand(child: SingleChildScrollView())
|
||||
: ListView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: index == 0
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(top: 16.0),
|
||||
child: getSectionChild(sections![index]));
|
||||
},
|
||||
itemCount: sections!.length,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -356,7 +358,8 @@ class HomePageGridSection extends StatelessWidget {
|
|||
|
||||
class HomePageItemWidget extends StatelessWidget {
|
||||
final HomePageItem item;
|
||||
const HomePageItemWidget(this.item, {super.key});
|
||||
final Size? itemSize;
|
||||
const HomePageItemWidget(this.item, {super.key, this.itemSize});
|
||||
|
||||
@override
|
||||
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:
|
||||
return ShowCard(
|
||||
item.value,
|
||||
|
|
|
@ -237,7 +237,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
bool _loadingTracks = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
List<Track> tracks = [];
|
||||
List<Track?> allTracks = [];
|
||||
List<Track> allTracks = [];
|
||||
int? trackCount;
|
||||
Sorting? _sort = Sorting(sourceType: SortSourceTypes.TRACKS);
|
||||
|
||||
|
@ -377,7 +377,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
|
|||
}
|
||||
|
||||
Future _loadAllOffline() async {
|
||||
List<Track?> tracks = await downloadManager.allOfflineTracks();
|
||||
List<Track> tracks = await downloadManager.allOfflineTracks();
|
||||
setState(() {
|
||||
allTracks = tracks;
|
||||
});
|
||||
|
@ -955,10 +955,33 @@ class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
final List<Playlist> playlists = await deezerAPI.getPlaylists();
|
||||
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;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
|
@ -48,13 +49,14 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
late StreamSubscription _mediaItemSub;
|
||||
late StreamSubscription _playbackStateSub;
|
||||
int? _currentIndex = -1;
|
||||
Duration _nextPosition = Duration.zero;
|
||||
Duration _nextOffset = Duration.zero;
|
||||
Duration _currentOffset = Duration.zero;
|
||||
final ScrollController _controller = ScrollController();
|
||||
final double height = 90;
|
||||
BoxConstraints? _widgetConstraints;
|
||||
Lyrics? _lyrics;
|
||||
bool _loading = true;
|
||||
CancelableOperation<Lyrics>? _lyricsCancelable;
|
||||
CancelToken? _lyricsCancelToken;
|
||||
Object? _error;
|
||||
|
||||
bool _freeScroll = false;
|
||||
|
@ -63,7 +65,10 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
|
||||
Future<void> _loadForId(String trackId) async {
|
||||
// cancel current request, if applicable
|
||||
await _lyricsCancelable?.cancel();
|
||||
_lyricsCancelToken?.cancel();
|
||||
_currentIndex = -1;
|
||||
_currentOffset = Duration.zero;
|
||||
_nextOffset = Duration.zero;
|
||||
|
||||
//Fetch
|
||||
if (_loading == false && _lyrics != null) {
|
||||
|
@ -75,10 +80,9 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
}
|
||||
|
||||
try {
|
||||
_lyricsCancelable =
|
||||
CancelableOperation.fromFuture(deezerAPI.lyrics(trackId));
|
||||
final lyrics = await _lyricsCancelable!.valueOrCancellation(null);
|
||||
if (lyrics == null) return;
|
||||
_lyricsCancelToken = CancelToken();
|
||||
final lyrics =
|
||||
await deezerAPI.lyrics(trackId, cancelToken: _lyricsCancelToken);
|
||||
_syncedLyrics = lyrics.sync;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
@ -86,9 +90,10 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
_lyrics = lyrics;
|
||||
});
|
||||
|
||||
_nextPosition = Duration.zero;
|
||||
SchedulerBinding.instance.addPostFrameCallback(
|
||||
(_) => _updatePosition(audioHandler.playbackState.value.position));
|
||||
} on DioException catch (e) {
|
||||
if (e.type != DioExceptionType.cancel) rethrow;
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
@ -126,21 +131,22 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
void _updatePosition(Duration position) {
|
||||
if (_loading) return;
|
||||
if (!_syncedLyrics) return;
|
||||
if (position < _nextPosition) return;
|
||||
if (position < _nextOffset && position > _currentOffset) return;
|
||||
|
||||
_currentIndex =
|
||||
_lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
|
||||
//Scroll to current lyric
|
||||
if (_currentIndex! < 0) return;
|
||||
//Update current lyric index
|
||||
if (_currentIndex! < _lyrics!.lyrics!.length) {
|
||||
// update nextPosition
|
||||
_nextPosition = _lyrics!.lyrics![_currentIndex! + 1].offset!;
|
||||
|
||||
if (_currentIndex! < _lyrics!.lyrics!.length - 1) {
|
||||
// update nextOffset
|
||||
_nextOffset = _lyrics!.lyrics![_currentIndex! + 1].offset!;
|
||||
} else {
|
||||
// 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);
|
||||
if (_freeScroll) return;
|
||||
_scrollToLyric();
|
||||
|
@ -153,9 +159,6 @@ class _LyricsWidgetState extends State<LyricsWidget> {
|
|||
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
|
||||
_playbackStateSub = AudioService.position.listen(_updatePosition);
|
||||
});
|
||||
if (audioHandler.mediaItem.value != null) {
|
||||
_loadForId(audioHandler.mediaItem.value!.id);
|
||||
}
|
||||
|
||||
/// Track change = ~exit~ reload lyrics
|
||||
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {
|
||||
|
|
|
@ -389,7 +389,13 @@ class MenuSheet {
|
|||
Text('Play mix'.i18n),
|
||||
icon: const Icon(Icons.online_prediction),
|
||||
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!);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -46,18 +46,24 @@ class PlayerBar extends StatelessWidget {
|
|||
initialData: audioHandler.mediaItem.valueOrNull,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data == null) {
|
||||
return Material(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.standard,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0, vertical: 6.0),
|
||||
leading: Image.asset(
|
||||
'assets/cover_thumb.jpg',
|
||||
width: 48.0,
|
||||
height: 48.0,
|
||||
// lazy way to prevent dragging up
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragEnd: (_) {},
|
||||
onVerticalDragUpdate: (_) {},
|
||||
child: Material(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.standard,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0, vertical: 6.0),
|
||||
leading: Image.asset(
|
||||
'assets/cover_thumb.jpg',
|
||||
width: 48.0,
|
||||
height: 48.0,
|
||||
),
|
||||
title: Text('Nothing is currently playing'.i18n),
|
||||
),
|
||||
title: Text('Nothing is currently playing'.i18n),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -72,6 +78,7 @@ class PlayerBar extends StatelessWidget {
|
|||
? Hero(tag: currentMediaItem.id, child: image)
|
||||
: image;
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
tileColor: _backgroundColor,
|
||||
|
|
|
@ -333,9 +333,11 @@ class PlayerScreenDesktop extends StatelessWidget {
|
|||
showQueueButton: false,
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(const Size.square(500)),
|
||||
child: const BigAlbumArt()),
|
||||
Flexible(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.loose(const Size.square(500)),
|
||||
child: const BigAlbumArt()),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: PlayerTextSubtext(textSize: 18.sp),
|
||||
|
@ -435,7 +437,6 @@ class _FitOrScrollTextState extends State<FitOrScrollText> {
|
|||
);
|
||||
|
||||
textPainter.layout(maxWidth: constraints.maxWidth);
|
||||
print(textPainter.didExceedMaxLines);
|
||||
|
||||
return !(textPainter.didExceedMaxLines ||
|
||||
textPainter.height > constraints.maxHeight ||
|
||||
|
@ -458,7 +459,7 @@ class _FitOrScrollTextState extends State<FitOrScrollText> {
|
|||
startPadding: 0.0,
|
||||
accelerationDuration: const Duration(seconds: 1),
|
||||
pauseAfterRound: const Duration(seconds: 2),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
fadingEdgeEndFraction: 0.05,
|
||||
fadingEdgeStartFraction: 0.05,
|
||||
);
|
||||
|
@ -483,12 +484,15 @@ class PlayerTextSubtext extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
FitOrScrollText(
|
||||
key: Key(currentMediaItem.displayTitle!),
|
||||
text: currentMediaItem.displayTitle!,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: textSize, fontWeight: FontWeight.bold)),
|
||||
SizedBox(
|
||||
height: 1.5 * textSize,
|
||||
child: FitOrScrollText(
|
||||
key: Key(currentMediaItem.displayTitle!),
|
||||
text: currentMediaItem.displayTitle!,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: textSize, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
// child: currentMediaItem.displayTitle!.length >= 26
|
||||
// ? Marquee(
|
||||
// key: Key(currentMediaItem.displayTitle!),
|
||||
|
@ -511,7 +515,6 @@ class PlayerTextSubtext extends StatelessWidget {
|
|||
// style: TextStyle(
|
||||
// fontSize: textSize, fontWeight: FontWeight.bold),
|
||||
// )),
|
||||
const SizedBox(height: 2.0),
|
||||
Text(
|
||||
currentMediaItem.displaySubtitle ?? '',
|
||||
maxLines: 1,
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
@ -59,6 +60,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
final _suggestions = ListNotifier<String>([]);
|
||||
final _showingSuggestions = ValueNotifier(false);
|
||||
final _loading = ValueNotifier(false);
|
||||
CancelToken? _searchCancelToken;
|
||||
Timer? _searchTimer;
|
||||
final _focus = FocusNode();
|
||||
final _textFieldFocusNode = FocusNode();
|
||||
|
@ -121,16 +123,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
_controller.text.length < 2 ||
|
||||
_controller.text.startsWith('http')) return;
|
||||
_loading.value = true;
|
||||
_searchCancelToken?.cancel();
|
||||
|
||||
//Load
|
||||
List<String>? sugg;
|
||||
final List<String>? suggestions;
|
||||
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) {
|
||||
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) {
|
||||
|
@ -193,8 +203,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
if (query.isEmpty) {
|
||||
_suggestions.clear();
|
||||
} else {
|
||||
_searchTimer ??= Timer(
|
||||
const Duration(milliseconds: 300), () {
|
||||
_searchTimer ??=
|
||||
Timer(const Duration(milliseconds: 1), () {
|
||||
_searchTimer = null;
|
||||
_loadSuggestions();
|
||||
});
|
||||
|
@ -274,16 +284,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
),
|
||||
...List.generate(min(cache.searchHistory.length, 10),
|
||||
(int i) {
|
||||
final data = cache.searchHistory[i].data;
|
||||
switch (cache.searchHistory[i].type) {
|
||||
case SearchHistoryItemType.track:
|
||||
switch (cache.searchHistory[i]) {
|
||||
case final Track data:
|
||||
return TrackTile.fromTrack(
|
||||
data,
|
||||
onTap: () {
|
||||
List<Track?> queue = cache.searchHistory
|
||||
.where((h) =>
|
||||
h.type == SearchHistoryItemType.track)
|
||||
.map<Track>((t) => t.data)
|
||||
final queue = cache.searchHistory
|
||||
.whereType<Track>()
|
||||
.toList();
|
||||
playerHelper.playFromTrackList(
|
||||
queue,
|
||||
|
@ -297,7 +304,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
.defaultTrackMenu(data, details: details),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
case SearchHistoryItemType.album:
|
||||
case final Album data:
|
||||
return AlbumTile(
|
||||
data,
|
||||
onTap: () {
|
||||
|
@ -308,7 +315,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
.defaultAlbumMenu(data, details: details),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
case SearchHistoryItemType.artist:
|
||||
case final Artist data:
|
||||
return ArtistHorizontalTile(
|
||||
data,
|
||||
onTap: () {
|
||||
|
@ -320,7 +327,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||
MenuSheet(context).defaultArtistMenu(data),
|
||||
trailing: _removeHistoryItemWidget(i),
|
||||
);
|
||||
case SearchHistoryItemType.playlist:
|
||||
case final Playlist data:
|
||||
return PlaylistTile(
|
||||
data,
|
||||
onTap: () {
|
||||
|
@ -480,13 +487,7 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
.getRange(0, min(results.tracks!.length, 3)))
|
||||
TrackTile.fromTrack(track, onTap: () {
|
||||
cache.addToSearchHistory(track);
|
||||
playerHelper.playFromTrackList(
|
||||
results.tracks!,
|
||||
track.id,
|
||||
QueueSource(
|
||||
text: 'Search'.i18n,
|
||||
id: query,
|
||||
source: 'search'));
|
||||
playerHelper.playSearchMixDeferred(track);
|
||||
}, onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(track, details: details);
|
||||
|
@ -495,12 +496,8 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
title: Text('Show all tracks'.i18n),
|
||||
onTap: () {
|
||||
Navigator.of(context).pushRoute(
|
||||
builder: (context) => TrackListScreen(
|
||||
results.tracks,
|
||||
QueueSource(
|
||||
id: query,
|
||||
source: 'search',
|
||||
text: 'Search'.i18n)));
|
||||
builder: (context) =>
|
||||
TrackListScreen(results.tracks, null));
|
||||
},
|
||||
),
|
||||
const FreezerDivider(),
|
||||
|
@ -699,8 +696,8 @@ class SearchResultsScreen extends StatelessWidget {
|
|||
|
||||
//List all tracks
|
||||
class TrackListScreen extends StatelessWidget {
|
||||
final QueueSource queueSource;
|
||||
final List<Track?>? tracks;
|
||||
final QueueSource? queueSource;
|
||||
final List<Track>? tracks;
|
||||
|
||||
const TrackListScreen(this.tracks, this.queueSource, {super.key});
|
||||
|
||||
|
@ -711,11 +708,16 @@ class TrackListScreen extends StatelessWidget {
|
|||
body: ListView.builder(
|
||||
itemCount: tracks!.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
Track t = tracks![i]!;
|
||||
Track t = tracks![i];
|
||||
return TrackTile.fromTrack(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(tracks!, t.id, queueSource);
|
||||
if (queueSource == null) {
|
||||
playerHelper.playSearchMixDeferred(t);
|
||||
return;
|
||||
}
|
||||
|
||||
playerHelper.playFromTrackList(tracks!, t.id, queueSource!);
|
||||
},
|
||||
onSecondary: (details) {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
|
|
|
@ -28,5 +28,10 @@
|
|||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
Loading…
Reference in New Issue