freezer/lib/api/definitions.dart
Pato05 87c9733f51
add build script for linux
fix audio service stop on android
getTrack backend improvements
get new track token when expired
move shuffle button into LibraryPlaylists as FAB
move favoriteButton next to track title
move lyrics button on top of album art
search: fix chips, and remove checkbox when selected
2024-02-19 00:49:32 +01:00

1632 lines
48 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;
static DeezerMediaItem? fromDeezerObject(Map data) {
switch (data['__TYPE__']!) {
case 'song':
return Track.fromPrivateJson(data);
case 'artist':
return Artist.fromPrivateJson(data);
case 'playlist':
return Playlist.fromPrivateJson(data);
case 'album':
return Album.fromPrivateJson(data);
default:
print('UNKNOWN MEDIA ITEM TYPE ${data['__TYPE__']}');
return null;
}
}
}
@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
MediaItem toMediaItem() {
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: mi.extras?['lyrics'] == null
? null
: 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(264, 264);
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<DeezerMediaItem>? topResult;
List<Track>? tracks;
List<Album>? albums;
List<Artist>? artists;
List<Playlist>? playlists;
List<Show>? shows;
List<ShowEpisode>? episodes;
SearchResults(
{this.topResult,
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(
topResult: (json['TOP_RESULT'] as List?)
?.map<DeezerMediaItem?>(
(e) => DeezerMediaItem.fromDeezerObject(e as Map))
.whereType<DeezerMediaItem>()
.toList(growable: false),
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;
String? translated;
Lyric({this.offset, this.text, this.lrcTimestamp, this.translated});
//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'],
translated: json['lineTranslated'],
lrcTimestamp: json['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 = 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']}');
return null;
}
_logger.fine('LAYOUT: $layout');
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, false));
case 'album':
return HomePageItem(
type: HomePageItemType.ALBUM,
value: Album.fromPrivateJson(json['data']));
case 'show':
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;
}
}
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']));
case 'EXTERNAL_LINK':
return HomePageItem(
type: HomePageItemType.EXTERNAL_LINK,
value: DeezerChannel.fromJson(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;
@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.isExternalLink = false});
factory DeezerChannel.fromPrivateJson(
Map<dynamic, dynamic> json, bool isExternalLink) =>
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,
isExternalLink: isExternalLink);
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,
@HiveField(6)
EXTERNAL_LINK,
}
@HiveType(typeId: 3)
enum HomePageSectionLayout {
@HiveField(0)
row,
@HiveField(1)
grid,
/// ROW but bigger
@HiveField(2)
slideshow,
}
enum RepeatType { NONE, LIST, TRACK }
enum DeezerMediaType {
track,
album,
artist,
playlist,
show,
episode,
}
class DeezerLinkResponse {
DeezerMediaType? type;
String? id;
DeezerLinkResponse({this.type, this.id});
//String to DeezerLinkType
static typeFromString(String t) {
return const <String, DeezerMediaType>{
'album': DeezerMediaType.album,
'artist': DeezerMediaType.artist,
'playlist': DeezerMediaType.playlist,
'track': DeezerMediaType.track,
'show': DeezerMediaType.show,
'episode': DeezerMediaType.episode,
}[t.toLowerCase().trim()];
}
}
//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});
}