0.6.5 - Local streaming http server

This commit is contained in:
exttex 2020-11-28 22:32:17 +01:00
parent 28c2de55fb
commit 21e7f55017
31 changed files with 1744 additions and 460 deletions

View file

@ -29,19 +29,9 @@ class Cache {
@JsonKey(defaultValue: [])
List<Track> history = [];
//Cache playlist sort type {id: sort}
@JsonKey(defaultValue: {})
Map<String, SortType> playlistSort;
//Sort
@JsonKey(defaultValue: AlbumSortType.DEFAULT)
AlbumSortType albumSort;
@JsonKey(defaultValue: ArtistSortType.DEFAULT)
ArtistSortType artistSort;
@JsonKey(defaultValue: PlaylistSortType.DEFAULT)
PlaylistSortType libraryPlaylistSort;
@JsonKey(defaultValue: SortType.DEFAULT)
SortType trackSort;
//All sorting cached
@JsonKey(defaultValue: [])
List<Sorting> sorts = [];
//Sleep timer
@JsonKey(ignore: true)

View file

@ -16,21 +16,11 @@ Cache _$CacheFromJson(Map<String, dynamic> json) {
e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList() ??
[]
..playlistSort = (json['playlistSort'] as Map<String, dynamic>)?.map(
(k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)),
) ??
{}
..albumSort =
_$enumDecodeNullable(_$AlbumSortTypeEnumMap, json['albumSort']) ??
AlbumSortType.DEFAULT
..artistSort =
_$enumDecodeNullable(_$ArtistSortTypeEnumMap, json['artistSort']) ??
ArtistSortType.DEFAULT
..libraryPlaylistSort = _$enumDecodeNullable(
_$PlaylistSortTypeEnumMap, json['libraryPlaylistSort']) ??
PlaylistSortType.DEFAULT
..trackSort = _$enumDecodeNullable(_$SortTypeEnumMap, json['trackSort']) ??
SortType.DEFAULT
..sorts = (json['sorts'] as List)
?.map((e) =>
e == null ? null : Sorting.fromJson(e as Map<String, dynamic>))
?.toList() ??
[]
..searchHistory =
Cache._searchHistoryFromJson(json['searchHistory2'] as List)
..threadsWarning = json['threadsWarning'] as bool ?? false
@ -40,18 +30,25 @@ Cache _$CacheFromJson(Map<String, dynamic> json) {
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
'libraryTracks': instance.libraryTracks,
'history': instance.history,
'playlistSort': instance.playlistSort
?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])),
'albumSort': _$AlbumSortTypeEnumMap[instance.albumSort],
'artistSort': _$ArtistSortTypeEnumMap[instance.artistSort],
'libraryPlaylistSort':
_$PlaylistSortTypeEnumMap[instance.libraryPlaylistSort],
'trackSort': _$SortTypeEnumMap[instance.trackSort],
'sorts': instance.sorts,
'searchHistory2': Cache._searchHistoryToJson(instance.searchHistory),
'threadsWarning': instance.threadsWarning,
'lastUpdateCheck': instance.lastUpdateCheck,
};
SearchHistoryItem _$SearchHistoryItemFromJson(Map<String, dynamic> json) {
return SearchHistoryItem(
json['data'],
_$enumDecodeNullable(_$SearchHistoryItemTypeEnumMap, json['type']),
);
}
Map<String, dynamic> _$SearchHistoryItemToJson(SearchHistoryItem instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$SearchHistoryItemTypeEnumMap[instance.type],
};
T _$enumDecode<T>(
Map<T, dynamic> enumValues,
dynamic source, {
@ -84,49 +81,6 @@ T _$enumDecodeNullable<T>(
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
}
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.REVERSE: 'REVERSE',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
};
const _$AlbumSortTypeEnumMap = {
AlbumSortType.DEFAULT: 'DEFAULT',
AlbumSortType.REVERSE: 'REVERSE',
AlbumSortType.ALPHABETIC: 'ALPHABETIC',
AlbumSortType.ARTIST: 'ARTIST',
AlbumSortType.DATE: 'DATE',
};
const _$ArtistSortTypeEnumMap = {
ArtistSortType.DEFAULT: 'DEFAULT',
ArtistSortType.REVERSE: 'REVERSE',
ArtistSortType.POPULARITY: 'POPULARITY',
ArtistSortType.ALPHABETIC: 'ALPHABETIC',
};
const _$PlaylistSortTypeEnumMap = {
PlaylistSortType.DEFAULT: 'DEFAULT',
PlaylistSortType.REVERSE: 'REVERSE',
PlaylistSortType.ALPHABETIC: 'ALPHABETIC',
PlaylistSortType.USER: 'USER',
PlaylistSortType.TRACK_COUNT: 'TRACK_COUNT',
};
SearchHistoryItem _$SearchHistoryItemFromJson(Map<String, dynamic> json) {
return SearchHistoryItem(
json['data'],
_$enumDecodeNullable(_$SearchHistoryItemTypeEnumMap, json['type']),
);
}
Map<String, dynamic> _$SearchHistoryItemToJson(SearchHistoryItem instance) =>
<String, dynamic>{
'data': instance.data,
'type': _$SearchHistoryItemTypeEnumMap[instance.type],
};
const _$SearchHistoryItemTypeEnumMap = {
SearchHistoryItemType.TRACK: 'TRACK',
SearchHistoryItemType.ALBUM: 'ALBUM',

View file

@ -459,12 +459,24 @@ class DeezerAPI {
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
}
// Get similar tracks for track with id [trackId]
//Get similar tracks for track with id [trackId]
Future<List<Track>> playMix(String trackId) async {
Map data = await callApi('song.getContextualTrackMix', params: {
'sng_ids': [trackId]
});
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
}
Future<List<ShowEpisode>> allShowEpisodes(String showId) async {
Map data = await callApi('deezer.pageShow', params: {
'country': settings.deezerCountry,
'lang': settings.deezerLanguage,
'nb': 1000,
'show_id': showId,
'start': 0,
'user_id': int.parse(deezerAPI.userId)
});
return data['results']['EPISODES']['data'].map<ShowEpisode>((e) => ShowEpisode.fromPrivateJson(e)).toList();
}
}

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:freezer/api/cache.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
@ -33,12 +34,13 @@ class Track {
bool favorite;
int diskNumber;
bool explicit;
int favoriteDate;
//Date added to playlist / favorites
int addedDate;
List<dynamic> playbackDetails;
Track({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.favoriteDate});
this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite, this.diskNumber, this.explicit, this.addedDate});
String get artistString => artists.map<String>((art) => art.name).join(', ');
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
@ -141,7 +143,7 @@ class Track {
favorite: favorite,
diskNumber: int.parse(json['DISK_NUMBER']??'1'),
explicit: (json['EXPLICIT_LYRICS'].toString() == '1') ? true:false,
favoriteDate: json['DATE_ADD']
addedDate: json['DATE_ADD']
);
}
Map<String, dynamic> toSQL({off = false}) => {
@ -157,7 +159,7 @@ class Track {
'favorite': (favorite??0)?1:0,
'diskNumber': diskNumber,
'explicit': explicit?1:0,
// 'favoriteDate': favoriteDate
//'favoriteDate': favoriteDate
};
factory Track.fromSQL(Map<String, dynamic> data) => Track(
id: data['trackId']??data['id'], //If loading from downloads table
@ -174,7 +176,7 @@ class Track {
favorite: (data['favorite'] == 1) ? true:false,
diskNumber: data['diskNumber'],
explicit: (data['explicit'] == 1) ? true:false,
// favoriteDate: data['favoriteDate']
//favoriteDate: data['favoriteDate']
);
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
@ -238,7 +240,7 @@ class Album {
'library': (library??false)?1:0,
'type': AlbumType.values.indexOf(type),
'releaseDate': releaseDate,
// 'favoriteDate': favoriteDate
//'favoriteDate': favoriteDate
};
factory Album.fromSQL(Map<String, dynamic> data) => Album(
id: data['id'],
@ -255,7 +257,7 @@ class Album {
library: (data['library'] == 1) ? true:false,
type: AlbumType.values[data['type']],
releaseDate: data['releaseDate'],
// favoriteDate: data['favoriteDate']
//favoriteDate: data['favoriteDate']
);
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
@ -344,7 +346,7 @@ class Artist {
'offline': off?1:0,
'library': (library??false)?1:0,
'radio': radio?1:0,
// 'favoriteDate': favoriteDate
//'favoriteDate': favoriteDate
};
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
id: data['id'],
@ -361,7 +363,7 @@ class Artist {
offline: (data['offline'] == 1)?true:false,
library: (data['library'] == 1)?true:false,
radio: (data['radio'] == 1)?true:false,
// favoriteDate: data['favoriteDate']
//favoriteDate: data['favoriteDate']
);
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
@ -702,6 +704,8 @@ class HomePageItem {
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;
}
@ -720,6 +724,8 @@ class HomePageItem {
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:
return HomePageItem();
}
@ -762,7 +768,8 @@ enum HomePageItemType {
PLAYLIST,
ARTIST,
CHANNEL,
ALBUM
ALBUM,
SHOW
}
enum HomePageSectionLayout {
@ -797,4 +804,164 @@ class DeezerLinkResponse {
if (t == 'track') return DeezerLinkType.TRACK;
return null;
}
}
//Sorting
enum SortType {
DEFAULT,
ALPHABETIC,
ARTIST,
ALBUM,
RELEASE_DATE,
POPULARITY,
USER,
TRACK_COUNT,
DATE_ADDED
}
enum SortSourceTypes {
//Library
TRACKS,
PLAYLISTS,
ALBUMS,
ARTISTS,
PLAYLIST
}
@JsonSerializable()
class Sorting {
SortType type;
bool reverse;
//For preserving sorting
String id;
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}) {
//Empty cache
if (cache.sorts == null) {
cache.sorts = [];
cache.save();
return null;
}
//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);
}
@JsonSerializable()
class Show {
String name;
String description;
ImageDetails art;
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: ImageDetails.fromPrivateString(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;
ShowEpisode({this.id, this.title, this.description, this.url, this.duration, this.publishedDate});
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': jsonEncode(show.toJson()),
'thumb': show.art.thumb
},
displayDescription: description,
duration: duration,
artUri: 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,
);
}
//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']
);
factory ShowEpisode.fromJson(Map<String, dynamic> json) => _$ShowEpisodeFromJson(json);
Map<String, dynamic> toJson() => _$ShowEpisodeToJson(this);
}
class StreamQualityInfo {
String format;
int size;
String source;
StreamQualityInfo({this.format, this.size, this.source});
factory StreamQualityInfo.fromJson(Map json) => StreamQualityInfo(
format: json['format'],
size: json['size'],
source: json['source']
);
int bitrate(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;
}
}

View file

@ -32,7 +32,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) {
favorite: json['favorite'] as bool,
diskNumber: json['diskNumber'] as int,
explicit: json['explicit'] as bool,
favoriteDate: json['favoriteDate'] as int,
addedDate: json['addedDate'] as int,
);
}
@ -49,7 +49,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'favorite': instance.favorite,
'diskNumber': instance.diskNumber,
'explicit': instance.explicit,
'favoriteDate': instance.favoriteDate,
'addedDate': instance.addedDate,
'playbackDetails': instance.playbackDetails,
};
@ -387,3 +387,81 @@ Map<String, dynamic> _$DeezerChannelToJson(DeezerChannel instance) =>
'title': instance.title,
'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor),
};
Sorting _$SortingFromJson(Map<String, dynamic> json) {
return Sorting(
type: _$enumDecodeNullable(_$SortTypeEnumMap, json['type']),
reverse: json['reverse'] as bool,
id: json['id'] as String,
sourceType:
_$enumDecodeNullable(_$SortSourceTypesEnumMap, json['sourceType']),
);
}
Map<String, dynamic> _$SortingToJson(Sorting instance) => <String, dynamic>{
'type': _$SortTypeEnumMap[instance.type],
'reverse': instance.reverse,
'id': instance.id,
'sourceType': _$SortSourceTypesEnumMap[instance.sourceType],
};
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
SortType.ALBUM: 'ALBUM',
SortType.RELEASE_DATE: 'RELEASE_DATE',
SortType.POPULARITY: 'POPULARITY',
SortType.USER: 'USER',
SortType.TRACK_COUNT: 'TRACK_COUNT',
SortType.DATE_ADDED: 'DATE_ADDED',
};
const _$SortSourceTypesEnumMap = {
SortSourceTypes.TRACKS: 'TRACKS',
SortSourceTypes.PLAYLISTS: 'PLAYLISTS',
SortSourceTypes.ALBUMS: 'ALBUMS',
SortSourceTypes.ARTISTS: 'ARTISTS',
SortSourceTypes.PLAYLIST: 'PLAYLIST',
};
Show _$ShowFromJson(Map<String, dynamic> json) {
return Show(
name: json['name'] as String,
description: json['description'] as String,
art: json['art'] == null
? null
: ImageDetails.fromJson(json['art'] as Map<String, dynamic>),
id: json['id'] as String,
);
}
Map<String, dynamic> _$ShowToJson(Show instance) => <String, dynamic>{
'name': instance.name,
'description': instance.description,
'art': instance.art,
'id': instance.id,
};
ShowEpisode _$ShowEpisodeFromJson(Map<String, dynamic> json) {
return ShowEpisode(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
url: json['url'] as String,
duration: json['duration'] == null
? null
: Duration(microseconds: json['duration'] as int),
publishedDate: json['publishedDate'] as String,
);
}
Map<String, dynamic> _$ShowEpisodeToJson(ShowEpisode instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'description': instance.description,
'url': instance.url,
'duration': instance.duration?.inMicroseconds,
'publishedDate': instance.publishedDate,
};

View file

@ -165,9 +165,10 @@ class PlayerHelper {
Future _loadQueuePlay(List<MediaItem> queue, String trackId) async {
await startService();
await settings.updateAudioServiceQuality();
await AudioService.customAction('setIndex', queue.indexWhere((m) => m.id == trackId));
await AudioService.updateQueue(queue);
if (queue[0].id != trackId)
await AudioService.skipToQueueItem(trackId);
// if (queue[0].id != trackId)
// await AudioService.skipToQueueItem(trackId);
if (!AudioService.playbackState.playing)
AudioService.play();
}
@ -236,6 +237,27 @@ class PlayerHelper {
source: 'playlist'
));
}
//Play episode from show, load whole show as queue
Future playShowEpisode(Show show, List<ShowEpisode> episodes, {int index = 0}) async {
QueueSource queueSource = QueueSource(
id: show.id,
text: show.name,
source: 'show'
);
//Generate media items
List<MediaItem> queue = episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
//Load and play
await startService();
await settings.updateAudioServiceQuality();
await setQueueSource(queueSource);
await AudioService.customAction('setIndex', index);
await AudioService.updateQueue(queue);
if (!AudioService.playbackState.playing)
AudioService.play();
}
//Load tracks as queue, play track id, set queue source
Future playFromTrackList(List<Track> tracks, String trackId, QueueSource queueSource) async {
await startService();
@ -340,7 +362,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
//Quality string
if (_queueIndex != -1 && _queueIndex < _queue.length) {
Map extras = mediaItem.extras;
extras['qualityString'] = event.qualityString??'';
extras['qualityString'] = '';
_queue[_queueIndex] = mediaItem.copyWith(extras: extras);
}
//Update
@ -530,7 +552,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
this._queue = q;
AudioServiceBackground.setQueue(_queue);
//Load
_queueIndex = 0;
await _loadQueue();
//await _player.seek(Duration.zero, index: 0);
}
@ -550,8 +571,8 @@ class AudioPlayerTask extends BackgroundAudioTask {
_audioSource = ConcatenatingAudioSource(children: sources);
//Load in just_audio
try {
await _player.load(_audioSource);
await _player.seek(Duration.zero, index: qi);
await _player.load(_audioSource, initialPosition: Duration.zero, initialIndex: qi);
// await _player.seek(Duration.zero, index: qi);
} catch (e) {
//Error loading tracks
}
@ -571,9 +592,15 @@ class AudioPlayerTask extends BackgroundAudioTask {
String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
File f = File(p.join(_offlinePath, mediaItem.id));
if (await f.exists()) {
return f.path;
//return f.path;
//Stream server URL
return 'http://localhost:36958/?id=${mediaItem.id}';
}
//Show episode direct link
if (mediaItem.extras['showUrl'] != null)
return mediaItem.extras['showUrl'];
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
//This just returns fake url that contains metadata
List playbackDetails = jsonDecode(mediaItem.extras['playbackDetails']);
@ -583,7 +610,8 @@ class AudioPlayerTask extends BackgroundAudioTask {
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
if ((playbackDetails??[]).length < 2) return null;
String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
//String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
String url = 'http://localhost:36958/?q=$quality&mv=${playbackDetails[1]}&md5origin=${playbackDetails[0]}&id=${mediaItem.id}';
return url;
}
@ -632,6 +660,10 @@ class AudioPlayerTask extends BackgroundAudioTask {
AudioServiceBackground.setQueue(_queue);
_broadcastState();
}
//Set index without affecting playback for loading
if (name == 'setIndex') {
this._queueIndex = args;
}
return true;
}