diff --git a/.gitignore b/.gitignore index eaf2f4b..15e6535 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /.lock-waf* /package-lock.json /node_modules +/*.json diff --git a/README.md b/README.md index 66ff9c8..d8dfd80 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# JellyPod +# jellyPod Control Jellyfin music playback from your Pebble with an iPod-like interface. # Supported Phone Clients -JellyPod requires a Jellyfin client to be running on your phone to handle playback. Not all clients support all features of JellyPod. Tested clients are detailed below. +jellyPod requires a Jellyfin client to be running on your phone to handle playback. Not all clients support all features of jellyPod. Tested clients are detailed below. ## Legend -- ✅ = verified (supports all features of JellyPod and prefers offline playback of local media over online streaming) +- ✅ = verified (supports all features of jellyPod and prefers offline playback of local media over online streaming) - ⚠️ = usable (functional with missing features/bugs detailed below) -- ⛔ = unsupported (does not work well or at all with JellyPod) +- ⛔ = unsupported (does not work well or at all with jellyPod) ## iOS - ✅ [Finamp Beta/Redesign](https://github.com/finamp-app/finamp/tree/redesign) (last tested release: 1.0.1) @@ -15,7 +15,7 @@ JellyPod requires a Jellyfin client to be running on your phone to handle playba - Your music continues to play in order even after you exhaust the tracks on the selected album and by the current artist - The next album is chosen by release date and the next artist is chosen alphanumerically - Multi-client/device support - - JellyPod is not just a remote for your phone + - jellyPod is not just a remote for your phone - Determines which client to control based on the most recently active session under your Jellyfin user - Classic design - Inspired by the 4th generation iPod diff --git a/package.json b/package.json index e71d1e4..815653b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@rebble/clay": "^1.0.10" }, "pebble": { - "displayName": "JellyPod", + "displayName": "jellyPod", "uuid": "b135c5a1-ddeb-4278-9da5-3ac68b718f0c", "sdkVersion": "3", "enableMultiJS": true, @@ -40,7 +40,7 @@ "IN_VANITY_LIST", "IN_ID_LIST", "OUT_MENU_NAV_SCOPE", - "OUT_TRACK_ID", + "OUT_JFID", "OUT_SHUFFLE_MODE" ], "resources": { diff --git a/src/c/JellyPod.c b/src/c/jellyPod.c similarity index 60% rename from src/c/JellyPod.c rename to src/c/jellyPod.c index 288e74f..0b3dfdd 100644 --- a/src/c/JellyPod.c +++ b/src/c/jellyPod.c @@ -3,13 +3,15 @@ #include typedef enum { - MENU_NAV_DISPLAY_MAIN = 0, - MENU_NAV_DISPLAY_ARTISTS = 1, - MENU_NAV_DISPLAY_ALBUMS = 2, - MENU_NAV_DISPLAY_ALBUM_TRACKS = 3, - MENU_NAV_DISPLAY_PLAYLISTS = 4, - MENU_NAV_DISPLAY_PLAYLIST_TRACKS = 5, -} MenuNavDisplay; + NAV_SCOPE_MAIN = 0, + NAV_SCOPE_ALPHA = 1, + NAV_SCOPE_ARTISTS = 2, + NAV_SCOPE_ALBUMS = 3, + NAV_SCOPE_ALBUM_TRACKS = 4, + NAV_SCOPE_PLAYLISTS = 5, + NAV_SCOPE_PLAYLIST_TRACKS = 6, + NAV_SCOPE_NOW_PLAYING = 7 +} NavScope; // layer-age static Window *s_main_window; @@ -18,7 +20,8 @@ static Layer *s_divider_layer; static MenuLayer *s_menu_layer; // globals -static MenuNavDisplay s_current_menu_display = MENU_NAV_DISPLAY_MAIN; +static bool s_pkjs_ready = false; +static NavScope s_current_menu_scope = NAV_SCOPE_MAIN; // resources static GFont s_font_chicago; @@ -39,34 +42,99 @@ static GFont s_font_chicago; static const GRect s_menu_grect = GRect(MARGIN_X, BAR_HEIGHT + MARGIN_Y, PBL_DISPLAY_WIDTH - (MARGIN_X * 2), PBL_DISPLAY_HEIGHT - (MARGIN_Y * 2)); static const char *s_main_menu_options[3] = {"Artists", "Playlists", "Now Playing"}; +////////////////////// PKJS ////////////////////// + +static void inbox_received_handler(DictionaryIterator *iter, void *context) { + Tuple *ready_tuple = dict_find(iter, MESSAGE_KEY_PKJS_READY); + if (ready_tuple) { + s_pkjs_ready = true; + return; + } +} + +static void to_pkjs_scope_change(NavScope scope, const char *jfId, bool shuffleInScope) { + if (!s_pkjs_ready) { + return; + } + + // send scope to return data for (PKJS side should track last scope and only send new data if the new scope differs) + DictionaryIterator *out; + AppMessageResult result = app_message_outbox_begin(&out); + if (result != APP_MSG_OK) { + return; + } + dict_write_uint8(out, MESSAGE_KEY_OUT_MENU_NAV_SCOPE, scope); + + if (shuffleInScope) { + // tell PKJS to shuffle if a shuffle option was clicked + dict_write_uint8(out, MESSAGE_KEY_OUT_SHUFFLE_MODE, shuffleInScope); // if true, PKJS side shuffles IDs in scope and passes them to JF API + } else if (jfId) { + // otherwise, send the selected jfId, if present; + // for artists/albums/playlists, determines next data set returned to C; + // for tracks, starts sequential playback (sequential relative to entire library unless scope is playlist) + dict_write_cstring(out, MESSAGE_KEY_OUT_JFID, jfId); + } + + result = app_message_outbox_send(); + if (result != APP_MSG_OK) { + return; + } +} + static uint16_t menu_get_num_rows(MenuLayer *menu_layer, uint16_t section_index, void *context) { - switch (s_current_menu_display) { - case MENU_NAV_DISPLAY_MAIN: + switch (s_current_menu_scope) { + case NAV_SCOPE_MAIN: return 3; - case MENU_NAV_DISPLAY_ARTISTS: - case MENU_NAV_DISPLAY_ALBUMS: - case MENU_NAV_DISPLAY_ALBUM_TRACKS: - case MENU_NAV_DISPLAY_PLAYLISTS: + case NAV_SCOPE_ALPHA: + case NAV_SCOPE_ARTISTS: + case NAV_SCOPE_ALBUMS: + case NAV_SCOPE_ALBUM_TRACKS: + case NAV_SCOPE_PLAYLISTS: default: return 99; } } static void menu_draw_row(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *context) { - switch (s_current_menu_display) { - case MENU_NAV_DISPLAY_MAIN: + switch (s_current_menu_scope) { + case NAV_SCOPE_MAIN: custom_menu_cell_title_draw(ctx, cell_layer, s_main_menu_options[cell_index->row], NULL, s_font_chicago, MARGIN_X); break; - case MENU_NAV_DISPLAY_ARTISTS: - case MENU_NAV_DISPLAY_ALBUMS: - case MENU_NAV_DISPLAY_ALBUM_TRACKS: - case MENU_NAV_DISPLAY_PLAYLISTS: + case NAV_SCOPE_ALPHA: + case NAV_SCOPE_ARTISTS: + case NAV_SCOPE_ALBUMS: + case NAV_SCOPE_ALBUM_TRACKS: + case NAV_SCOPE_PLAYLISTS: default: - break; + return; } } -static void menu_selection_changed(struct MenuLayer *menu_layer, MenuIndex new_index, MenuIndex old_index, void *callback_context) { +static void menu_select_click(struct MenuLayer *menu_layer, MenuIndex *clicked_index, void *context) { + switch (s_current_menu_scope) { + case NAV_SCOPE_MAIN: + switch (clicked_index->row) { + case 0: + to_pkjs_scope_change(NAV_SCOPE_ALPHA, NULL, false); + break; + case 1: + to_pkjs_scope_change(NAV_SCOPE_PLAYLISTS, NULL, false); + break; + default: + to_pkjs_scope_change(NAV_SCOPE_NOW_PLAYING, NULL, false); // TODO + } + return; + case NAV_SCOPE_ALPHA: + case NAV_SCOPE_ARTISTS: + case NAV_SCOPE_ALBUMS: + case NAV_SCOPE_ALBUM_TRACKS: + case NAV_SCOPE_PLAYLISTS: + default: + return; + } +} + +static void menu_selection_changed(struct MenuLayer *menu_layer, MenuIndex new_index, MenuIndex old_index, void *context) { speaker_play_tone(200, 10, 80, SpeakerWaveformSine); } @@ -79,7 +147,7 @@ static void main_window_load() { Layer *main_window_layer = window_get_root_layer(s_main_window); s_menu_layer = menu_layer_create(s_menu_grect); - menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){.get_num_rows = menu_get_num_rows, .draw_row = menu_draw_row, .selection_changed = menu_selection_changed}); + menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){.get_num_rows = menu_get_num_rows, .draw_row = menu_draw_row, .select_click = menu_select_click, .selection_changed = menu_selection_changed}); menu_layer_pad_bottom_enable(s_menu_layer, false); menu_layer_set_click_config_onto_window(s_menu_layer, s_main_window); #ifdef PBL_ROUND @@ -88,7 +156,7 @@ static void main_window_load() { layer_add_child(main_window_layer, menu_layer_get_layer(s_menu_layer)); s_status_bar_layer = custom_status_bar_layer_create(BAR_HEIGHT, GColorWhite, 15); - custom_status_bar_layer_set_text(s_status_bar_layer, CSB_TEXT_CENTER, "pebble"); + custom_status_bar_layer_set_text(s_status_bar_layer, CSB_TEXT_CENTER, "jellyPod"); custom_status_bar_layer_set_text_font(s_status_bar_layer, CSB_TEXT_CENTER, s_font_chicago); #ifdef PBL_RECT custom_status_bar_layer_set_text(s_status_bar_layer, CSB_TEXT_LEFT, " | |"); @@ -107,15 +175,6 @@ static void main_window_unload() { menu_layer_destroy(s_menu_layer); } -////////////////////// PKJS ////////////////////// - -static void inbox_received_handler(DictionaryIterator *iter, void *context) { - Tuple *ready_tuple = dict_find(iter, MESSAGE_KEY_PKJS_READY); - if (ready_tuple) { - return; - } -} - static void init() { // initialize pkjs app_message_register_inbox_received(inbox_received_handler); diff --git a/src/pkjs/index.js b/src/pkjs/index.js index 749e166..b702a43 100644 --- a/src/pkjs/index.js +++ b/src/pkjs/index.js @@ -2,32 +2,117 @@ var Clay = require('@rebble/clay'); var clayConfig = require('./config'); new Clay(clayConfig); +var cfg = JSON.parse(localStorage.getItem('clay-settings')); + Pebble.addEventListener("ready", function () { - Pebble.sendAppMessage({ PKJS_READY: 1 }); + // store cache of library info from Jellyfin + if (cfg.PRIV_BASE_URL && cfg.PRIV_API_KEY && cfg.PRIV_USERNAME) { + // get userId + callAPI("GET", cfg.PRIV_BASE_URL + "/Users", function (userIdResp) { + if (userIdResp === null) { return; } + const userIdObj = JSON.parse(userIdResp); + let userId; + for (const user of userIdObj) { + if (user.Name === cfg.PRIV_USERNAME) { + userId = user.Id; + break; + } + } + + // get library + callAPI("GET", cfg.PRIV_BASE_URL + "/Items?userId=" + userId + "&recursive=true&includeItemTypes=Audio&sortBy=AlbumArtist,Artist,ProductionYear,Album,IndexNumber,Name'", function (libResp) { + if (libResp === null) { return; } + const libObj = JSON.parse(libResp); + // extraction output: + // a map of artists to arrays of albums + // a map of albums to arrays of contained tracks + + // extract alphanumerical list of artists + let artists = []; + let lastArtist = ""; + let unknownFound = false; + for (const track of libObj.Items) { + if (track.AlbumArtist) { + if (track.AlbumArtist != lastArtist) { + artists.push(track.AlbumArtist); + lastArtist = track.AlbumArtist; + } + } else if (track.Artists.length > 0) { + if (track.Artists[0] != lastArtist) { + artists.push(track.Artists[0]); + lastArtist = track.Artists[0]; + } + } else if (!unknownFound) { + unknownFound = true; + } + } + if (unknownFound) { + artists.push("unknown artist"); + } + console.log(artists); + + // sort artists into arrays mapped to first characters + const charMap = new Map(); + let currentChar = ""; + let currentArr = []; + for (const artistName of artists) { + char = Array.from(artistName)[0].toUpperCase(); + if (currentChar == "") { + currentChar = char; + } + if (char == currentChar) { + currentArr.push(artistName); + } else { + charMap.set(currentChar, currentArr); + currentChar = char; + currentArr = [artistName]; + } + } + if (currentChar !== "") { + charMap.set(currentChar, currentArr); + } + console.log(...charMap); + }); + + Pebble.sendAppMessage({ PKJS_READY: 1 }); + }); + } }); -function callAPI(method, endpoint) { +Pebble.addEventListener("appmessage", function (dict) { + if (dict.payload["OUT_MENU_NAV_SCOPE"]) { + //const cfg = JSON.parse(localStorage.getItem('clay-settings')); + //callAPI(cfg.PKJS_PAVLOK_API_KEY, parseInt(dict.payload["PKJS_PAVLOK_ZAP"])); + } +}); + +function reportAPIFailure() { + console.log("API FAILURE"); +} + +function callAPI(method, endpoint, callback) { const xhr = new XMLHttpRequest(); xhr.onload = function () { switch (xhr.status) { case 200: - return this.responseText; + callback(this.responseText); + return; default: - reportAPIFailure(); // TODO + reportAPIFailure(); } }; xhr.onerror = function () { - reportAPIFailure(); // TODO + reportAPIFailure(); }; xhr.ontimeout = function () { - reportAPIFailure(); // TODO + reportAPIFailure(); }; xhr.open(method, endpoint, true); xhr.timeout = 8000; xhr.setRequestHeader("Accept", "application/json"); - xhr.setRequestHeader("Authorization", 'MediaBrowser Token="' + apiKey + '"'); // TODO + xhr.setRequestHeader("Authorization", 'MediaBrowser Token="' + cfg.PRIV_API_KEY + '"'); xhr.send(); }