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/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View File

@ -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)

View File

@ -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 {}
}

View File

@ -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 }

View File

@ -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;
}

View File

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

View File

@ -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);
}

View File

@ -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) {

View File

@ -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());

View File

@ -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(

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/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,

View File

@ -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) {}
}

View File

@ -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) {

View File

@ -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!);
},
);

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

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