Pato05
2862c9ec05
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
1574 lines
46 KiB
Dart
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});
|
|
}
|