2019-11-14 19:42:02 +00:00
|
|
|
#include <playlist.h>
|
|
|
|
#include "spi_master.h"
|
|
|
|
#include "config.h"
|
|
|
|
#include <SD.h>
|
|
|
|
#include <algorithm>
|
2019-11-16 22:03:13 +00:00
|
|
|
#include <ArduinoJson.h>
|
2019-11-27 05:51:20 +00:00
|
|
|
#include <TinyXML.h>
|
2019-11-14 19:42:02 +00:00
|
|
|
|
2019-11-27 05:51:20 +00:00
|
|
|
Playlist::Playlist(String path) {
|
|
|
|
if (path.startsWith("/")) {
|
|
|
|
_add_path(path);
|
|
|
|
} else if (path.startsWith("http")) {
|
|
|
|
_examine_http_url(path);
|
2019-11-20 05:13:15 +00:00
|
|
|
}
|
2019-11-27 05:51:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::_add_path(String path) {
|
2019-11-14 19:42:02 +00:00
|
|
|
SPIMaster::select_sd();
|
|
|
|
TRACE("Examining folder %s...\n", path.c_str());
|
|
|
|
if (!path.startsWith("/")) path = String("/") + path;
|
|
|
|
if (!SD.exists(path)) {
|
|
|
|
DEBUG("Could not open path '%s'.\n", path.c_str());
|
|
|
|
SPIMaster::select_sd(false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
File dir = SD.open(path);
|
|
|
|
File entry;
|
|
|
|
while (entry = dir.openNextFile()) {
|
|
|
|
String filename = entry.name();
|
|
|
|
filename = filename.substring(path.length() + 1);
|
|
|
|
String ext = filename.substring(filename.length() - 4);
|
|
|
|
if (!entry.isDirectory() &&
|
|
|
|
!filename.startsWith(".") &&
|
|
|
|
( ext.equals(".mp3") ||
|
|
|
|
ext.equals(".ogg") ||
|
|
|
|
ext.equals(".wma") ||
|
|
|
|
ext.equals(".mp4") ||
|
|
|
|
ext.equals(".mpa"))) {
|
|
|
|
TRACE(" Adding entry %s\n", entry.name());
|
2019-11-27 05:51:20 +00:00
|
|
|
String title = filename.substring(0, filename.length() - 4);
|
2019-11-28 05:19:11 +00:00
|
|
|
_files.push_back({.filename=entry.name(), .title=title});
|
2019-11-16 22:03:13 +00:00
|
|
|
bool non_ascii_chars = false;
|
|
|
|
for(int i=0; i<filename.length(); i++) {
|
|
|
|
char c = filename.charAt(i);
|
|
|
|
if (c < 0x20 || c >= 0x7F) {
|
|
|
|
non_ascii_chars = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (non_ascii_chars) {
|
|
|
|
ERROR("WARNING: File '%s' contains non-ascii chars!\n", filename.c_str());
|
|
|
|
}
|
2019-11-14 19:42:02 +00:00
|
|
|
} else {
|
|
|
|
TRACE(" Ignoring entry %s\n", filename.c_str());
|
|
|
|
}
|
|
|
|
entry.close();
|
|
|
|
}
|
|
|
|
dir.close();
|
|
|
|
SPIMaster::select_sd(false);
|
|
|
|
std::sort(_files.begin(), _files.end());
|
|
|
|
}
|
|
|
|
|
2019-11-27 05:51:20 +00:00
|
|
|
void Playlist::_examine_http_url(String url) {
|
|
|
|
HTTPClientWrapper* http = new HTTPClientWrapper();
|
|
|
|
if (!http->get(url)) {
|
|
|
|
DEBUG("Could not GET %s.\n", url.c_str());
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
String ct = http->getContentType();
|
|
|
|
DEBUG("Content-Type is %s.\n", ct.c_str());
|
|
|
|
if (ct.startsWith("audio/x-mpegurl")) {
|
|
|
|
_parse_m3u(http);
|
|
|
|
} else if (ct.startsWith("audio/")) {
|
2019-11-28 05:19:11 +00:00
|
|
|
_files.push_back({.filename=url, .title=url});
|
2019-11-27 05:51:20 +00:00
|
|
|
} else if (ct.startsWith("application/rss+xml")) {
|
|
|
|
_parse_rss(http);
|
|
|
|
} else if (ct.startsWith("application/pls+xml")) {
|
|
|
|
_parse_pls(http);
|
|
|
|
} else {
|
|
|
|
ERROR("Unknown content type %s.\n", ct.c_str());
|
|
|
|
}
|
|
|
|
http->close();
|
|
|
|
delete http;
|
|
|
|
}
|
|
|
|
|
|
|
|
std::vector<PlaylistEntry>* xml_files_ptr = NULL;
|
|
|
|
String xml_last_tag = "";
|
|
|
|
String xml_title = "";
|
|
|
|
String xml_album_title = "";
|
|
|
|
String xml_url = "";
|
|
|
|
String xml_enclosure_url = "";
|
|
|
|
bool xml_enclosure_is_audio = false;
|
|
|
|
|
|
|
|
void xmlcb(uint8_t status, char* tagName, uint16_t tagLen, char* data, uint16_t dataLen) {
|
|
|
|
String tag(tagName);
|
|
|
|
if (status & STATUS_START_TAG) xml_last_tag = tag;
|
|
|
|
|
|
|
|
if (tag.equals("/rss/channel/title") && (status & STATUS_TAG_TEXT)) {
|
|
|
|
xml_album_title = data;
|
|
|
|
} else if (tag.endsWith("/item") && (status & STATUS_START_TAG)) {
|
|
|
|
xml_title = "";
|
|
|
|
xml_url = "";
|
|
|
|
} else if (tag.endsWith("/item/title") && (status & STATUS_TAG_TEXT)) {
|
|
|
|
xml_title = String(data);
|
|
|
|
//} else if (xml_last_tag.endsWith("/item/enclosure") && (status & STATUS_ATTR_TEXT)) {
|
|
|
|
// DEBUG("tag: %s, data: %s\n", tag.c_str(), data);
|
|
|
|
} else if (xml_last_tag.endsWith("/item/enclosure") && tag.equals("type") && (status & STATUS_ATTR_TEXT) && String(data).indexOf("audio/")>=0) {
|
|
|
|
DEBUG("enclosure is audio\n");
|
|
|
|
xml_enclosure_is_audio = true;
|
|
|
|
} else if (xml_last_tag.endsWith("/item/enclosure") && tag.equals("url") && (status & STATUS_ATTR_TEXT)) {
|
|
|
|
DEBUG("found url\n");
|
|
|
|
xml_enclosure_url = String(data);
|
|
|
|
} else if (tag.endsWith("/item/enclosure") && (status & STATUS_END_TAG)) {
|
|
|
|
DEBUG("end of enclosure. xml_enclosure_is_audio: %d, xml_enclosure_url: %s\n", xml_enclosure_is_audio, xml_enclosure_url.c_str());
|
|
|
|
if (xml_enclosure_is_audio && xml_enclosure_url.length()>0) {
|
|
|
|
xml_url = xml_enclosure_url;
|
|
|
|
}
|
|
|
|
xml_enclosure_is_audio = false;
|
|
|
|
xml_enclosure_url = "";
|
|
|
|
} else if (tag.endsWith("/item") && (status & STATUS_END_TAG)) {
|
|
|
|
if (xml_title.length()>0 && xml_url.length()>0) {
|
|
|
|
DEBUG("Adding playlist entry: '%s' => '%s'\n", xml_title.c_str(), xml_url.c_str());
|
|
|
|
xml_files_ptr->push_back({xml_url, xml_title});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::_parse_rss(HTTPClientWrapper* http) {
|
|
|
|
DEBUG("RSS parser running.\n");
|
|
|
|
// http is already initialized
|
|
|
|
int i;
|
|
|
|
|
|
|
|
TinyXML xml;
|
|
|
|
uint8_t* buffer = new uint8_t[150];
|
|
|
|
xml.init(buffer, 150, &xmlcb);
|
|
|
|
xml_files_ptr = &_files;
|
|
|
|
xml_title = "";
|
|
|
|
xml_album_title = "";
|
|
|
|
xml_url = "";
|
|
|
|
xml_enclosure_is_audio = false;
|
|
|
|
xml_enclosure_url = "";
|
|
|
|
while ((i = http->read()) >= 0) {
|
|
|
|
xml.processChar(i);
|
|
|
|
}
|
|
|
|
xml_files_ptr = NULL;
|
|
|
|
if (xml_album_title.length()>0) {
|
|
|
|
title = xml_album_title;
|
|
|
|
}
|
|
|
|
xml_album_title = "";
|
|
|
|
delete buffer;
|
|
|
|
// don't close http at the end
|
|
|
|
DEBUG("RSS parser finished.\n");
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::_parse_m3u(HTTPClientWrapper* http) {
|
|
|
|
// http is already initialized
|
|
|
|
String line = "";
|
|
|
|
String title = "";
|
|
|
|
int i;
|
|
|
|
do {
|
|
|
|
i = http->read();
|
|
|
|
char c = i;
|
|
|
|
if (i>=-1 && c!='\r' && c!='\n') {
|
|
|
|
line += c;
|
|
|
|
} else {
|
|
|
|
if (line.equals("#EXTM3U")) {
|
|
|
|
// Do nothing
|
|
|
|
} else if (line.startsWith("#EXTINF")) {
|
|
|
|
int idx = line.indexOf(",");
|
|
|
|
if (idx>4) {
|
|
|
|
// Get the title
|
|
|
|
title = line.substring(idx+1);
|
|
|
|
}
|
|
|
|
} else if (line.startsWith("http")) {
|
|
|
|
if (title.length()==0) title = line;
|
2019-11-28 05:19:11 +00:00
|
|
|
_files.push_back({.filename=line, .title=title});
|
2019-11-27 05:51:20 +00:00
|
|
|
title = "";
|
|
|
|
}
|
|
|
|
line = "";
|
|
|
|
}
|
|
|
|
} while (i>=0);
|
|
|
|
// don't close http at the end
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::_parse_pls(HTTPClientWrapper* http) {
|
|
|
|
// http is already initialized
|
|
|
|
String line;
|
|
|
|
String title = "";
|
|
|
|
String url = "";
|
|
|
|
int last_index = -1;
|
|
|
|
int index;
|
|
|
|
|
|
|
|
while(true) {
|
|
|
|
line = http->readLine();
|
|
|
|
if (line.startsWith("Title")) {
|
|
|
|
uint8_t eq_idx = line.indexOf('=');
|
|
|
|
if (eq_idx==-1) continue;
|
|
|
|
|
|
|
|
index = line.substring(5, eq_idx-4).toInt();
|
|
|
|
title = line.substring(eq_idx+1);
|
|
|
|
if (index != last_index) {
|
|
|
|
url = "";
|
|
|
|
last_index = index;
|
|
|
|
}
|
|
|
|
} else if (line.startsWith("File")) {
|
|
|
|
uint8_t eq_idx = line.indexOf('=');
|
|
|
|
if (eq_idx==-1) continue;
|
|
|
|
|
|
|
|
index = line.substring(5, eq_idx-4).toInt();
|
|
|
|
url = line.substring(eq_idx+1);
|
|
|
|
if (index != last_index) {
|
|
|
|
title = "";
|
|
|
|
last_index = index;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (title.length()>0 && url.length()>0) {
|
2019-11-28 05:19:11 +00:00
|
|
|
_files.push_back({.filename=url, .title=title});
|
2019-11-27 05:51:20 +00:00
|
|
|
last_index = -1;
|
|
|
|
title = "";
|
|
|
|
url = "";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// don't close http at the end
|
|
|
|
}
|
|
|
|
|
|
|
|
uint16_t Playlist::get_file_count() {
|
|
|
|
return _files.size();
|
|
|
|
}
|
|
|
|
|
2019-11-16 22:03:13 +00:00
|
|
|
void Playlist::start() {
|
|
|
|
_started = true;
|
|
|
|
}
|
|
|
|
|
2019-11-14 19:42:02 +00:00
|
|
|
bool Playlist::has_track_prev() {
|
|
|
|
return _current_track > 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool Playlist::has_track_next() {
|
|
|
|
return _current_track < _files.size()-1;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool Playlist::track_prev() {
|
|
|
|
if (_current_track > 0) {
|
|
|
|
_current_track--;
|
|
|
|
_position = 0;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool Playlist::track_next() {
|
|
|
|
if (_current_track < _files.size()-1) {
|
|
|
|
_current_track++;
|
|
|
|
_position = 0;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2019-11-16 22:03:13 +00:00
|
|
|
bool Playlist::set_track(uint8_t track) {
|
|
|
|
if (track < _files.size()) {
|
|
|
|
_current_track = track;
|
2019-11-17 13:25:47 +00:00
|
|
|
_position = 0;
|
2019-11-16 22:03:13 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2019-11-14 19:42:02 +00:00
|
|
|
void Playlist::track_restart() {
|
|
|
|
_position = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::shuffle(uint8_t random_offset) {
|
|
|
|
DEBUG("Shuffling the playlist with an offset of %d...\n", random_offset);
|
|
|
|
for (int i=random_offset; i<_files.size(); i++) {
|
|
|
|
int j = random(random_offset, _files.size()-1);
|
|
|
|
if (i!=j) {
|
|
|
|
TRACE(" Swapping elements %d and %d.\n", i, j);
|
2019-11-27 05:51:20 +00:00
|
|
|
PlaylistEntry temp = _files[i];
|
2019-11-14 19:42:02 +00:00
|
|
|
_files[i] = _files[j];
|
|
|
|
_files[j] = temp;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
_shuffled = true;
|
|
|
|
TRACE("Done.\n");
|
|
|
|
}
|
|
|
|
|
2019-11-16 22:03:13 +00:00
|
|
|
void Playlist::advent_shuffle(uint8_t day) {
|
|
|
|
if (day > 24) day = 24;
|
|
|
|
|
|
|
|
if (day > _files.size()) return;
|
|
|
|
|
|
|
|
_files.insert(_files.begin(), _files[day - 1]);
|
|
|
|
_files.erase(_files.begin() + day - 1, _files.end());
|
|
|
|
}
|
|
|
|
|
2019-11-14 19:42:02 +00:00
|
|
|
void Playlist::reset() {
|
|
|
|
std::sort(_files.begin(), _files.end());
|
|
|
|
_current_track = 0;
|
|
|
|
_position = 0;
|
|
|
|
_shuffled = false;
|
2019-11-16 22:03:13 +00:00
|
|
|
_started = false;
|
2019-11-14 19:42:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
String Playlist::get_current_file() {
|
2019-11-27 05:51:20 +00:00
|
|
|
return _files[_current_track].filename;
|
2019-11-14 19:42:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
uint32_t Playlist::get_position() {
|
|
|
|
return _position;
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::set_position(uint32_t p) {
|
|
|
|
_position = p;
|
|
|
|
}
|
|
|
|
|
|
|
|
bool Playlist::is_fresh() {
|
2019-11-16 22:03:13 +00:00
|
|
|
return !_shuffled && !_started && _position==0 && _current_track==0;
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::dump() {
|
|
|
|
for (int i=0; i<_files.size(); i++) {
|
2019-11-27 05:51:20 +00:00
|
|
|
DEBUG(" %02d %2s %s\n", i+1, (i==_current_track) ? "->" : "", _files[i].filename.c_str());
|
2019-11-16 22:03:13 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void Playlist::json(JsonObject json) {
|
|
|
|
json["_type"] = "playlist";
|
|
|
|
JsonArray files = json.createNestedArray("files");
|
2019-11-27 05:51:20 +00:00
|
|
|
for (PlaylistEntry entry: _files) {
|
|
|
|
JsonObject o = files.createNestedObject();
|
|
|
|
o["filename"] = entry.filename;
|
|
|
|
o["title"] = entry.title;
|
2019-11-16 22:03:13 +00:00
|
|
|
}
|
|
|
|
json["current_track"] = _current_track;
|
|
|
|
json["has_track_next"] = has_track_next();
|
|
|
|
json["has_track_prev"] = has_track_prev();
|
|
|
|
}
|