freezer/lib/api/definitions.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

1574 lines
46 KiB
Dart

import 'dart:math';
import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/page_routes/blur_slide.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart';
import 'package:just_audio/just_audio.dart';
import 'package:freezer/translations.i18n.dart';
import 'dart:convert';
import 'package:logging/logging.dart';
part 'definitions.g.dart';
abstract class DeezerMediaItem {
String? get id;
}
@HiveType(typeId: 5)
@JsonSerializable()
class Track extends DeezerMediaItem {
@override
@HiveField(0)
String id;
@HiveField(1)
String? title;
@HiveField(2)
Album? album;
@HiveField(3)
List<Artist>? artists;
@HiveField(4)
Duration? duration;
@HiveField(5)
DeezerImageDetails? albumArt;
@HiveField(6)
int? trackNumber;
@HiveField(7)
bool? offline;
@HiveField(8)
Lyrics? lyrics;
@HiveField(9)
bool? favorite;
@HiveField(10)
int? diskNumber;
@HiveField(11)
bool? explicit;
@HiveField(12)
//Date added to playlist / favorites
int? addedDate;
// information for playback
String? trackToken;
int? trackTokenExpiration;
@HiveField(13)
List<dynamic>? playbackDetails;
Track({
required this.id,
this.title,
this.duration,
this.album,
this.playbackDetails,
this.albumArt,
this.artists,
this.trackNumber,
this.offline,
this.lyrics,
this.favorite,
this.diskNumber,
this.explicit,
this.addedDate,
this.trackToken,
this.trackTokenExpiration,
});
String get artistString => artists == null
? ""
: artists!.map<String>((art) => art.name!).join(', ');
String get durationString => durationAsString(duration!);
static String durationAsString(Duration duration) {
return "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
}
//MediaItem
Future<MediaItem> toMediaItem() async {
return MediaItem(
title: title!,
album: album!.title!,
artist: artists![0].name,
displayTitle: title,
displaySubtitle: artistString,
displayDescription: album!.title,
// audio_service already does caching for us, so doing it ourselves is NOT needed
artUri: Uri.parse(albumArt!.full),
duration: duration,
id: id,
extras: {
"playbackDetails": jsonEncode(playbackDetails),
"thumb": albumArt!.thumb,
"lyrics": jsonEncode(lyrics!.toJson()),
"albumId": album!.id,
"trackToken": trackToken,
"trackTokenExpiration": trackTokenExpiration,
"artists":
jsonEncode(artists!.map<Map>((art) => art.toJson()).toList())
});
}
factory Track.fromMediaItem(MediaItem mi) {
//Load album and artists.
//It is stored separately, to save id and other metadata
Album? album;
// e('/storage/emulated/0/Android/data/f.f.freezer/files/test.json')
// .writeAsStringSync(jsonEncode(mi.extras!));
List<Artist>? artists;
if (mi.extras != null) {
album = Album(title: mi.album, id: mi.extras!['albumId']);
if (mi.extras!['artists'] != null) {
artists = jsonDecode(mi.extras!['artists'])
.map<Artist>((j) => Artist.fromJson(j))
.toList();
}
}
List<String>? playbackDetails;
if (mi.extras!['playbackDetails'] != null) {
playbackDetails = (jsonDecode(mi.extras!['playbackDetails']) ?? [])
.map<String>((e) => e.toString())
.toList();
}
return Track(
title: mi.title,
artists: artists,
album: album,
id: mi.id,
albumArt: DeezerImageDetails.fromUrl(mi.artUri.toString()),
duration: mi.duration!,
playbackDetails: playbackDetails,
lyrics:
Lyrics.fromJson(jsonDecode(((mi.extras ?? {})['lyrics']) ?? "{}")));
}
//JSON
factory Track.fromPrivateJson(Map<dynamic, dynamic> json,
{bool favorite = false}) {
String? title = json['SNG_TITLE'];
if (json['VERSION'] != null && json['VERSION'] != '') {
title = "${json['SNG_TITLE']} ${json['VERSION']}";
}
return Track(
id: json['SNG_ID'].toString(),
title: title!,
duration: Duration(seconds: int.parse(json['DURATION'])),
albumArt: DeezerImageDetails(json['ALB_PICTURE']),
album: Album.fromPrivateJson(json),
artists: (json['ARTISTS'] ?? [json])
.map<Artist>((dynamic art) => Artist.fromPrivateJson(art))
.toList(),
trackNumber: int.parse((json['TRACK_NUMBER'] ?? '0').toString()),
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
favorite: favorite,
diskNumber: int.parse(json['DISK_NUMBER'] ?? '1'),
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true : false,
addedDate: json['DATE_ADD'],
trackToken: json['TRACK_TOKEN'],
trackTokenExpiration: json['TRACK_TOKEN_EXPIRE'],
);
}
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'title': title,
'album': album!.id,
'artists': artists!.map<String?>((dynamic a) => a.id).join(','),
'duration': duration?.inSeconds,
'albumArt': albumArt!.full,
'trackNumber': trackNumber,
'offline': off ? 1 : 0,
'lyrics': jsonEncode(lyrics!.toJson()),
'favorite': (favorite ?? false) ? 1 : 0,
'diskNumber': diskNumber,
'explicit': (explicit ?? false) ? 1 : 0,
//'favoriteDate': favoriteDate
};
factory Track.fromSQL(Map<String, dynamic> data) => Track(
id: data['trackId'] ?? data['id'], //If loading from downloads table
title: data['title'],
album: Album(id: data['album']),
duration: Duration(seconds: data['duration']),
albumArt: DeezerImageDetails.fromUrl(data['albumArt']),
trackNumber: data['trackNumber'],
artists: List<Artist>.generate(data['artists'].split(',').length,
(i) => Artist(id: data['artists'].split(',')[i])),
offline: (data['offline'] == 1) ? true : false,
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
favorite: (data['favorite'] == 1) ? true : false,
diskNumber: data['diskNumber'],
explicit: (data['explicit'] == 1) ? true : false,
//favoriteDate: data['favoriteDate']
);
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
}
@HiveType(typeId: 13)
enum AlbumType {
@HiveField(0)
ALBUM,
@HiveField(1)
SINGLE,
@HiveField(2)
FEATURED
}
@HiveType(typeId: 12)
@JsonSerializable()
class Album extends DeezerMediaItem {
@override
@HiveField(0)
final String? id;
@HiveField(1)
final String? title;
@HiveField(2)
List<Artist>? artists;
@HiveField(3)
List<Track>? tracks;
@HiveField(4)
final DeezerImageDetails? art;
@HiveField(5)
final int? fans;
@HiveField(6)
final bool?
offline; //If the album is offline, or just saved in db as metadata
@HiveField(7)
bool? library;
@HiveField(8)
final AlbumType? type;
@HiveField(9)
final String? releaseDate;
@HiveField(10)
final String? favoriteDate;
Album(
{this.id,
this.title,
this.art,
this.artists,
this.tracks,
this.fans,
this.offline,
this.library,
this.type,
this.releaseDate,
this.favoriteDate});
String get artistString =>
artists!.map<String?>((art) => art.name).join(', ');
Duration get duration =>
Duration(seconds: tracks!.fold(0, (v, t) => v += t.duration!.inSeconds));
String get durationString =>
"${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
String get fansString => NumberFormat.compact().format(fans);
//JSON
factory Album.fromPrivateJson(Map<dynamic, dynamic> json,
{Map<dynamic, dynamic> songsJson = const {}, bool library = false}) {
AlbumType type = AlbumType.ALBUM;
if (json['TYPE'] != null && json['TYPE'].toString() == "0") {
type = AlbumType.SINGLE;
}
if (json['ROLE_ID'] == 5) type = AlbumType.FEATURED;
return Album(
id: json['ALB_ID'].toString(),
title: json['ALB_TITLE'],
art: DeezerImageDetails(json['ALB_PICTURE']),
artists: (json['ARTISTS'] ?? [json])
.map<Artist>((dynamic art) => Artist.fromPrivateJson(art))
.toList(),
tracks: (songsJson['data'] ?? [])
.map<Track>((dynamic track) => Track.fromPrivateJson(track))
.toList(),
fans: json['NB_FAN'],
library: library,
type: type,
releaseDate:
json['DIGITAL_RELEASE_DATE'] ?? json['PHYSICAL_RELEASE_DATE'],
favoriteDate: json['DATE_FAVORITE']);
}
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'title': title,
'artists': (artists ?? []).map<String?>((dynamic a) => a.id).join(','),
'tracks': (tracks ?? []).map<String?>((dynamic t) => t.id).join(','),
'art': art?.full ?? '',
'fans': fans,
'offline': off ? 1 : 0,
'library': (library ?? false) ? 1 : 0,
'type': type == null ? null : AlbumType.values.indexOf(type!),
'releaseDate': releaseDate,
//'favoriteDate': favoriteDate
};
factory Album.fromSQL(Map<String, dynamic> data) => Album(
id: data['id'],
title: data['title'],
artists: List<Artist>.generate(data['artists'].split(',').length,
(i) => Artist(id: data['artists'].split(',')[i])),
tracks: List<Track>.generate(data['tracks'].split(',').length,
(i) => Track(id: data['tracks'].split(',')[i])),
art: data['art'] == null || data['art'] == ''
? null
: DeezerImageDetails.fromUrl(data['art']),
fans: data['fans'],
offline: (data['offline'] == 1) ? true : false,
library: (data['library'] == 1) ? true : false,
type: AlbumType.values[(data['type'] == -1) ? 0 : data['type']],
releaseDate: data['releaseDate'],
//favoriteDate: data['favoriteDate']
);
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
Map<String, dynamic> toJson() => _$AlbumToJson(this);
}
enum ArtistHighlightType { ALBUM }
@JsonSerializable()
class ArtistHighlight {
dynamic data;
ArtistHighlightType? type;
String? title;
ArtistHighlight({this.data, this.type, this.title});
static ArtistHighlight? fromPrivateJson(Map<dynamic, dynamic>? json) {
if (json == null || json['ITEM'] == null) return null;
switch (json['TYPE']) {
case 'album':
return ArtistHighlight(
data: Album.fromPrivateJson(json['ITEM']),
type: ArtistHighlightType.ALBUM,
title: json['TITLE']);
}
return null;
}
//JSON
factory ArtistHighlight.fromJson(Map<String, dynamic> json) =>
_$ArtistHighlightFromJson(json);
Map<String, dynamic> toJson() => _$ArtistHighlightToJson(this);
}
@HiveType(typeId: 11)
@JsonSerializable()
class Artist extends DeezerMediaItem {
@override
@HiveField(0)
String id;
@HiveField(1)
String? name;
@HiveField(2)
List<Album>? albums;
@HiveField(3)
int? albumCount;
@HiveField(4)
List<Track>? topTracks;
@HiveField(5)
DeezerImageDetails? picture;
@HiveField(6)
int? fans;
@HiveField(7)
bool? offline;
@HiveField(8)
bool? library;
@HiveField(9)
bool? radio;
@HiveField(10)
String? favoriteDate;
@HiveField(11)
ArtistHighlight? highlight;
Artist(
{required this.id,
this.name,
this.albums,
this.albumCount,
this.topTracks,
this.picture,
this.fans,
this.offline,
this.library,
this.radio,
this.favoriteDate,
this.highlight});
String get fansString => NumberFormat.compact().format(fans);
//JSON
factory Artist.fromPrivateJson(Map<dynamic, dynamic> json,
{Map<dynamic, dynamic> albumsJson = const {},
Map<dynamic, dynamic> topJson = const {},
Map<dynamic, dynamic>? highlight,
bool library = false}) {
//Get wether radio is available
bool radio = false;
if (json['SMARTRADIO'] == true || json['SMARTRADIO'] == 1) radio = true;
return Artist(
id: json['ART_ID'].toString(),
name: json['ART_NAME'],
fans: json['NB_FAN'],
picture: json.containsKey('ART_PICTURE')
? DeezerImageDetails(json['ART_PICTURE'], type: 'artist')
: null,
albumCount: albumsJson['total'] as int?,
albums: (albumsJson['data'] ?? [])
.map<Album>((dynamic data) => Album.fromPrivateJson(data))
.toList(),
topTracks: (topJson['data'] ?? [])
.map<Track>((dynamic data) => Track.fromPrivateJson(data))
.toList(),
library: library,
radio: radio,
favoriteDate: json['DATE_FAVORITE'],
highlight: ArtistHighlight.fromPrivateJson(highlight));
}
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'name': name,
'albums': albums!.map<String?>((dynamic a) => a.id).join(','),
'topTracks': topTracks!.map<String?>((dynamic t) => t.id).join(','),
'picture': picture?.full,
'fans': fans,
'albumCount': albumCount ?? (albums ?? []).length,
'offline': off ? 1 : 0,
'library': (library ?? false) ? 1 : 0,
'radio': radio! ? 1 : 0,
//'favoriteDate': favoriteDate
};
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
id: data['id'],
name: data['name'],
topTracks: List<Track>.generate(data['topTracks'].split(',').length,
(i) => Track(id: data['topTracks'].split(',')[i])),
albums: List<Album>.generate(data['albums'].split(',').length,
(i) => Album(id: data['albums'].split(',')[i])),
albumCount: data['albumCount'],
picture: data['picture'] == null
? null
: DeezerImageDetails.fromUrl(data['picture']),
fans: data['fans'],
offline: (data['offline'] == 1) ? true : false,
library: (data['library'] == 1) ? true : false,
radio: (data['radio'] == 1) ? true : false,
//favoriteDate: data['favoriteDate']
);
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
Map<String, dynamic> toJson() => _$ArtistToJson(this);
}
@HiveType(typeId: 9)
@JsonSerializable()
class Playlist extends DeezerMediaItem {
@override
@HiveField(0)
String id;
@HiveField(1)
String? title;
@HiveField(2)
List<Track>? tracks;
@HiveField(3)
@JsonImageDetailsConverter()
ImageDetails? image;
@HiveField(4)
Duration? duration;
@HiveField(5)
int? trackCount;
@HiveField(6)
User? user;
@HiveField(7)
int? fans;
@HiveField(8)
bool? library;
@HiveField(9)
String? description;
Playlist(
{required this.id,
this.title,
this.tracks,
this.image,
this.trackCount,
this.duration,
this.user,
this.fans,
this.library,
this.description});
String get durationString =>
"${duration!.inHours}:${duration!.inMinutes.remainder(60).toString().padLeft(2, '0')}:${duration!.inSeconds.remainder(60).toString().padLeft(2, '0')}";
//JSON
factory Playlist.fromPrivateJson(Map<dynamic, dynamic> json,
{Map<dynamic, dynamic> songsJson = const {}, bool library = false}) =>
Playlist(
id: json['PLAYLIST_ID'].toString(),
title: json['TITLE'],
trackCount: json['NB_SONG'] ?? songsJson['total'],
image: DeezerImageDetails(json['PLAYLIST_PICTURE'],
type: json['PICTURE_TYPE']),
fans: json['NB_FAN'],
duration: Duration(seconds: json['DURATION'] ?? 0),
description: json['DESCRIPTION'],
user: User(
id: json['PARENT_USER_ID'],
name: json['PARENT_USERNAME'] ?? '',
picture: json.containsKey('PARENT_USER_PICTURE')
? DeezerImageDetails(json['PARENT_USER_PICTURE'],
type: 'user')
: null),
tracks: (songsJson['data'] ?? [])
.map<Track>((dynamic data) => Track.fromPrivateJson(data))
.toList(),
library: library);
Map<String, dynamic> toSQL() => {
'id': id,
'title': title,
'tracks': tracks!.map<String?>((dynamic t) => t.id).join(','),
'image': image!.full,
'duration': duration!.inSeconds,
'userId': user!.id,
'userName': user!.name,
'fans': fans,
'description': description,
'library': (library ?? false) ? 1 : 0
};
// TODO: fix
factory Playlist.fromSQL(data) => Playlist(
id: data['id'],
title: data['title'],
description: data['description'],
tracks: data["tracks"] == null
? null
: List<Track>.generate(data['tracks'].split(',').length,
(i) => Track(id: data['tracks'].split(',')[i])),
image: DeezerImageDetails.fromUrl(data['image']),
duration:
data['duration'] == null ? null : Duration(seconds: data['duration']),
user: User(id: data['userId'], name: data['userName']),
fans: data['fans'],
library: (data['library'] == 1) ? true : false);
factory Playlist.fromJson(Map<String, dynamic> json) =>
_$PlaylistFromJson(json);
Map<String, dynamic> toJson() => _$PlaylistToJson(this);
}
@HiveType(typeId: 10)
@JsonSerializable()
class User {
@HiveField(0)
String? id;
@HiveField(1)
String? name;
@HiveField(2)
DeezerImageDetails? picture;
User({this.id, this.name, this.picture});
//Mostly handled by playlist
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
class JsonImageDetailsConverter
extends JsonConverter<ImageDetails, Map<String, dynamic>> {
const JsonImageDetailsConverter();
@override
Map<String, dynamic> toJson(ImageDetails object) => object.toJson();
@override
ImageDetails fromJson(Map<String, dynamic> json) {
if (json.containsKey('full') || json.containsKey('thumb')) {
return UrlImageDetails.fromJson(json);
} else {
return DeezerImageDetails.fromJson(json);
}
}
}
abstract class ImageDetails {
String get full => throw UnimplementedError("get full is not implemented");
String get thumb => throw UnimplementedError("get thumb is not implemented");
Map<String, dynamic> toJson() =>
throw UnimplementedError("toJson() is not implemented");
}
@JsonSerializable()
class UrlImageDetails extends ImageDetails {
@override
final String full;
@override
final String thumb;
UrlImageDetails({required this.full, required this.thumb});
factory UrlImageDetails.single(String url) =>
UrlImageDetails(full: url, thumb: url);
factory UrlImageDetails.fromJson(Map<String, dynamic> json) =>
_$UrlImageDetailsFromJson(json);
@override
Map<String, dynamic> toJson() => _$UrlImageDetailsToJson(this);
}
// TODO: migrate to Uri instead of String
@HiveType(typeId: 6)
@JsonSerializable()
class DeezerImageDetails extends ImageDetails {
@HiveField(0)
String type;
@HiveField(1)
String md5;
DeezerImageDetails(this.md5, {this.type = 'cover'});
@override
String get full => size(1000, 1000);
@override
String get thumb => size(140, 140);
String size(int width, int height,
{int num = 80, String id = '000000', String format = 'jpg'}) =>
'https://e-cdns-images.dzcdn.net/images/$type/$md5/${width}x$height-$id-$num-0-0.$format';
//JSON
factory DeezerImageDetails.fromUrl(String url) {
final uri = Uri.parse(url);
return DeezerImageDetails(uri.pathSegments[2], type: uri.pathSegments[1]);
}
factory DeezerImageDetails.fromPrivateJson(Map<dynamic, dynamic> json) =>
DeezerImageDetails(json['md5'].split('-').first, type: json['type']);
factory DeezerImageDetails.fromJson(Map<String, dynamic> json) =>
_$DeezerImageDetailsFromJson(json);
@override
Map<String, dynamic> toJson() => _$DeezerImageDetailsToJson(this);
}
class SearchResults {
List<Track>? tracks;
List<Album>? albums;
List<Artist>? artists;
List<Playlist>? playlists;
List<Show>? shows;
List<ShowEpisode>? episodes;
SearchResults(
{this.tracks,
this.albums,
this.artists,
this.playlists,
this.shows,
this.episodes});
//Check if no search results
bool get empty {
return ((tracks == null || tracks!.isEmpty) &&
(albums == null || albums!.isEmpty) &&
(artists == null || artists!.isEmpty) &&
(playlists == null || playlists!.isEmpty) &&
(shows == null || shows!.isEmpty) &&
(episodes == null || episodes!.isEmpty));
}
factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) =>
SearchResults(
tracks: json['TRACK']['data']
.map<Track>((dynamic data) => Track.fromPrivateJson(data))
.toList(),
albums: json['ALBUM']['data']
.map<Album>((dynamic data) => Album.fromPrivateJson(data))
.toList(),
artists: json['ARTIST']['data']
.map<Artist>((dynamic data) => Artist.fromPrivateJson(data))
.toList(),
playlists: json['PLAYLIST']['data']
.map<Playlist>((dynamic data) => Playlist.fromPrivateJson(data))
.toList(),
shows: json['SHOW']['data']
.map<Show>((dynamic data) => Show.fromPrivateJson(data))
.toList(),
episodes: json['EPISODE']['data']
.map<ShowEpisode>(
(dynamic data) => ShowEpisode.fromPrivateJson(data))
.toList());
}
@HiveType(typeId: 7)
@JsonSerializable()
class Lyrics {
@HiveField(0)
String? id;
@HiveField(1)
String? writers;
@HiveField(2)
List<Lyric>? lyrics;
bool sync;
Lyrics({this.id, this.writers, this.lyrics, this.sync = true});
static error() => Lyrics(id: null, writers: null, lyrics: [
Lyric(
offset: const Duration(milliseconds: 0),
text: 'Lyrics unavailable, empty or failed to load!'.i18n)
]);
//JSON
factory Lyrics.fromPrivateJson(Map<dynamic, dynamic> json) {
return Lyrics(
id: json['LYRICS_ID'],
writers: json['LYRICS_WRITERS'],
sync: json.containsKey('LYRICS_SYNC_JSON'),
lyrics: json.containsKey('LYRICS_SYNC_JSON')
? ((json['LYRICS_SYNC_JSON'] ?? []) as List)
.map<Lyric>((l) => Lyric.fromPrivateJson(l))
.whereNot((l) => l.offset == null)
.toList()
: [Lyric(text: json['LYRICS_TEXT'])]);
}
factory Lyrics.fromJson(Map<String, dynamic> json) => _$LyricsFromJson(json);
Map<String, dynamic> toJson() => _$LyricsToJson(this);
}
@HiveType(typeId: 8)
@JsonSerializable()
class Lyric {
@HiveField(0)
Duration? offset;
@HiveField(1)
String? text;
@HiveField(2)
String? lrcTimestamp;
Lyric({this.offset, this.text, this.lrcTimestamp});
//JSON
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
if (json['milliseconds'] == null || json['line'] == null) {
return Lyric(); //Empty lyric
}
return Lyric(
offset:
Duration(milliseconds: int.parse(json['milliseconds'].toString())),
text: json['line'],
lrcTimestamp: json['lrc_timestamp']);
}
factory Lyric.fromJson(Map<String, dynamic> json) => _$LyricFromJson(json);
Map<String, dynamic> toJson() => _$LyricToJson(this);
}
@HiveType(typeId: 32)
@JsonSerializable()
class QueueSource {
@HiveField(0)
final String? id;
@HiveField(1)
final String? text;
@HiveField(2)
final String? source;
const QueueSource({this.id, this.text, this.source});
factory QueueSource.fromJson(Map<String, dynamic> json) =>
_$QueueSourceFromJson(json);
Map<String, dynamic> toJson() => _$QueueSourceToJson(this);
}
@HiveType(typeId: 4)
@JsonSerializable()
class SmartTrackList {
static String? _configFromJson(String v) => v == 'default' ? null : v;
@HiveField(0)
String? id;
@HiveField(1)
String? title;
@HiveField(2)
String? subtitle;
@HiveField(3)
String? description;
@HiveField(4)
int? trackCount;
@HiveField(5)
List<Track>? tracks;
@HiveField(6)
List<DeezerImageDetails>? cover;
@HiveField(7, defaultValue: null)
@JsonKey(fromJson: _configFromJson)
String? flowConfig;
SmartTrackList({
this.id,
this.title,
this.description,
this.trackCount,
this.tracks,
this.cover,
this.subtitle,
this.flowConfig,
});
//JSON
factory SmartTrackList.fromPrivateJson(Map<dynamic, dynamic> json,
{Map<dynamic, dynamic> songsJson = const {}}) {
final String id;
final String? flowConfig;
final Map data = json['data'] ?? json['DATA'];
if (json['type'] != null && json['type'] == 'flow') {
id = 'flow';
flowConfig = json['id'];
} else {
id = data['SMARTTRACKLIST_ID'];
flowConfig = null;
}
return SmartTrackList(
id: id,
flowConfig: flowConfig,
title: json['title'] ?? data['TITLE'],
subtitle: json['subtitle'] ?? data['SUBTITLE'],
description: json['description'] ?? data['DESCRIPTION'] ?? '',
trackCount: data['NB_SONG'] ?? songsJson['total'] ?? 0,
tracks: (songsJson['data'] ?? [])
.map<Track>((t) => Track.fromPrivateJson(t))
.toList(),
cover: ((json['pictures'] ??
(json.containsKey('COVER') ? [json['COVER']] : [])) as List)
.cast<Map>()
.map<DeezerImageDetails>(DeezerImageDetails.fromPrivateJson)
.toList(growable: false));
}
factory SmartTrackList.fromJson(Map<String, dynamic> json) =>
_$SmartTrackListFromJson(json);
Map<String, dynamic> toJson() => _$SmartTrackListToJson(this);
}
@JsonSerializable()
@HiveType(typeId: 33)
class HomePage {
static const Duration cacheDuration = Duration(hours: 12);
@HiveField(0)
final List<HomePageSection> sections;
@HiveField(1)
late final DateTime lastUpdated;
static const _boxName = 'channels';
HomePage({required this.sections, DateTime? lastUpdated}) {
if (lastUpdated != null) {
this.lastUpdated = lastUpdated;
} else {
this.lastUpdated = DateTime.now();
}
}
static Future<bool> exists(String channel) async {
if (!await Hive.boxExists(_boxName)) return false;
return (await Hive.openLazyBox<HomePage>(_boxName)).containsKey(channel);
}
Future<void> save(String channel) async {
final box = await Hive.openLazyBox<HomePage>(_boxName);
await box.delete(channel);
await box.put(channel, this);
await box.close();
}
static Future<HomePage?> local(String channel) async {
final LazyBox<HomePage> box;
try {
box = await Hive.openLazyBox<HomePage>(_boxName);
} catch (e) {
Logger.root.warning(e);
return null;
}
final instance = box.get(channel, defaultValue: null);
await box.close();
return instance;
}
//JSON
factory HomePage.fromPrivateJson(Map<dynamic, dynamic> json) {
if (json['sections'] == null) return HomePage(sections: []);
final sections = (json['sections'] as List)
.map<HomePageSection?>(
(element) => HomePageSection.fromPrivateJson(element))
.where((element) => element != null)
.cast<HomePageSection>();
return HomePage(sections: sections.toList());
}
factory HomePage.fromJson(Map<String, dynamic> json) =>
_$HomePageFromJson(json);
Map<String, dynamic> toJson() => _$HomePageToJson(this);
Future<void> close() async {
if (Hive.isBoxOpen(_boxName)) await Hive.box(_boxName).close();
}
}
@HiveType(typeId: 0)
@JsonSerializable()
class HomePageSection {
static final _logger = Logger('HomePageSection');
@HiveField(0)
String? title;
@HiveField(1)
HomePageSectionLayout layout;
//For loading more items
@HiveField(2)
String? pagePath;
@HiveField(3)
bool? hasMore;
@HiveField(4)
@JsonKey(fromJson: _homePageItemFromJson, toJson: _homePageItemToJson)
List<HomePageItem>? items;
HomePageSection(
{required this.layout,
this.items,
this.title,
this.pagePath,
this.hasMore});
//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
}[json['layout'] ?? ''];
if (layout == null) {
_logger.warning('UNKNOWN LAYOUT: ${json['layout']}');
_logger.warning('LAYOUT DATA:');
_logger.warning(json);
return null;
}
final items = <HomePageItem>[];
for (var i in (json['items'] ?? [])) {
HomePageItem? hpi = HomePageItem.fromPrivateJson(i);
if (hpi != null) items.add(hpi);
}
return HomePageSection(
title: json['title'],
items: items,
layout: layout,
pagePath: json['target'],
hasMore: json['hasMoreItems'] ?? false);
}
factory HomePageSection.fromJson(Map<String, dynamic> json) =>
_$HomePageSectionFromJson(json);
Map<String, dynamic> toJson() => _$HomePageSectionToJson(this);
static _homePageItemFromJson(json) =>
json.map<HomePageItem>((d) => HomePageItem.fromJson(d)).toList();
static _homePageItemToJson(items) => items.map((i) => i.toJson()).toList();
}
@HiveType(typeId: 1)
class HomePageItem {
@HiveField(0)
final HomePageItemType type;
@HiveField(1)
final dynamic value;
HomePageItem({required this.type, this.value});
static HomePageItem? fromPrivateJson(Map<dynamic, dynamic> json) {
String? type = json['type'];
switch (type) {
//Smart Track List
case 'flow':
case 'smarttracklist':
return HomePageItem(
type: HomePageItemType.SMARTTRACKLIST,
value: SmartTrackList.fromPrivateJson(json));
case 'playlist':
return HomePageItem(
type: HomePageItemType.PLAYLIST,
value: Playlist.fromPrivateJson(json['data']));
case 'artist':
return HomePageItem(
type: HomePageItemType.ARTIST,
value: Artist.fromPrivateJson(json['data']));
case 'channel':
return HomePageItem(
type: HomePageItemType.CHANNEL,
value: DeezerChannel.fromPrivateJson(json));
case 'album':
return HomePageItem(
type: HomePageItemType.ALBUM,
value: Album.fromPrivateJson(json['data']));
case 'show':
return HomePageItem(
type: HomePageItemType.SHOW,
value: Show.fromPrivateJson(json['data']));
default:
return null;
}
}
factory HomePageItem.fromJson(Map<String, dynamic> json) {
String? t = json['type'];
switch (t) {
case 'SMARTTRACKLIST':
return HomePageItem(
type: HomePageItemType.SMARTTRACKLIST,
value: SmartTrackList.fromJson(json['value']));
case 'PLAYLIST':
return HomePageItem(
type: HomePageItemType.PLAYLIST,
value: Playlist.fromJson(json['value']));
case 'ARTIST':
return HomePageItem(
type: HomePageItemType.ARTIST,
value: Artist.fromJson(json['value']));
case 'CHANNEL':
return HomePageItem(
type: HomePageItemType.CHANNEL,
value: DeezerChannel.fromJson(json['value']));
case 'ALBUM':
return HomePageItem(
type: HomePageItemType.ALBUM, value: Album.fromJson(json['value']));
case 'SHOW':
return HomePageItem(
type: HomePageItemType.SHOW,
value: Show.fromPrivateJson(json['value']));
default:
throw Exception('Unexpected type $t for HomePageItem');
}
}
Map<String, dynamic> toJson() {
String type = describeEnum(this.type);
return {'type': type, 'value': value.toJson()};
}
}
@HiveType(typeId: 14)
@JsonSerializable()
class DeezerChannel {
@HiveField(0)
final String? id;
@HiveField(1)
final String? target;
@HiveField(2)
final String? title;
@HiveField(3)
@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson)
final Color backgroundColor;
@HiveField(4, defaultValue: null)
final DeezerImageDetails? logo;
@HiveField(5, defaultValue: null)
final DeezerImageDetails? picture;
const DeezerChannel(
{this.id,
this.title,
this.backgroundColor = Colors.blue,
this.target,
this.logo,
this.picture});
factory DeezerChannel.fromPrivateJson(Map<dynamic, dynamic> json) =>
DeezerChannel(
id: json['id'],
title: json['title'],
backgroundColor: Color(int.tryParse(
json['background_color']?.replaceFirst('#', 'FF') ?? "",
radix: 16) ??
Colors.blue.value),
target: json['target'].replaceFirst('/', ''),
logo: json['data']?['logo'] != null
? DeezerImageDetails(json['data']['logo'], type: 'misc')
: null,
picture: json.containsKey('pictures') && json['pictures'].length > 0
? DeezerImageDetails.fromPrivateJson(json['pictures'][0])
: null);
factory DeezerChannel.fromJson(Map<String, dynamic> json) =>
_$DeezerChannelFromJson(json);
Map<String, dynamic> toJson() => _$DeezerChannelToJson(this);
static Color _colorFromJson(int color) => Color(color);
static int _colorToJson(Color color) => color.value;
}
@HiveType(typeId: 2)
enum HomePageItemType {
@HiveField(0)
SMARTTRACKLIST,
@HiveField(1)
PLAYLIST,
@HiveField(2)
ARTIST,
@HiveField(3)
CHANNEL,
@HiveField(4)
ALBUM,
@HiveField(5)
SHOW,
}
@HiveType(typeId: 3)
enum HomePageSectionLayout {
@HiveField(0)
ROW,
@HiveField(1)
GRID,
}
enum RepeatType { NONE, LIST, TRACK }
enum DeezerLinkType { TRACK, ALBUM, ARTIST, PLAYLIST }
class DeezerLinkResponse {
DeezerLinkType? type;
String? id;
DeezerLinkResponse({this.type, this.id});
//String to DeezerLinkType
static typeFromString(String t) {
t = t.toLowerCase().trim();
if (t == 'album') return DeezerLinkType.ALBUM;
if (t == 'artist') return DeezerLinkType.ARTIST;
if (t == 'playlist') return DeezerLinkType.PLAYLIST;
if (t == 'track') return DeezerLinkType.TRACK;
return null;
}
}
//Sorting
@HiveType(typeId: 18)
enum SortType {
@HiveField(0)
DEFAULT,
@HiveField(1)
ALPHABETIC,
@HiveField(2)
ARTIST,
@HiveField(3)
ALBUM,
@HiveField(4)
RELEASE_DATE,
@HiveField(5)
POPULARITY,
@HiveField(6)
USER,
@HiveField(7)
TRACK_COUNT,
@HiveField(8)
DATE_ADDED
}
@HiveType(typeId: 19)
enum SortSourceTypes {
@HiveField(0)
TRACKS,
@HiveField(1)
PLAYLISTS,
@HiveField(2)
ALBUMS,
@HiveField(3)
ARTISTS,
@HiveField(4)
PLAYLIST
}
@HiveType(typeId: 17)
@JsonSerializable()
class Sorting {
@HiveField(0)
SortType? type;
@HiveField(1)
bool? reverse;
@HiveField(2)
//For preserving sorting
String? id;
@HiveField(3)
SortSourceTypes? sourceType;
Sorting(
{this.type = SortType.DEFAULT,
this.reverse = false,
this.id,
this.sourceType});
//Find index of sorting from cache
static int? index(SortSourceTypes type, {String? id}) {
//Find index
int index;
if (id != null) {
index =
cache.sorts.indexWhere((s) => s!.sourceType == type && s.id == id);
} else {
index = cache.sorts.indexWhere((s) => s!.sourceType == type);
}
if (index == -1) return null;
return index;
}
factory Sorting.fromJson(Map<String, dynamic> json) =>
_$SortingFromJson(json);
Map<String, dynamic> toJson() => _$SortingToJson(this);
}
@HiveType(typeId: 15)
@JsonSerializable()
class Show {
@HiveField(0)
String? name;
@HiveField(1)
String? description;
@HiveField(2)
DeezerImageDetails? art;
@HiveField(3)
String? id;
Show({this.name, this.description, this.art, this.id});
//JSON
factory Show.fromPrivateJson(Map<dynamic, dynamic> json) => Show(
id: json['SHOW_ID'],
name: json['SHOW_NAME'],
art: DeezerImageDetails(json['SHOW_ART_MD5'], type: 'talk'),
description: json['SHOW_DESCRIPTION']);
factory Show.fromJson(Map<String, dynamic> json) => _$ShowFromJson(json);
Map<String, dynamic> toJson() => _$ShowToJson(this);
}
@JsonSerializable()
class ShowEpisode {
String? id;
String? title;
String? description;
String? url;
Duration? duration;
String? publishedDate;
//Might not be fully available
Show? show;
ShowEpisode(
{this.id,
this.title,
this.description,
this.url,
this.duration,
this.publishedDate,
this.show});
String get durationString =>
"${duration!.inMinutes}:${duration!.inSeconds.remainder(60).toString().padLeft(2, '0')}";
//Generate MediaItem for playback
MediaItem toMediaItem(Show show) {
return MediaItem(
title: title!,
displayTitle: title,
displaySubtitle: show.name,
album: show.name!,
id: id!,
extras: {'showUrl': url, 'show': show.toJson(), 'thumb': show.art!.thumb},
displayDescription: description,
duration: duration,
artUri: Uri.parse(show.art!.full),
);
}
factory ShowEpisode.fromMediaItem(MediaItem mi) {
return ShowEpisode(
id: mi.id,
title: mi.title,
description: mi.displayDescription,
url: mi.extras!['showUrl'],
duration: mi.duration,
show: Show.fromJson(mi.extras!['show']));
}
//JSON
factory ShowEpisode.fromPrivateJson(Map<dynamic, dynamic> json) =>
ShowEpisode(
id: json['EPISODE_ID'],
title: json['EPISODE_TITLE'],
description: json['EPISODE_DESCRIPTION'],
url: json['EPISODE_DIRECT_STREAM_URL'],
duration: Duration(seconds: int.parse(json['DURATION'].toString())),
publishedDate: json['EPISODE_PUBLISHED_TIMESTAMP'],
show: Show.fromPrivateJson(json));
factory ShowEpisode.fromJson(Map<String, dynamic> json) =>
_$ShowEpisodeFromJson(json);
Map<String, dynamic> toJson() => _$ShowEpisodeToJson(this);
}
enum Format {
MP3,
FLAC,
}
enum Source {
offline,
stream,
}
typedef StreamInfoCallback = void Function(StreamQualityInfo qualityInfo);
@JsonSerializable()
class StreamQualityInfo {
final Format format;
// file size
final int? size;
// not available if offline
final AudioQuality? quality;
final Source source;
StreamQualityInfo({
required this.format,
required this.source,
required this.quality,
this.size,
});
factory StreamQualityInfo.fromJson(Map<String, dynamic> json) =>
_$StreamQualityInfoFromJson(json);
Map<String, dynamic> toJson() => _$StreamQualityInfoToJson(this);
int calculateBitrate(Duration duration) {
if (size == null || size == 0) return 0;
int bitrate = (((size! * 8) / 1000) / duration.inSeconds).round();
//Round to known values
if (bitrate > 122 && bitrate < 134) return 128;
if (bitrate > 315 && bitrate < 325) return 320;
return bitrate;
}
}
extension Reorder<T> on List<T> {
String test() {
return map((e) => (e as MediaItem).title).join(', ');
}
void reorder(int oldIndex, int newIndex) {
assert(oldIndex != newIndex);
if (oldIndex < newIndex) {
newIndex -= 1;
}
final element = removeAt(oldIndex);
insert(newIndex, element);
}
}
double hypot(num c1, num c2) => sqrt(pow(c1.abs(), 2) + pow(c2.abs(), 2));
Map<String, dynamic> mediaItemToJson(MediaItem mi) => {
'id': mi.id,
'title': mi.title,
'artUri': mi.artUri?.toString(),
'playable': mi.playable,
'duration': mi.duration?.inMilliseconds,
'extras': mi.extras,
'album': mi.album,
'artist': mi.artist,
'displayTitle': mi.displayTitle,
'displaySubtitle': mi.displaySubtitle,
'displayDescription': mi.displayDescription,
};
MediaItem mediaItemFromJson(Map<String, dynamic> json) => MediaItem(
id: json['id'] as String,
title: json['title'] as String,
artUri: json['artUri'] == null ? null : Uri.parse(json['artUri']),
playable: json['playable'] as bool?,
duration: json['duration'] == null
? null
: Duration(milliseconds: json['duration'] as int),
extras: json['extras'] as Map<String, dynamic>?,
album: json['album'] as String?,
artist: json['artist'] as String?,
displayTitle: json['displayTitle'] as String?,
displaySubtitle: json['displaySubtitle'] as String?,
displayDescription: json['displayDescription'] as String?,
);
/// Will generate a new darkened color by [percent], and leaves the opacity untouched
///
/// [percent] is a double which value is from 0 to 1, the closer to one, the darker the color is
Color darken(Color color, {double percent = 0.25}) =>
Color.lerp(color, Colors.black, percent)!;
extension LastItem<T> on List<T> {
T get lastItem => this[length - 1];
}
extension ToLoopMode on AudioServiceRepeatMode {
LoopMode toLoopMode() {
switch (this) {
case AudioServiceRepeatMode.none:
return LoopMode.off;
case AudioServiceRepeatMode.one:
return LoopMode.one;
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.all:
return LoopMode.all;
}
}
}
// extension ToAudioServiceRepeatMode on LoopMode {
// AudioServiceRepeatMode toAudioServiceRepeatMode() {
// switch (this) {
// case LoopMode.off:
// return AudioServiceRepeatMode.none;
// case LoopMode.one:
// return AudioServiceRepeatMode.one;
// case LoopMode.all:
// return AudioServiceRepeatMode.all;
// }
// }
// }
extension PushRoute on NavigatorState {
Future<T?> pushRoute<T extends Object?>(
{required WidgetBuilder builder, RouteSettings? s}) {
final PageRoute<T> route;
switch (settings.navigatorRouteType) {
case NavigatorRouteType.blur_slide:
route = BlurSlidePageRoute<T>(builder: builder, settings: s);
break;
case NavigatorRouteType.material:
route = MaterialPageRoute<T>(builder: builder, settings: s);
break;
case NavigatorRouteType.cupertino:
route = CupertinoPageRoute<T>(builder: builder, settings: s);
break;
case NavigatorRouteType.fade:
route = FadePageRoute<T>(builder: builder, settings: s);
break;
case NavigatorRouteType.fade_blur:
route = FadePageRoute<T>(builder: builder, settings: s, blur: true);
break;
}
return push(route);
}
}
@HiveType(typeId: 30)
enum NavigatorRouteType {
/// Slide from the bottom, with a backdrop filter on the previous screen
@HiveField(0)
blur_slide,
/// Fade
@HiveField(1)
fade,
/// Fade with blur
@HiveField(2)
fade_blur,
/// Standard material route look
@HiveField(3)
material,
/// Standard cupertino route look
@HiveField(4)
cupertino,
}
extension ScaffoldMessengerSnack on ScaffoldMessengerState {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> snack(
String content, {
SnackBarBehavior behavior = SnackBarBehavior.floating,
SnackBarAction? action,
Duration? duration,
}) =>
showSnackBar(SnackBar(
content: Text(content),
behavior: behavior,
duration: duration ?? const Duration(seconds: 4),
action: action));
}
class QualityException implements Exception {
final String? _message;
QualityException([this._message]);
String get message => _message ?? "$runtimeType";
}
// different kinds of flow (scrapped from web)
enum FlowConfig {
motivation,
party,
chill,
melancholy,
youAndMe,
focus,
}
extension GetId on FlowConfig {
String getId() {
return const <FlowConfig, String>{
FlowConfig.motivation: 'motivation',
FlowConfig.party: 'party',
FlowConfig.chill: 'chill',
FlowConfig.melancholy: 'melancholy',
FlowConfig.youAndMe: 'you_and_me',
FlowConfig.focus: 'focus',
}[this]!;
}
}
extension LastChars on String {
String withoutLast(int n) => substring(0, length - n);
}
class TrackUrlSource {
final String provider;
final String url;
TrackUrlSource({required this.provider, required this.url});
factory TrackUrlSource.fromPrivateJson(Map<String, dynamic> json) =>
TrackUrlSource(provider: json['provider'], url: json['url']);
}
class GetTrackUrlResponse {
final List<TrackUrlSource>? sources;
final String? error;
GetTrackUrlResponse({this.sources, this.error});
}