freezer/lib/api/definitions.dart
Pato05 6f1fb73ed8
better connectivity checks + error handling for linux
desktop UI
add setting for navigation rail
changes to DeezerAudioSource: get quality when needed and cache url to avoid re generating and resending too many HEAD requests
2023-10-17 00:22:50 +02:00

1543 lines
45 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 'package:isar/isar.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;
@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,
});
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,
"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']);
}
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'],
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']}');
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]!;
}
}