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? 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? 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((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 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((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? 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((j) => Artist.fromJson(j)) .toList(); } } List? playbackDetails; if (mi.extras!['playbackDetails'] != null) { playbackDetails = (jsonDecode(mi.extras!['playbackDetails']) ?? []) .map((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 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((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 toSQL({off = false}) => { 'id': id, 'title': title, 'album': album!.id, 'artists': artists!.map((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 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.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 json) => _$TrackFromJson(json); Map 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? artists; @HiveField(3) List? 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((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 json, {Map 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((dynamic art) => Artist.fromPrivateJson(art)) .toList(), tracks: (songsJson['data'] ?? []) .map((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 toSQL({off = false}) => { 'id': id, 'title': title, 'artists': (artists ?? []).map((dynamic a) => a.id).join(','), 'tracks': (tracks ?? []).map((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 data) => Album( id: data['id'], title: data['title'], artists: List.generate(data['artists'].split(',').length, (i) => Artist(id: data['artists'].split(',')[i])), tracks: List.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 json) => _$AlbumFromJson(json); Map 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? 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 json) => _$ArtistHighlightFromJson(json); Map toJson() => _$ArtistHighlightToJson(this); } @HiveType(typeId: 11) @JsonSerializable() class Artist extends DeezerMediaItem { @override @HiveField(0) String id; @HiveField(1) String? name; @HiveField(2) List? albums; @HiveField(3) int? albumCount; @HiveField(4) List? 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 json, {Map albumsJson = const {}, Map topJson = const {}, Map? 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((dynamic data) => Album.fromPrivateJson(data)) .toList(), topTracks: (topJson['data'] ?? []) .map((dynamic data) => Track.fromPrivateJson(data)) .toList(), library: library, radio: radio, favoriteDate: json['DATE_FAVORITE'], highlight: ArtistHighlight.fromPrivateJson(highlight)); } Map toSQL({off = false}) => { 'id': id, 'name': name, 'albums': albums!.map((dynamic a) => a.id).join(','), 'topTracks': topTracks!.map((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 data) => Artist( id: data['id'], name: data['name'], topTracks: List.generate(data['topTracks'].split(',').length, (i) => Track(id: data['topTracks'].split(',')[i])), albums: List.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 json) => _$ArtistFromJson(json); Map toJson() => _$ArtistToJson(this); } @HiveType(typeId: 9) @JsonSerializable() class Playlist extends DeezerMediaItem { @override @HiveField(0) String id; @HiveField(1) String? title; @HiveField(2) List? 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 json, {Map 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((dynamic data) => Track.fromPrivateJson(data)) .toList(), library: library); Map toSQL() => { 'id': id, 'title': title, 'tracks': tracks!.map((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.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 json) => _$PlaylistFromJson(json); Map 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 json) => _$UserFromJson(json); Map toJson() => _$UserToJson(this); } class JsonImageDetailsConverter extends JsonConverter> { const JsonImageDetailsConverter(); @override Map toJson(ImageDetails object) => object.toJson(); @override ImageDetails fromJson(Map 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 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 json) => _$UrlImageDetailsFromJson(json); @override Map 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 json) => DeezerImageDetails(json['md5'].split('-').first, type: json['type']); factory DeezerImageDetails.fromJson(Map json) => _$DeezerImageDetailsFromJson(json); @override Map toJson() => _$DeezerImageDetailsToJson(this); } class SearchResults { List? tracks; List? albums; List? artists; List? playlists; List? shows; List? 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 json) => SearchResults( tracks: json['TRACK']['data'] .map((dynamic data) => Track.fromPrivateJson(data)) .toList(), albums: json['ALBUM']['data'] .map((dynamic data) => Album.fromPrivateJson(data)) .toList(), artists: json['ARTIST']['data'] .map((dynamic data) => Artist.fromPrivateJson(data)) .toList(), playlists: json['PLAYLIST']['data'] .map((dynamic data) => Playlist.fromPrivateJson(data)) .toList(), shows: json['SHOW']['data'] .map((dynamic data) => Show.fromPrivateJson(data)) .toList(), episodes: json['EPISODE']['data'] .map( (dynamic data) => ShowEpisode.fromPrivateJson(data)) .toList()); } @HiveType(typeId: 7) @JsonSerializable() class Lyrics { @HiveField(0) String? id; @HiveField(1) String? writers; @HiveField(2) List? 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 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((l) => Lyric.fromPrivateJson(l)) .whereNot((l) => l.offset == null) .toList() : [Lyric(text: json['LYRICS_TEXT'])]); } factory Lyrics.fromJson(Map json) => _$LyricsFromJson(json); Map 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 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 json) => _$LyricFromJson(json); Map 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 json) => _$QueueSourceFromJson(json); Map 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? tracks; @HiveField(6) List? 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 json, {Map 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((t) => Track.fromPrivateJson(t)) .toList(), cover: ((json['pictures'] ?? (json.containsKey('COVER') ? [json['COVER']] : [])) as List) .cast() .map(DeezerImageDetails.fromPrivateJson) .toList(growable: false)); } factory SmartTrackList.fromJson(Map json) => _$SmartTrackListFromJson(json); Map toJson() => _$SmartTrackListToJson(this); } @JsonSerializable() @HiveType(typeId: 33) class HomePage { static const Duration cacheDuration = Duration(hours: 12); @HiveField(0) final List 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 exists(String channel) async { if (!await Hive.boxExists(_boxName)) return false; return (await Hive.openLazyBox(_boxName)).containsKey(channel); } Future save(String channel) async { final box = await Hive.openLazyBox(_boxName); await box.delete(channel); await box.put(channel, this); await box.close(); } static Future local(String channel) async { final LazyBox box; try { box = await Hive.openLazyBox(_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 json) { if (json['sections'] == null) return HomePage(sections: []); final sections = (json['sections'] as List) .map( (element) => HomePageSection.fromPrivateJson(element)) .where((element) => element != null) .cast(); return HomePage(sections: sections.toList()); } factory HomePage.fromJson(Map json) => _$HomePageFromJson(json); Map toJson() => _$HomePageToJson(this); Future 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? items; HomePageSection( {required this.layout, this.items, this.title, this.pagePath, this.hasMore}); //JSON static HomePageSection? fromPrivateJson(Map 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 = []; 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 json) => _$HomePageSectionFromJson(json); Map toJson() => _$HomePageSectionToJson(this); static _homePageItemFromJson(json) => json.map((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 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 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 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 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 json) => _$DeezerChannelFromJson(json); Map 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 json) => _$SortingFromJson(json); Map 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 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 json) => _$ShowFromJson(json); Map 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 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 json) => _$ShowEpisodeFromJson(json); Map 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 json) => _$StreamQualityInfoFromJson(json); Map 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 on List { 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 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 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?, 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 on List { 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 pushRoute( {required WidgetBuilder builder, RouteSettings? s}) { final PageRoute route; switch (settings.navigatorRouteType) { case NavigatorRouteType.blur_slide: route = BlurSlidePageRoute(builder: builder, settings: s); break; case NavigatorRouteType.material: route = MaterialPageRoute(builder: builder, settings: s); break; case NavigatorRouteType.cupertino: route = CupertinoPageRoute(builder: builder, settings: s); break; case NavigatorRouteType.fade: route = FadePageRoute(builder: builder, settings: s); break; case NavigatorRouteType.fade_blur: route = FadePageRoute(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 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.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 json) => TrackUrlSource(provider: json['provider'], url: json['url']); } class GetTrackUrlResponse { final List? sources; final String? error; GetTrackUrlResponse({this.sources, this.error}); }