Changed the playing code to use Playlists managed by a PlaylistManager. This allows you to have randomized playlists and stuff. Also, you can now access special functions via the contents of RFID tags. See the README for a list of available modes.
This commit is contained in:
parent
6e05900b5a
commit
e471a57578
26
README.md
26
README.md
@ -22,3 +22,29 @@
|
||||
| BTN_VOL_DOWN | | 32 |
|
||||
|
||||
Buttons pull to GND if pushed -> Internal Pull-Up needed!
|
||||
|
||||
# RFID stuff
|
||||
The mapping of rfid tags to files uses the ID of the
|
||||
tag. Create a file `ids.txt` in a folder containing
|
||||
one or more IDs will lead to the folder beginning to play
|
||||
when a tag with that id is on the reader.
|
||||
|
||||
The ID should be a 8 character long, downcase string
|
||||
containing the ID in hexadecimal. E.g. `23b1aa7d`.
|
||||
|
||||
## Special modes
|
||||
You can also save data on the tags to further manipulate
|
||||
the system. Position of the data is irrelevant, the whole
|
||||
tag will be searched.
|
||||
|
||||
Using `[random]` will play the files in a random order.
|
||||
`[random:2]` will randomize everything except the first 2
|
||||
files. This can be useful for having the favorite song of
|
||||
your kids playing, but after that getting a bit of randomness.
|
||||
|
||||
Using `[lock]` will turn this key into a key for the locking
|
||||
mode. Scanning the tag enables locking mode. The next album
|
||||
started will keep running until the end. Removing the tag
|
||||
will be deactivated, as are the buttons for prev and next
|
||||
track. You can disable locking mode by again scanning the
|
||||
lock tag again.
|
||||
|
@ -3,14 +3,19 @@
|
||||
#include <Arduino.h>
|
||||
#include "config.h"
|
||||
#include "player.h"
|
||||
#include "playlist.h"
|
||||
#include "playlist_manager.h"
|
||||
#include "mqtt_client.h"
|
||||
#include <MFRC522.h>
|
||||
|
||||
enum ControllerState { NORMAL, LOCKING, LOCKED };
|
||||
|
||||
class Controller {
|
||||
private:
|
||||
MFRC522* _rfid;
|
||||
SPIMaster* _spi;
|
||||
MQTTClient* _mqtt_client;
|
||||
PlaylistManager* _pm;
|
||||
ControllerState _state = NORMAL;
|
||||
bool _rfid_enabled = true;
|
||||
void _check_rfid();
|
||||
void _check_serial();
|
||||
@ -33,7 +38,7 @@ private:
|
||||
unsigned long _last_mqtt_report_at = 0;
|
||||
void _send_mqtt_report();
|
||||
public:
|
||||
Controller(Player* p, SPIMaster* s);
|
||||
Controller(Player* p, PlaylistManager* pm);
|
||||
void set_mqtt_client(MQTTClient* m);
|
||||
String get_status_json();
|
||||
void loop();
|
||||
|
@ -2,9 +2,8 @@
|
||||
#include "config.h"
|
||||
#include <SPI.h>
|
||||
#include <SD.h>
|
||||
#include <list>
|
||||
#include <map>
|
||||
#include "spi_master.h"
|
||||
#include "playlist.h"
|
||||
|
||||
#define SCI_MODE 0x00
|
||||
#define SCI_STATUS 0x01
|
||||
@ -38,13 +37,7 @@
|
||||
class Player {
|
||||
private:
|
||||
enum state { uninitialized, idle, playing, stopping,
|
||||
system_sound_while_playing, system_sound_while_stopped,
|
||||
sleeping, recording };
|
||||
struct album_state {
|
||||
uint8_t index;
|
||||
uint32_t position;
|
||||
};
|
||||
void _check_system_sound(String filename);
|
||||
void _reset();
|
||||
void _init();
|
||||
void _wait();
|
||||
@ -59,11 +52,8 @@ private:
|
||||
void _flush_and_cancel();
|
||||
int8_t _get_endbyte();
|
||||
void _flush(uint count, int8_t fill_byte);
|
||||
void _set_last_track(const char* album, uint8_t track, uint32_t position);
|
||||
std::map<String, album_state> _last_tracks;
|
||||
String _random_album();
|
||||
void _play_file(String filename, uint32_t offset);
|
||||
uint32_t _id3_tag_offset(File f);
|
||||
void _play_file(String filename, uint32_t offset);
|
||||
void _finish_playing();
|
||||
void _finish_stopping(bool turn_speaker_off);
|
||||
void _mute();
|
||||
@ -74,21 +64,15 @@ private:
|
||||
void _patch_adpcm();
|
||||
void _speaker_off();
|
||||
void _speaker_on();
|
||||
void _fill_id_to_folder_map();
|
||||
String _foldername_for_id(String id);
|
||||
|
||||
SPISettings _spi_settings_slow = SPISettings(250000, MSBFIRST, SPI_MODE0);
|
||||
SPISettings _spi_settings_fast = SPISettings(4000000, MSBFIRST, SPI_MODE0);
|
||||
SPISettings* _spi_settings = &_spi_settings_slow;
|
||||
|
||||
std::list<String> _files_in_dir(String dir);
|
||||
String _find_album_dir(String album);
|
||||
File _file;
|
||||
uint8_t _buffer[32];
|
||||
String _playing_album;
|
||||
uint8_t _playing_index;
|
||||
uint8_t _playing_album_songs;
|
||||
uint32_t _current_play_position;
|
||||
Playlist* _current_playlist;
|
||||
uint _refills;
|
||||
uint8_t _volume;
|
||||
uint16_t _stop_delay;
|
||||
@ -96,26 +80,17 @@ private:
|
||||
SPIMaster* _spi;
|
||||
unsigned long _stopped_at;
|
||||
public:
|
||||
std::map<String, String> id_to_folder_map;
|
||||
|
||||
Player(SPIMaster* s);
|
||||
void vol_up();
|
||||
void vol_down();
|
||||
void track_next();
|
||||
void track_prev();
|
||||
bool is_playing();
|
||||
|
||||
bool play_id(String id);
|
||||
bool play_album(String album);
|
||||
void play_random_album();
|
||||
bool play_song(String album, uint8_t song_index, uint32_t offset=0);
|
||||
void play_system_sound(String filename);
|
||||
bool play();
|
||||
bool play(Playlist* p);
|
||||
void stop(bool turn_speaker_off=true);
|
||||
bool loop();
|
||||
void set_volume(uint8_t vol, bool save = true);
|
||||
std::list<String> ls(String path, bool withFiles=true, bool withDirs=true, bool withHidden=false);
|
||||
String album() { return _playing_album; }
|
||||
uint8_t track() { return _playing_index; }
|
||||
uint32_t position() { return _current_play_position; }
|
||||
uint8_t volume() { return _volume; }
|
||||
};
|
||||
|
25
include/playlist.h
Normal file
25
include/playlist.h
Normal file
@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
class Playlist {
|
||||
private:
|
||||
uint32_t _position = 0;
|
||||
uint32_t _current_track = 0;
|
||||
bool _shuffled = false;
|
||||
std::vector<String> _files;
|
||||
public:
|
||||
Playlist(String path);
|
||||
bool has_track_next();
|
||||
bool has_track_prev();
|
||||
bool track_next();
|
||||
bool track_prev();
|
||||
void track_restart();
|
||||
void reset();
|
||||
bool is_empty();
|
||||
String get_current_file();
|
||||
uint32_t get_position();
|
||||
void set_position(uint32_t p);
|
||||
void shuffle(uint8_t random_offset=0);
|
||||
bool is_fresh();
|
||||
};
|
14
include/playlist_manager.h
Normal file
14
include/playlist_manager.h
Normal file
@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include "playlist.h"
|
||||
|
||||
class PlaylistManager {
|
||||
private:
|
||||
std::map<String, String> _map;
|
||||
std::map<String, Playlist*> _playlists;
|
||||
public:
|
||||
PlaylistManager();
|
||||
Playlist* get_playlist_for_id(String id);
|
||||
void dump_ids();
|
||||
};
|
@ -6,7 +6,7 @@
|
||||
|
||||
class SPIMaster {
|
||||
public:
|
||||
SPIMaster() {
|
||||
static void init() {
|
||||
PIN_SD_CS_SETUP();
|
||||
PIN_VS1053_XCS_SETUP();
|
||||
PIN_VS1053_XDCS_SETUP();
|
||||
@ -14,27 +14,27 @@ public:
|
||||
disable();
|
||||
}
|
||||
|
||||
void select_sd(bool enabled=true) {
|
||||
static void select_sd(bool enabled=true) {
|
||||
PIN_SD_CS(enabled ? LOW : HIGH);
|
||||
delayMicroseconds(MCP_SPI_SETTING_DELAY);
|
||||
}
|
||||
|
||||
void select_vs1053_xcs(bool enabled=true) {
|
||||
static void select_vs1053_xcs(bool enabled=true) {
|
||||
PIN_VS1053_XCS(enabled ? LOW : HIGH);
|
||||
delayMicroseconds(MCP_SPI_SETTING_DELAY);
|
||||
}
|
||||
|
||||
void select_vs1053_xdcs(bool enabled=true) {
|
||||
static void select_vs1053_xdcs(bool enabled=true) {
|
||||
PIN_VS1053_XDCS(enabled ? LOW : HIGH);
|
||||
delayMicroseconds(MCP_SPI_SETTING_DELAY);
|
||||
}
|
||||
|
||||
void select_rc522(bool enabled=true) {
|
||||
static void select_rc522(bool enabled=true) {
|
||||
PIN_RC522_CS(enabled ? LOW : HIGH);
|
||||
delayMicroseconds(MCP_SPI_SETTING_DELAY);
|
||||
}
|
||||
|
||||
void disable() {
|
||||
static void disable() {
|
||||
PIN_SD_CS(HIGH);
|
||||
PIN_VS1053_XCS(HIGH);
|
||||
PIN_VS1053_XDCS(HIGH);
|
||||
|
@ -1,10 +1,11 @@
|
||||
#include "controller.h"
|
||||
#include "spi_master.h"
|
||||
#include "config.h"
|
||||
#include "playlist.h"
|
||||
|
||||
Controller::Controller(Player* p, SPIMaster* s) {
|
||||
Controller::Controller(Player* p, PlaylistManager* pm) {
|
||||
_player = p;
|
||||
_spi = s;
|
||||
_pm = pm;
|
||||
_rfid = new MFRC522(17, MFRC522::UNUSED_PIN);
|
||||
|
||||
BTN_NEXT_SETUP();
|
||||
@ -12,13 +13,13 @@ Controller::Controller(Player* p, SPIMaster* s) {
|
||||
BTN_VOL_UP_SETUP();
|
||||
BTN_VOL_DOWN_SETUP();
|
||||
|
||||
_spi->select_rc522();
|
||||
SPIMaster::select_rc522();
|
||||
DEBUG("Initializing RC522...\n");
|
||||
_rfid->PCD_Init();
|
||||
#ifdef SHOW_DEBUG
|
||||
_rfid->PCD_DumpVersionToSerial();
|
||||
#endif
|
||||
_spi->select_rc522(false);
|
||||
SPIMaster::select_rc522(false);
|
||||
INFO("RC522 initialized.\n");
|
||||
|
||||
for (uint8_t i=0; i<NUM_BUTTONS; i++) _button_last_pressed_at[i]=0;
|
||||
@ -44,7 +45,7 @@ void Controller::loop() {
|
||||
}
|
||||
|
||||
uint32_t Controller::_get_rfid_card_uid() {
|
||||
_spi->select_rc522();
|
||||
SPIMaster::select_rc522();
|
||||
if (!_rfid->PICC_ReadCardSerial()) {
|
||||
if (!_rfid->PICC_IsNewCardPresent()) {
|
||||
return 0;
|
||||
@ -53,7 +54,7 @@ uint32_t Controller::_get_rfid_card_uid() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
_spi->select_rc522(false);
|
||||
SPIMaster::select_rc522(false);
|
||||
uint32_t uid = _rfid->uid.uidByte[0]<<24 | _rfid->uid.uidByte[1]<<16 | _rfid->uid.uidByte[2]<<8 | _rfid->uid.uidByte[3];
|
||||
return uid;
|
||||
}
|
||||
@ -63,19 +64,21 @@ void Controller::_check_rfid() {
|
||||
if (_rfid_present) {
|
||||
byte buffer[2];
|
||||
byte buffer_size = 2;
|
||||
_spi->select_rc522();
|
||||
SPIMaster::select_rc522();
|
||||
status = _rfid->PICC_WakeupA(buffer, &buffer_size);
|
||||
if (status == MFRC522::STATUS_OK) {
|
||||
// Card is still present.
|
||||
_rfid->PICC_HaltA();
|
||||
_spi->select_rc522(false);
|
||||
SPIMaster::select_rc522(false);
|
||||
return;
|
||||
}
|
||||
_spi->select_rc522(false);
|
||||
SPIMaster::select_rc522(false);
|
||||
// Card is now gone
|
||||
_rfid_present = false;
|
||||
INFO("No more RFID card.\n");
|
||||
_player->stop();
|
||||
if (_state != LOCKED) {
|
||||
_player->stop();
|
||||
}
|
||||
} else {
|
||||
uint32_t uid = _get_rfid_card_uid();
|
||||
if (uid > 0) {
|
||||
@ -92,23 +95,63 @@ void Controller::_check_rfid() {
|
||||
|
||||
String data = _read_rfid_data();
|
||||
|
||||
_player->play_id(s_uid);
|
||||
Playlist* pl = _pm->get_playlist_for_id(s_uid);
|
||||
if (data.indexOf("[lock]") != -1) {
|
||||
if (_state == LOCKED) {
|
||||
_state = NORMAL;
|
||||
DEBUG("ControllerState is now UNLOCKED\n");
|
||||
} else {
|
||||
DEBUG("ControllerState is now LOCKING\n");
|
||||
_state = LOCKING;
|
||||
}
|
||||
}
|
||||
if (pl==NULL) {
|
||||
INFO("Could not find album for id '%s'.", s_uid.c_str());
|
||||
return;
|
||||
}
|
||||
int index;
|
||||
if (data.indexOf("[random]") != -1 && pl->is_fresh()) {
|
||||
pl->shuffle();
|
||||
} else if ((index=data.indexOf("[random:")) != -1 && pl->is_fresh()) {
|
||||
String temp = data.substring(index + 8);
|
||||
index = temp.indexOf("]");
|
||||
TRACE("temp: %s, temp.substring(0, %d): %s\n", temp.c_str(), index, temp.substring(0, index).c_str());
|
||||
if (index>0) {
|
||||
uint8_t random_offset = temp.substring(0, index).toInt();
|
||||
pl->shuffle(random_offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state == LOCKED) {
|
||||
DEBUG("ControllerState is LOCKED, ignoring card.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_state == LOCKING) {
|
||||
_state = LOCKED;
|
||||
DEBUG("ControllerState is now LOCKED.\n");
|
||||
}
|
||||
|
||||
_player->play(pl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String Controller::_read_rfid_data() {
|
||||
_spi->select_rc522();
|
||||
static MFRC522::MIFARE_Key keys[8] = {
|
||||
{{0xd3, 0xf7, 0xd3, 0xf7, 0xd3, 0xf7}}, // D3 F7 D3 F7 D3 F7
|
||||
{{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}}, // FF FF FF FF FF FF = factory default
|
||||
{{0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5}}, // A0 A1 A2 A3 A4 A5
|
||||
{{0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5}}, // B0 B1 B2 B3 B4 B5
|
||||
{{0x4d, 0x3a, 0x99, 0xc3, 0x51, 0xdd}}, // 4D 3A 99 C3 51 DD
|
||||
{{0x1a, 0x98, 0x2c, 0x7e, 0x45, 0x9a}}, // 1A 98 2C 7E 45 9A
|
||||
{{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}}, // AA BB CC DD EE FF
|
||||
{{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}} // 00 00 00 00 00 00
|
||||
};
|
||||
SPIMaster::select_rc522();
|
||||
DEBUG("Trying to read RFID data...\n");
|
||||
|
||||
String data = "";
|
||||
MFRC522::MIFARE_Key key;
|
||||
key.keyByte[0] = 0xD3;
|
||||
key.keyByte[1] = 0xF7;
|
||||
key.keyByte[2] = 0xD3;
|
||||
key.keyByte[3] = 0xF7;
|
||||
key.keyByte[4] = 0xD3;
|
||||
key.keyByte[5] = 0xF7;
|
||||
MFRC522::PICC_Type type = _rfid->PICC_GetType(_rfid->uid.sak);
|
||||
|
||||
uint8_t sectors = 0;
|
||||
@ -119,35 +162,45 @@ String Controller::_read_rfid_data() {
|
||||
default: INFO("Unknown PICC type %s\n", String(MFRC522::PICC_GetTypeName(type)).c_str());
|
||||
}
|
||||
|
||||
int good_key_index = -1;
|
||||
for (uint8_t sector=1; sector<sectors; sector++) {
|
||||
uint8_t blocks = (sector < 32) ? 4 : 16;
|
||||
uint8_t block_offset = (sector < 32) ? sector * 4 : 128 + (sector - 32) * 16;
|
||||
|
||||
MFRC522::StatusCode status;
|
||||
status = _rfid->PCD_Authenticate(MFRC522::PICC_CMD_MF_AUTH_KEY_A, block_offset, &key, &_rfid->uid);
|
||||
if (status != MFRC522::STATUS_OK) {
|
||||
DEBUG("PCD_Authenticate() for sector %d failed: %s\n", sector, String(_rfid->GetStatusCodeName(status)).c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
for (uint8_t block=0; block<blocks-1; block++) {
|
||||
byte buffer[18];
|
||||
uint8_t byte_count = 18;
|
||||
status = _rfid->MIFARE_Read(block_offset + block, buffer, &byte_count);
|
||||
if (status != MFRC522::STATUS_OK) {
|
||||
DEBUG("MIFARE_Read() failed: %s\n", String(_rfid->GetStatusCodeName(status)).c_str());
|
||||
continue;
|
||||
|
||||
for (int i=0; i<8; i++) {
|
||||
MFRC522::MIFARE_Key *k = &keys[i];
|
||||
TRACE("Trying MIFARE key %02X %02X %02X %02X %02X %02X...\n", k->keyByte[0], k->keyByte[1], k->keyByte[2], k->keyByte[3], k->keyByte[4], k->keyByte[5]);
|
||||
status = _rfid->PCD_Authenticate(MFRC522::PICC_CMD_MF_AUTH_KEY_A, block_offset, k, &_rfid->uid);
|
||||
if (status == MFRC522::STATUS_OK) {
|
||||
TRACE("Authentication succeeded with key #%d\n", i);
|
||||
good_key_index = i;
|
||||
break;
|
||||
}
|
||||
for (int i=0; i<16; i++) {
|
||||
if (buffer[i]>=0x20 && buffer[i]<0x7F) data.concat((char)buffer[i]);
|
||||
}
|
||||
if (good_key_index == -1) {
|
||||
TRACE("Could not find a valid MIFARE key.\n");
|
||||
} else {
|
||||
for (uint8_t block=0; block<blocks-1; block++) {
|
||||
byte buffer[18];
|
||||
uint8_t byte_count = 18;
|
||||
status = _rfid->MIFARE_Read(block_offset + block, buffer, &byte_count);
|
||||
if (status != MFRC522::STATUS_OK) {
|
||||
DEBUG("MIFARE_Read() failed: %s\n", String(_rfid->GetStatusCodeName(status)).c_str());
|
||||
continue;
|
||||
}
|
||||
for (int i=0; i<16; i++) {
|
||||
if (buffer[i]>=0x20 && buffer[i]<0x7F) data.concat((char)buffer[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_rfid->PICC_HaltA();
|
||||
_rfid->PCD_StopCrypto1();
|
||||
DEBUG("Data from RFID: %s", data.c_str());
|
||||
_spi->select_rc522(false);
|
||||
DEBUG("Data from RFID: %s\n", data.c_str());
|
||||
SPIMaster::select_rc522(false);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -169,16 +222,15 @@ void Controller::_check_serial() {
|
||||
void Controller::_execute_serial_command(String cmd) {
|
||||
DEBUG("Executing command: %s\n", cmd.c_str());
|
||||
|
||||
if (cmd.equals("ls")) {
|
||||
_execute_command_ls("/");
|
||||
} else if (cmd.startsWith("ls ")) {
|
||||
_execute_command_ls(cmd.substring(3));
|
||||
} else if (cmd.equals("play")) {
|
||||
_player->play_random_album();
|
||||
} else if (cmd.startsWith("play ")) {
|
||||
_player->play_id(cmd.substring(5));
|
||||
} else if (cmd.startsWith("sys ")) {
|
||||
_player->play_system_sound(cmd.substring(4));
|
||||
if (cmd.startsWith("play ")) {
|
||||
Playlist* p = _pm->get_playlist_for_id(cmd.substring(5));
|
||||
_player->play(p);
|
||||
//} else if (cmd.equals("ls")) {
|
||||
// _execute_command_ls("/");
|
||||
//} else if (cmd.startsWith("ls ")) {
|
||||
// _execute_command_ls(cmd.substring(3));
|
||||
//} else if (cmd.equals("play")) {
|
||||
// _player->play_random_album();
|
||||
} else if (cmd.equals("stop")) {
|
||||
_player->stop();
|
||||
} else if (cmd.equals("help")) {
|
||||
@ -192,7 +244,7 @@ void Controller::_execute_serial_command(String cmd) {
|
||||
} else if (cmd.equals("n")) {
|
||||
_player->track_next();
|
||||
} else if (cmd.equals("ids")) {
|
||||
_execute_command_ids();
|
||||
_pm->dump_ids();
|
||||
} else {
|
||||
ERROR("Unknown command: %s\n", cmd.c_str());
|
||||
}
|
||||
@ -201,25 +253,19 @@ void Controller::_execute_serial_command(String cmd) {
|
||||
|
||||
void Controller::_execute_command_ls(String path) {
|
||||
INFO("Listing contents of %s:\n", path.c_str());
|
||||
std::list<String> files = _player->ls(path);
|
||||
for(std::list<String>::iterator it=files.begin(); it!=files.end(); ++it) {
|
||||
INFO(" %s\n", (*it).c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void Controller::_execute_command_ids() {
|
||||
for (std::map<String, String>::iterator it = _player->id_to_folder_map.begin(); it!=_player->id_to_folder_map.end(); ++it) {
|
||||
INFO(" %s -> %s\n", it->first.c_str(), it->second.c_str());
|
||||
}
|
||||
// TODO
|
||||
//std::list<String> files = _player->ls(path);
|
||||
//for(std::list<String>::iterator it=files.begin(); it!=files.end(); ++it) {
|
||||
// INFO(" %s\n", (*it).c_str());
|
||||
//}
|
||||
}
|
||||
|
||||
void Controller::_execute_command_help() {
|
||||
INFO("Valid commands are:");
|
||||
INFO(" help - Displays this help\n");
|
||||
INFO(" ls [dir] - Lists the contents of [dir] or, if not given, of /\n");
|
||||
//INFO(" ls [dir] - Lists the contents of [dir] or, if not given, of /\n");
|
||||
INFO(" ids - Lists all known ID-to-folder mappings\n");
|
||||
INFO(" play [id] - Plays the album with the given id\n");
|
||||
INFO(" sys [file]- Plays the file as system sound\n");
|
||||
INFO(" stop - Stops playback\n");
|
||||
INFO(" - / + - Decrease or increase the volume\n");
|
||||
INFO(" p / n - Previous or next track\n");
|
||||
@ -227,13 +273,21 @@ void Controller::_execute_command_help() {
|
||||
|
||||
void Controller::_check_buttons() {
|
||||
if (BTN_PREV() && _debounce_button(0)) {
|
||||
_player->track_prev();
|
||||
if (_state == NORMAL) {
|
||||
_player->track_prev();
|
||||
} else {
|
||||
DEBUG("Ignoring btn_prev because state is LOCKED.\n");
|
||||
}
|
||||
} else if (BTN_VOL_UP() && _debounce_button(1)) {
|
||||
_player->vol_up();
|
||||
} else if (BTN_VOL_DOWN() && _debounce_button(2)) {
|
||||
_player->vol_down();
|
||||
} else if (BTN_NEXT() && _debounce_button(3)) {
|
||||
_player->track_next();
|
||||
if (_state == NORMAL) {
|
||||
_player->track_next();
|
||||
} else {
|
||||
DEBUG("Ignoring btn_next because state is LOCKED.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,13 +306,14 @@ String Controller::get_status_json() {
|
||||
response.concat(_player->is_playing() ? "playing" : "idle");
|
||||
response.concat("\", ");
|
||||
if (_player->is_playing()) {
|
||||
response.concat("\"album\": \"");
|
||||
response.concat(_player->album());
|
||||
response.concat("\", \"track\": ");
|
||||
response.concat(_player->track());
|
||||
response.concat(", \"position\": ");
|
||||
response.concat(_player->position());
|
||||
response.concat(", ");
|
||||
// TODO
|
||||
//response.concat("\"album\": \"");
|
||||
//response.concat(_player->album());
|
||||
//response.concat("\", \"track\": ");
|
||||
//response.concat(_player->track());
|
||||
//response.concat(", \"position\": ");
|
||||
//response.concat(_player->position());
|
||||
//response.concat(", ");
|
||||
}
|
||||
response.concat("\"volume\": ");
|
||||
response.concat(_player->volume());
|
||||
|
@ -8,10 +8,12 @@
|
||||
#include "spi_master.h"
|
||||
#include "http_server.h"
|
||||
#include "mqtt_client.h"
|
||||
#include "playlist_manager.h"
|
||||
#include <ESP8266FtpServer.h>
|
||||
|
||||
Controller* controller;
|
||||
Player* player;
|
||||
PlaylistManager* pm;
|
||||
//HTTPServer* http_server;
|
||||
FtpServer* ftp_server;
|
||||
MQTTClient* mqtt_client;
|
||||
@ -33,6 +35,7 @@ void setup() {
|
||||
DEBUG("Setting up SPI...\n");
|
||||
SPI.begin();
|
||||
SPI.setHwCs(false);
|
||||
SPIMaster::init();
|
||||
SPIMaster* spi = new SPIMaster();
|
||||
INFO("SPI initialized.\n");
|
||||
|
||||
@ -45,9 +48,13 @@ void setup() {
|
||||
}
|
||||
spi->select_sd(false);
|
||||
|
||||
DEBUG("Initializing PlaylistManager...\n");
|
||||
pm = new PlaylistManager();
|
||||
DEBUG("done.\n");
|
||||
|
||||
DEBUG("Initializing Player and Controller...\n");
|
||||
player = new Player(spi);
|
||||
controller = new Controller(player, spi);
|
||||
controller = new Controller(player, pm);
|
||||
INFO("Player and controller initialized.\n");
|
||||
|
||||
DEBUG("Connecting to wifi \"%s\"...\n", WIFI_SSID);
|
||||
|
267
src/player.cpp
267
src/player.cpp
@ -15,8 +15,6 @@ Player::Player(SPIMaster* s) {
|
||||
_spi->disable();
|
||||
PIN_VS1053_DREQ_SETUP();
|
||||
|
||||
_fill_id_to_folder_map();
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
@ -70,14 +68,6 @@ void Player::_init() {
|
||||
|
||||
INFO("VS1053 initialization completed.\n");
|
||||
|
||||
INFO("Checking system sounds...\n");
|
||||
_spi->select_sd();
|
||||
_check_system_sound("no_prev_song.mp3");
|
||||
_check_system_sound("no_next_song.mp3");
|
||||
_check_system_sound("volume_max.mp3");
|
||||
_check_system_sound("volume_min.mp3");
|
||||
_spi->select_sd(false);
|
||||
|
||||
_state = idle;
|
||||
}
|
||||
|
||||
@ -149,15 +139,6 @@ void Player::_record() {
|
||||
_state = recording;
|
||||
}
|
||||
|
||||
void Player::_check_system_sound(String filename) {
|
||||
String path = String("/system/") + filename;
|
||||
if (!SD.exists(path)) {
|
||||
ERROR("System sound %s is missing on the sd card!\n", path.c_str());
|
||||
} else {
|
||||
DEBUG("%s found.\n", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
inline void Player::_wait() {
|
||||
while(!PIN_VS1053_DREQ());
|
||||
}
|
||||
@ -476,15 +457,15 @@ void Player::set_volume(uint8_t vol, bool save) {
|
||||
}
|
||||
|
||||
void Player::vol_up() {
|
||||
if (_volume == VOLUME_MAX) play_system_sound("volume_max.mp3");
|
||||
else if (_volume + VOLUME_STEP > VOLUME_MAX) set_volume(VOLUME_MAX);
|
||||
else set_volume(_volume + VOLUME_STEP);
|
||||
uint8_t vol = _volume + VOLUME_STEP;
|
||||
if (vol > VOLUME_MAX) vol=VOLUME_MAX;
|
||||
set_volume(vol);
|
||||
}
|
||||
|
||||
void Player::vol_down() {
|
||||
if (_volume >= VOLUME_MIN + VOLUME_STEP) set_volume(_volume - VOLUME_STEP);
|
||||
else if (_volume == VOLUME_MIN) play_system_sound("volume_min.mp3");
|
||||
else set_volume(VOLUME_MIN);
|
||||
uint8_t vol = _volume - VOLUME_STEP;
|
||||
if (vol < VOLUME_MIN) vol=VOLUME_MIN;
|
||||
set_volume(vol);
|
||||
}
|
||||
|
||||
void Player::_mute() {
|
||||
@ -501,26 +482,27 @@ void Player::_unmute() {
|
||||
|
||||
void Player::track_next() {
|
||||
if (_state != playing) return;
|
||||
if (_playing_index + 1 >= _playing_album_songs) {
|
||||
play_system_sound("no_next_song.mp3");
|
||||
if (!_current_playlist->has_track_next()) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index + 1);
|
||||
_current_playlist->track_next();
|
||||
play();
|
||||
}
|
||||
|
||||
void Player::track_prev() {
|
||||
if (_state != playing) return;
|
||||
if (_current_play_position > 100000) {
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index);
|
||||
_current_playlist->track_restart();
|
||||
play();
|
||||
} else {
|
||||
if (_playing_index == 0) {
|
||||
play_system_sound("no_prev_song.mp3");
|
||||
if (!_current_playlist->has_track_prev()) {
|
||||
return;
|
||||
}
|
||||
stop();
|
||||
play_song(_playing_album, _playing_index - 1);
|
||||
_current_playlist->track_prev();
|
||||
play();
|
||||
}
|
||||
}
|
||||
|
||||
@ -528,195 +510,21 @@ bool Player::is_playing() {
|
||||
return _state == playing;
|
||||
}
|
||||
|
||||
std::list<String> Player::ls(String path, bool withFiles, bool withDirs, bool withHidden) {
|
||||
_spi->select_sd();
|
||||
std::list<String> result;
|
||||
if (!SD.exists(path)) return result;
|
||||
File dir = SD.open(path);
|
||||
File entry;
|
||||
while (entry = dir.openNextFile()) {
|
||||
if (!withDirs && entry.isDirectory()) continue;
|
||||
if (!withFiles && !entry.isDirectory()) continue;
|
||||
String filename = entry.name();
|
||||
if (!withHidden && filename.startsWith(".")) continue;
|
||||
if (entry.isDirectory()) filename.concat("/");
|
||||
result.push_back(filename);
|
||||
}
|
||||
_spi->select_sd(false);
|
||||
result.sort();
|
||||
return result;
|
||||
bool Player::play(Playlist* p) {
|
||||
_current_playlist = p;
|
||||
return play();
|
||||
}
|
||||
|
||||
String Player::_find_album_dir(String id) {
|
||||
_spi->select_sd();
|
||||
if (id.endsWith("/")) id = id.substring(0, id.length() - 1);
|
||||
String id_with_divider = id + " - ";
|
||||
File root = SD.open("/");
|
||||
File entry;
|
||||
String result = String("");
|
||||
while ((result.length()==0) && (entry = root.openNextFile())) {
|
||||
String name = entry.name() + 1;
|
||||
TRACE("Checking if '%s' startsWith '%s'...\n", name.c_str(), id.c_str());
|
||||
if (entry.isDirectory() && (name.startsWith(id_with_divider) || name.equals(id))) {
|
||||
result = name;
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
root.close();
|
||||
_spi->select_sd(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::list<String> Player::_files_in_dir(String path) {
|
||||
_spi->select_sd();
|
||||
TRACE("Examining folder %s...\n", path.c_str());
|
||||
if (!path.startsWith("/")) path = String("/") + path;
|
||||
//if (!path.endsWith("/")) path.concat("/");
|
||||
std::list<String> result;
|
||||
if (!SD.exists(path)) {
|
||||
DEBUG("Could not open path '%s'.\n", path.c_str());
|
||||
_spi->select_sd(false);
|
||||
return result;
|
||||
}
|
||||
File dir = SD.open(path);
|
||||
File entry;
|
||||
while (entry = dir.openNextFile()) {
|
||||
String filename = entry.name();
|
||||
filename = filename.substring(path.length() + 1);
|
||||
if (!entry.isDirectory() &&
|
||||
!filename.startsWith(".") &&
|
||||
( filename.endsWith(".mp3") ||
|
||||
filename.endsWith(".ogg") ||
|
||||
filename.endsWith(".wma") ||
|
||||
filename.endsWith(".mp4") ||
|
||||
filename.endsWith(".mpa"))) {
|
||||
TRACE(" Adding entry %s\n", entry.name());
|
||||
result.push_back(entry.name());
|
||||
} else {
|
||||
TRACE(" Ignoring entry %s\n", filename.c_str());
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
_spi->select_sd(false);
|
||||
result.sort();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void Player::_fill_id_to_folder_map() {
|
||||
DEBUG("_fill_id_to_folder_map() running...");
|
||||
_spi->select_sd();
|
||||
File root = SD.open("/");
|
||||
File entry;
|
||||
while (entry = root.openNextFile()) {
|
||||
String foldername = entry.name();
|
||||
// Remove trailing slash
|
||||
foldername.remove(foldername.length());
|
||||
TRACE("Looking at %s...\n", foldername.c_str());
|
||||
if (!entry.isDirectory() || foldername.startsWith("/.")) continue;
|
||||
if (!SD.exists(foldername + "/ids.txt")) {
|
||||
TRACE("Folder %s does not contain ids.txt -> ignoring\n", foldername.c_str());
|
||||
continue;
|
||||
}
|
||||
TRACE("Reading contents of %s...\n", (foldername + "/ids.txt").c_str());
|
||||
File f = SD.open(foldername + "/ids.txt");
|
||||
String buffer = "";
|
||||
while (f.available()) {
|
||||
char c = f.read();
|
||||
if (c=='\n' || c=='\r') {
|
||||
if (buffer.length() > 0) {
|
||||
id_to_folder_map[buffer] = foldername;
|
||||
DEBUG("Adding mapping '%s'=>'%s'\n", buffer.c_str(), foldername.c_str());
|
||||
buffer = "";
|
||||
}
|
||||
} else {
|
||||
buffer.concat(c);
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
|
||||
if (buffer.length() > 0) {
|
||||
id_to_folder_map[buffer] = foldername;
|
||||
DEBUG("Adding mapping '%s'=>'%s'\n", buffer.c_str(), foldername.c_str());
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
root.close();
|
||||
DEBUG("fill_id_to_folder_map done.\n");
|
||||
_spi->select_sd(false);
|
||||
}
|
||||
|
||||
String Player::_random_album() {
|
||||
std::list<String> albums = ls("/", false, true, false);
|
||||
uint8_t rnd = random(albums.size());
|
||||
std::list<String>::iterator it = albums.begin();
|
||||
for (int i=0; i<rnd; i++) { it++; }
|
||||
return *it;
|
||||
}
|
||||
|
||||
void Player::play_random_album() {
|
||||
play_album(_random_album());
|
||||
}
|
||||
|
||||
bool Player::play_id(String id) {
|
||||
String folder = _foldername_for_id(id);
|
||||
if (folder.length()==0) return false;
|
||||
return play_album(folder);
|
||||
}
|
||||
|
||||
String Player::_foldername_for_id(String id) {
|
||||
DEBUG("Searching for id %s...\n", id.c_str());
|
||||
std::map<String, String>::iterator it = id_to_folder_map.find(id);
|
||||
if (it != id_to_folder_map.end()) {
|
||||
DEBUG("Found folder '%s' for id %s.\n", it->first.c_str(), it->second.c_str());
|
||||
return it->second;
|
||||
}
|
||||
DEBUG("No folder found for id %s.\n", id.c_str());
|
||||
return "";
|
||||
}
|
||||
|
||||
bool Player::play_album(String album) {
|
||||
album_state s = _last_tracks[album.c_str()];
|
||||
DEBUG("Last index for album '%s' was %d,%d\n", album.c_str(), s.index, s.position);
|
||||
return play_song(album, s.index, s.position);
|
||||
}
|
||||
|
||||
bool Player::play_song(String album, uint8_t index, uint32_t skip_to) {
|
||||
bool Player::play() {
|
||||
if (_state == sleeping || _state == recording) _wakeup();
|
||||
if (_state != idle) return false;
|
||||
DEBUG("Trying to play song at index %d, offset %d of album %s\n", index, skip_to, album.c_str());
|
||||
std::list<String> files = _files_in_dir(album);
|
||||
_playing_album_songs = files.size();
|
||||
DEBUG("Found %d songs in album\n", files.size());
|
||||
if (index >= files.size()) {
|
||||
ERROR("No matching file found - not playing.\n");
|
||||
return false;
|
||||
}
|
||||
String file = *(std::next(files.begin(), index));
|
||||
String file = _current_playlist->get_current_file();
|
||||
uint32_t position = _current_playlist->get_position();
|
||||
_state = playing;
|
||||
_playing_album = album;
|
||||
_playing_index = index;
|
||||
_set_last_track(album.c_str(), index, skip_to);
|
||||
_play_file(file, skip_to);
|
||||
_play_file(file, position);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Player::play_system_sound(String filename) {
|
||||
String file = filename;
|
||||
if (!SD.exists(file)) {
|
||||
ERROR("File %s does not exist!\n", file.c_str());
|
||||
return;
|
||||
}
|
||||
if (_state == playing) {
|
||||
stop();
|
||||
_state = system_sound_while_playing;
|
||||
} else {
|
||||
_state = system_sound_while_stopped;
|
||||
}
|
||||
_play_file(file, 0);
|
||||
}
|
||||
|
||||
void Player::_play_file(String file, uint32_t file_offset) {
|
||||
INFO("play_file('%s', %d)\n", file.c_str(), file_offset);
|
||||
_spi->select_sd();
|
||||
@ -797,11 +605,10 @@ void Player::_finish_playing() {
|
||||
}
|
||||
|
||||
void Player::stop(bool turn_speaker_off) {
|
||||
if (_state != playing /* && _state != system_sound_while_playing && _state != system_sound_while_stopped*/) return;
|
||||
if (_state != playing) return;
|
||||
INFO("Stopping...\n");
|
||||
if (_state == playing) {
|
||||
_set_last_track(_playing_album.c_str(), _playing_index, (uint32_t)_file.position());
|
||||
}
|
||||
_current_playlist->set_position(_current_play_position);
|
||||
|
||||
_state = stopping;
|
||||
_stop_delay = 0;
|
||||
_write_control_register(SCI_MODE, _read_control_register(SCI_MODE) | SM_CANCEL);
|
||||
@ -842,18 +649,12 @@ void Player::_refill() {
|
||||
DEBUG("EOF reached.\n");
|
||||
_skip_to = 0;
|
||||
_finish_playing();
|
||||
if (_state == system_sound_while_playing) {
|
||||
_finish_stopping(false);
|
||||
play_album(_playing_album);
|
||||
return;
|
||||
} else if (_state == system_sound_while_stopped) {
|
||||
_finish_stopping(true);
|
||||
return;
|
||||
}
|
||||
_finish_stopping(false);
|
||||
bool result = play_song(_playing_album, _playing_index + 1);
|
||||
if (!result) {
|
||||
_set_last_track(_playing_album.c_str(), 0, 0);
|
||||
if (_current_playlist->has_track_next()) {
|
||||
_current_playlist->track_next();
|
||||
play();
|
||||
} else {
|
||||
_current_playlist->reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -880,10 +681,7 @@ void Player::_refill() {
|
||||
}
|
||||
|
||||
bool Player::_refill_needed() {
|
||||
return _state==playing ||
|
||||
_state==stopping ||
|
||||
_state==system_sound_while_playing ||
|
||||
_state==system_sound_while_stopped;
|
||||
return _state==playing || _state==stopping;
|
||||
}
|
||||
|
||||
bool Player::loop() {
|
||||
@ -914,8 +712,3 @@ bool Player::loop() {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Player::_set_last_track(const char* album, uint8_t index, uint32_t position) {
|
||||
DEBUG("Setting _last_track[%s]=%d,%d.\n", album, index, position);
|
||||
_last_tracks[album] = {index, position};
|
||||
}
|
||||
|
108
src/playlist.cpp
Normal file
108
src/playlist.cpp
Normal file
@ -0,0 +1,108 @@
|
||||
#include <playlist.h>
|
||||
#include "spi_master.h"
|
||||
#include "config.h"
|
||||
#include <SD.h>
|
||||
#include <algorithm>
|
||||
|
||||
Playlist::Playlist(String path) {
|
||||
// Add files to _files
|
||||
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());
|
||||
_files.push_back(entry.name());
|
||||
} else {
|
||||
TRACE(" Ignoring entry %s\n", filename.c_str());
|
||||
}
|
||||
entry.close();
|
||||
}
|
||||
dir.close();
|
||||
SPIMaster::select_sd(false);
|
||||
std::sort(_files.begin(), _files.end());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
String temp = _files[i];
|
||||
_files[i] = _files[j];
|
||||
_files[j] = temp;
|
||||
}
|
||||
}
|
||||
_shuffled = true;
|
||||
TRACE("Done.\n");
|
||||
}
|
||||
|
||||
void Playlist::reset() {
|
||||
std::sort(_files.begin(), _files.end());
|
||||
_current_track = 0;
|
||||
_position = 0;
|
||||
_shuffled = false;
|
||||
}
|
||||
|
||||
String Playlist::get_current_file() {
|
||||
return _files[_current_track];
|
||||
}
|
||||
|
||||
uint32_t Playlist::get_position() {
|
||||
return _position;
|
||||
}
|
||||
|
||||
void Playlist::set_position(uint32_t p) {
|
||||
_position = p;
|
||||
}
|
||||
|
||||
bool Playlist::is_fresh() {
|
||||
return !_shuffled && _position==0 && _current_track==0;
|
||||
}
|
60
src/playlist_manager.cpp
Normal file
60
src/playlist_manager.cpp
Normal file
@ -0,0 +1,60 @@
|
||||
#include "playlist_manager.h"
|
||||
#include <SD.h>
|
||||
#include "spi_master.h"
|
||||
|
||||
PlaylistManager::PlaylistManager() {
|
||||
SPIMaster::select_sd();
|
||||
File root = SD.open("/");
|
||||
File entry;
|
||||
while (entry = root.openNextFile()) {
|
||||
String foldername = entry.name();
|
||||
// Remove trailing slash
|
||||
foldername.remove(foldername.length());
|
||||
TRACE("Looking at %s...", foldername.c_str());
|
||||
if (!entry.isDirectory() || foldername.startsWith("/.")) continue;
|
||||
if (!SD.exists(foldername + "/ids.txt")) {
|
||||
TRACE("No ids.txt -> ignoring\n");
|
||||
continue;
|
||||
}
|
||||
File f = SD.open(foldername + "/ids.txt");
|
||||
String buffer = "";
|
||||
if (f.available()) {
|
||||
do {
|
||||
char c = f.read();
|
||||
if (!f.available() && c!='\n' && c!='\r') {
|
||||
buffer.concat(c);
|
||||
c='\n';
|
||||
}
|
||||
|
||||
if (c=='\n' || c=='\r') {
|
||||
if (buffer.length() > 0) {
|
||||
_map[buffer] = foldername;
|
||||
TRACE(" ID %s", buffer.c_str());
|
||||
buffer="";
|
||||
}
|
||||
} else {
|
||||
buffer.concat(c);
|
||||
}
|
||||
} while(f.available());
|
||||
}
|
||||
f.close();
|
||||
entry.close();
|
||||
}
|
||||
root.close();
|
||||
SPIMaster::select_sd(false);
|
||||
}
|
||||
|
||||
Playlist* PlaylistManager::get_playlist_for_id(String id) {
|
||||
if (!_map.count(id)) return NULL;
|
||||
String folder = _map[id];
|
||||
if (!_playlists.count(folder)) {
|
||||
_playlists[folder] = new Playlist(folder);
|
||||
}
|
||||
return _playlists[folder];
|
||||
}
|
||||
|
||||
void PlaylistManager::dump_ids() {
|
||||
for (std::map<String, String>::iterator it = _map.begin(); it!=_map.end(); it++) {
|
||||
INFO(" %s -> %s\n", it->first.c_str(), it->second.c_str());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user