[GH-ISSUE #299] Default sorting of song list #219

Closed
opened 2026-02-26 02:32:28 +03:00 by kerem · 22 comments
Owner

Originally created by @alex-phillips on GitHub (Apr 13, 2016).
Original GitHub issue: https://github.com/koel/koel/issues/299

I can't seem to figure out the right place to put this, otherwise I'd submit a PR, but I think the default sorting of the song list should be artist - album - track. I'm not sure what the default sort currently is (if any) but every time I open up the app I have to click the artist column twice to get to what is defaulted on most media players.

Originally created by @alex-phillips on GitHub (Apr 13, 2016). Original GitHub issue: https://github.com/koel/koel/issues/299 I can't seem to figure out the right place to put this, otherwise I'd submit a PR, but I think the default sorting of the song list should be artist - album - track. I'm not sure what the default sort currently is (if any) but every time I open up the app I have to click the artist column twice to get to what is defaulted on most media players.
kerem closed this issue 2026-02-26 02:32:28 +03:00
Author
Owner

@phanan commented on GitHub (Apr 14, 2016):

You're right, there's no default sort as of current. I don't think there should be one, either. At least, I personally don't care about it, and it takes only one or two clicks anyway.

<!-- gh-comment-id:209778960 --> @phanan commented on GitHub (Apr 14, 2016): You're right, there's no default sort as of current. I don't think there should be one, either. At least, I personally don't care about it, and it takes only one or two clicks anyway.
Author
Owner

@alex-phillips commented on GitHub (Apr 14, 2016):

What if we made it a 'sticky' setting and saved the last sort in a cookie which was restored the next time the page is loaded? I feel that would be a good compromise and the setting could even be disabled if desired?

<!-- gh-comment-id:209926817 --> @alex-phillips commented on GitHub (Apr 14, 2016): What if we made it a 'sticky' setting and saved the last sort in a cookie which was restored the next time the page is loaded? I feel that would be a good compromise and the setting could even be disabled if desired?
Author
Owner

@phanan commented on GitHub (Apr 14, 2016):

Saving the last state is not a bad idea, however we'll need one state for every song-list screen (all songs, favorites, and every playlist). That could look ugly very quickly IMO.

<!-- gh-comment-id:209982398 --> @phanan commented on GitHub (Apr 14, 2016): Saving the last state is not a bad idea, however we'll need one state for every song-list screen (all songs, favorites, and every playlist). That could look ugly very quickly IMO.
Author
Owner

@alex-phillips commented on GitHub (Apr 14, 2016):

What if we only applied it to 'song list' view (the main ones). This would cover when your viewing songs, whether it be on the artist page, album page, or 'all songs' and only apply it to those views? Those are the only views that would by default actually matter what order the songs are referred to in. I don't think you'd want the software to auto-sort your favorites or playlists since you'd create those yourself.

I'd be happy to take this on and submit a PR, but I'd rather not do the work without discussing with you on how it should be done and actually get merged in. Otherwise it'd be wasted time :-P.

<!-- gh-comment-id:209989164 --> @alex-phillips commented on GitHub (Apr 14, 2016): What if we only applied it to 'song list' view (the main ones). This would cover when your viewing songs, whether it be on the artist page, album page, or 'all songs' and only apply it to those views? Those are the only views that would by default actually matter what order the songs are referred to in. I don't think you'd want the software to auto-sort your favorites or playlists since you'd create those yourself. I'd be happy to take this on and submit a PR, but I'd rather not do the work without discussing with you on how it should be done and actually get merged in. Otherwise it'd be wasted time :-P.
Author
Owner

@phanan commented on GitHub (Apr 14, 2016):

Let's go for All Songs, Artist, Album, and Favorites then. I agree that we
don't need to cater for custom playlists.
And of course, a PR would be much appreciated!

On Thu, Apr 14, 2016 at 11:04 PM, Alex Phillips notifications@github.com
wrote:

What if we only applied it to 'song list' view (the main ones). This would
cover when your viewing songs, whether it be on the artist page, album
page, or 'all songs' and only apply it to those views? Those are the only
views that would by default actually matter what order the songs are
referred to in. I don't think you'd want the software to auto-sort your
favorites or playlists since you'd create those yourself.

I'd be happy to take this on and submit a PR, but I'd rather not do the
work without discussing with you on how it should be done and actually get
merged in. Otherwise it'd be wasted time :-P.


You are receiving this because you commented.
Reply to this email directly or view it on GitHub
https://github.com/phanan/koel/issues/299#issuecomment-209989164

<!-- gh-comment-id:209990916 --> @phanan commented on GitHub (Apr 14, 2016): Let's go for All Songs, Artist, Album, and Favorites then. I agree that we don't need to cater for custom playlists. And of course, a PR would be much appreciated! On Thu, Apr 14, 2016 at 11:04 PM, Alex Phillips notifications@github.com wrote: > What if we only applied it to 'song list' view (the main ones). This would > cover when your viewing songs, whether it be on the artist page, album > page, or 'all songs' and only apply it to those views? Those are the only > views that would by default actually matter what order the songs are > referred to in. I don't think you'd want the software to auto-sort your > favorites or playlists since you'd create those yourself. > > I'd be happy to take this on and submit a PR, but I'd rather not do the > work without discussing with you on how it should be done and actually get > merged in. Otherwise it'd be wasted time :-P. > > — > You are receiving this because you commented. > Reply to this email directly or view it on GitHub > https://github.com/phanan/koel/issues/299#issuecomment-209989164
Author
Owner

@alex-phillips commented on GitHub (Apr 14, 2016):

I'll get to it. Thanks for the insight!

<!-- gh-comment-id:210013303 --> @alex-phillips commented on GitHub (Apr 14, 2016): I'll get to it. Thanks for the insight!
Author
Owner

@alex-phillips commented on GitHub (Apr 14, 2016):

@phanan What's the best way to determine the current view inside of the song-list.vue file? I can't tell anywhere we're currently checking a state like that except the extras components.

<!-- gh-comment-id:210144684 --> @alex-phillips commented on GitHub (Apr 14, 2016): @phanan What's the best way to determine the current `view` inside of the `song-list.vue` file? I can't tell anywhere we're currently checking a state like that except the extras components.
Author
Owner

@phanan commented on GitHub (Apr 15, 2016):

It's the type prop.

<!-- gh-comment-id:210225357 --> @phanan commented on GitHub (Apr 15, 2016): It's the `type` prop.
Author
Owner

@alex-phillips commented on GitHub (Apr 15, 2016):

So it's up to you how to approach this, and both ways are extremely easy.

  1. We could have a sticky sort setting that is only saved / applied on allSongs, artists, and album view (and a couple others?)
  2. We have a sticky setting for every view type. This would be extremely simple as you just prefix the ls key with this.type and apply it as long as that key exists. This would make each individual view have its own sticky sorting.

Code examples below:

data() {
  let sortKey = '';
  let order = 1;
  switch (this.type) {
    case 'allSongs':
    case 'album':
    case 'artist':
      sortKey = ls.get('sort-key', false) ? ls.get('sort-key') : '';
      order = ls.get('sort-order', false) ? ls.get('sort-order') : 1;
      break;
  }

  return {
    lastSelectedRow: null,
    q: '', // The filter query
    sortKey: ls.get(`${this.type}.sort-key`, false) ? ls.get(`${this.type}.sort-key`) : '',
    order: ls.get(`${this.type}.sort-order`, false) ? ls.get(`${this.type}.sort-order`) : 1,
    componentCache: {},
    sortingByAlbum: false,
    sortingByArtist: false,
  };
},
data() {
  return {
    lastSelectedRow: null,
    q: '', // The filter query
    sortKey: ls.get(`${this.type}.sort-key`, false) ? ls.get(`${this.type}.sort-key`) : '',
    order: ls.get(`${this.type}.sort-order`, false) ? ls.get(`${this.type}.sort-order`) : 1,
    componentCache: {},
    sortingByAlbum: false,
    sortingByArtist: false,
  };
},

Obviously these code snippets are only the portions where we apply the saved value, but the setting would be quite similar.

<!-- gh-comment-id:210451520 --> @alex-phillips commented on GitHub (Apr 15, 2016): So it's up to you how to approach this, and both ways are extremely easy. 1. We could have a sticky sort setting that is only saved / applied on `allSongs`, `artists,` and `album` view (and a couple others?) 2. We have a sticky setting for every view type. This would be extremely simple as you just prefix the `ls` key with `this.type` and apply it as long as that key exists. This would make each individual view have its own sticky sorting. Code examples below: 1. ``` data() { let sortKey = ''; let order = 1; switch (this.type) { case 'allSongs': case 'album': case 'artist': sortKey = ls.get('sort-key', false) ? ls.get('sort-key') : ''; order = ls.get('sort-order', false) ? ls.get('sort-order') : 1; break; } return { lastSelectedRow: null, q: '', // The filter query sortKey: ls.get(`${this.type}.sort-key`, false) ? ls.get(`${this.type}.sort-key`) : '', order: ls.get(`${this.type}.sort-order`, false) ? ls.get(`${this.type}.sort-order`) : 1, componentCache: {}, sortingByAlbum: false, sortingByArtist: false, }; }, ``` 2. ``` data() { return { lastSelectedRow: null, q: '', // The filter query sortKey: ls.get(`${this.type}.sort-key`, false) ? ls.get(`${this.type}.sort-key`) : '', order: ls.get(`${this.type}.sort-order`, false) ? ls.get(`${this.type}.sort-order`) : 1, componentCache: {}, sortingByAlbum: false, sortingByArtist: false, }; }, ``` Obviously these code snippets are only the portions where we apply the saved value, but the setting would be quite similar.
Author
Owner

@phanan commented on GitHub (Apr 15, 2016):

I would go with 2, but with a different approach: preferenceStore.state.sorts.

state: {
    volume: 7,
    notify: true,
    repeatMode: 'NO_REPEAT',
    showExtraPanel: true,
    confirmClosing: false,
    equalizer: {
        preamp: 0,
        gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    },
    artistsViewMode: null,
    albumsViewMode: null,
    selectedPreset: -1,
    sorts: [{ queue: { key: '', order: 1 } }], // also: allSongs, favorites, playlist, artist, album
},

This looks more structural and cleaner IMHO.

<!-- gh-comment-id:210463579 --> @phanan commented on GitHub (Apr 15, 2016): I would go with 2, but with a different approach: `preferenceStore.state.sorts`. ``` state: { volume: 7, notify: true, repeatMode: 'NO_REPEAT', showExtraPanel: true, confirmClosing: false, equalizer: { preamp: 0, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, artistsViewMode: null, albumsViewMode: null, selectedPreset: -1, sorts: [{ queue: { key: '', order: 1 } }], // also: allSongs, favorites, playlist, artist, album }, ``` This looks more structural and cleaner IMHO.
Author
Owner

@alex-phillips commented on GitHub (Apr 15, 2016):

Would it be smarter to have the type be the key for an object containing the information rather than an array of objects? That way you wouldn't have to iterate through the array but just check if the property exists:

state: {
    volume: 7,
    notify: true,
    repeatMode: 'NO_REPEAT',
    showExtraPanel: true,
    confirmClosing: false,
    equalizer: {
        preamp: 0,
        gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    },
    artistsViewMode: null,
    albumsViewMode: null,
    selectedPreset: -1,
    sorts: {"queue":{"key":"","order":1},"allSongs":{"key":"","order":1}}, // also: allSongs, favorites, playlist, artist, album
},
<!-- gh-comment-id:210465501 --> @alex-phillips commented on GitHub (Apr 15, 2016): Would it be smarter to have the `type` be the key for an object containing the information rather than an array of objects? That way you wouldn't have to iterate through the array but just check if the property exists: ``` state: { volume: 7, notify: true, repeatMode: 'NO_REPEAT', showExtraPanel: true, confirmClosing: false, equalizer: { preamp: 0, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, artistsViewMode: null, albumsViewMode: null, selectedPreset: -1, sorts: {"queue":{"key":"","order":1},"allSongs":{"key":"","order":1}}, // also: allSongs, favorites, playlist, artist, album }, ```
Author
Owner

@phanan commented on GitHub (Apr 15, 2016):

Yes of course I meant an object :P

<!-- gh-comment-id:210465860 --> @phanan commented on GitHub (Apr 15, 2016): Yes of course I meant an object :P
Author
Owner

@alex-phillips commented on GitHub (Apr 15, 2016):

Great, just clarifying. I'll have a PR for you sometime today.

<!-- gh-comment-id:210466025 --> @alex-phillips commented on GitHub (Apr 15, 2016): Great, just clarifying. I'll have a PR for you sometime today.
Author
Owner

@alex-phillips commented on GitHub (Apr 15, 2016):

I'm running into an issue calling .set on the preferences object with a nested key (i.e., sorts.allSongs). Is there a good way to save a nested value in preferences? Or should I call .get('sorts'), edit what I need to, and then just save sorts back to preferences?

Or should I add support for calling set with dot-notation strings?

<!-- gh-comment-id:210475605 --> @alex-phillips commented on GitHub (Apr 15, 2016): I'm running into an issue calling `.set` on the preferences object with a nested key (i.e., `sorts.allSongs`). Is there a good way to save a nested value in preferences? Or should I call `.get('sorts')`, edit what I need to, and then just save `sorts` back to preferences? Or should I add support for calling `set` with dot-notation strings?
Author
Owner

@phanan commented on GitHub (Apr 15, 2016):

.set for nested keys isn't supported (because there wasn't a need for it), so you can do either :P

<!-- gh-comment-id:210476121 --> @phanan commented on GitHub (Apr 15, 2016): `.set` for nested keys isn't supported (because there wasn't a need for it), so you can do either :P
Author
Owner

@alex-phillips commented on GitHub (Apr 15, 2016):

It looks like I'm able to set the sorts into the preferences just fine (when I open the resources tab I can inspect the local storage object and see the value there), but if I call preferences.get('sorts') it returns an empty object.

Do I have to initialize preferences at all before using it? Maybe I'm attempting to use the preferences object too early?

<!-- gh-comment-id:210507892 --> @alex-phillips commented on GitHub (Apr 15, 2016): It looks like I'm able to set the sorts into the preferences just fine (when I open the resources tab I can inspect the local storage object and see the value there), but if I call `preferences.get('sorts')` it returns an empty object. Do I have to initialize preferences at all before using it? Maybe I'm attempting to use the preferences object too early?
Author
Owner

@alex-phillips commented on GitHub (Apr 18, 2016):

@phanan any thoughts on this? If I use the example snippets above (with prefs instead of ls), when the page loads, it never retrieves the data I see stored in local storage.

<!-- gh-comment-id:211341409 --> @alex-phillips commented on GitHub (Apr 18, 2016): @phanan any thoughts on this? If I use the example snippets above (with prefs instead of ls), when the page loads, it never retrieves the data I see stored in local storage.
Author
Owner

@phanan commented on GitHub (Apr 18, 2016):

Oops sorry, this slipped my mind. Can you post the whole code?

<!-- gh-comment-id:211355786 --> @phanan commented on GitHub (Apr 18, 2016): Oops sorry, this slipped my mind. Can you post the whole code?
Author
Owner

@alex-phillips commented on GitHub (Apr 18, 2016):

preferences.js

import { extend, has, each } from 'lodash';

import userStore from './user';
import ls from '../services/ls';

export default {
    storeKey: '',

    state: {
        volume: 7,
        notify: true,
        repeatMode: 'NO_REPEAT',
        showExtraPanel: true,
        confirmClosing: false,
        equalizer: {
            preamp: 0,
            gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        },
        artistsViewMode: null,
        albumsViewMode: null,
        sorts: {
            'allSongs': {
                'key': '',
                'order': 1,
            },
        },
    },

    /**
     * Init the store.
     *
     * @param  {Object} user The user whose preferences we are managing.
     */
    init(user = null) {
        if (!user) {
            user = userStore.current;
        }

        this.storeKey = `preferences_${user.id}`;
        extend(this.state, ls.get(this.storeKey, this.state));
        this.setupProxy();
    },

    /**
     * Proxy the state properties, so that each can be directly accessed using the key.
     */
    setupProxy() {
        each(Object.keys(this.state), key => {
            Object.defineProperty(this, key, {
                get: () => this.state[key],
                set: (value) => {
                    this.state[key] = value;
                    this.save();
                },
                configurable: true,
            });
        });
    },

    set(key, val) {
        let keys = key.split('.');
        let xary = this.state;
        while (key = keys.shift()) {
            if (keys.length === 0) {
                xary[key] = val;
                break;
            }

            if (!has(xary, key)) {
                xary[key] = {};
            }

            xary = xary[key];
        }

        this.save();
    },

    get(path, defaultVal) {
        if (defaultVal === undefined) {
            defaultVal = null;
        }

        let retval = this.state;

        path = path.split('.');
        for (let i = 0; i < path.length; i++) {
            if (!has(retval, path[i])) {
                return defaultVal;
            }

            retval = retval[path[i]];
        }

        return retval;
    },

    save() {
        ls.set(this.storeKey, this.state);
    },
};

song-list.vue

<template>
    <div class="song-list-wrap main-scroll-wrap {{ type }}"
        v-el:wrapper
        tabindex="1"
        @scroll="scrolling"
        @keydown.delete.prevent.stop="handleDelete"
        @keydown.enter.prevent.stop="handleEnter"
        @keydown.a.prevent="handleA"
    >
        <table v-show="items.length">
            <thead>
                <tr>
                    <th @click="sort('track')" class="track-number">#
                        <i class="fa fa-angle-down" v-show="sortKey === 'track' && order > 0"></i>
                        <i class="fa fa-angle-up" v-show="sortKey === 'track' && order < 0"></i>
                    </th>
                    <th @click="sort('title')">Title
                        <i class="fa fa-angle-down" v-show="sortKey === 'title' && order > 0"></i>
                        <i class="fa fa-angle-up" v-show="sortKey === 'title' && order < 0"></i>
                    </th>
                    <th @click="sort(['album.artist.name', 'album.name', 'track'])">Artist
                        <i class="fa fa-angle-down" v-show="sortingByArtist && order > 0"></i>
                        <i class="fa fa-angle-up" v-show="sortingByArtist && order < 0"></i>
                    </th>
                    <th @click="sort(['album.name', 'track'])">Album
                        <i class="fa fa-angle-down" v-show="sortingByAlbum && order > 0"></i>
                        <i class="fa fa-angle-up" v-show="sortingByAlbum && order < 0"></i>
                    </th>
                    <th @click="sort('fmtLength')" class="time">Time
                        <i class="fa fa-angle-down" v-show="sortKey === 'fmtLength' && order > 0"></i>
                        <i class="fa fa-angle-up" v-show="sortKey === 'fmtLength' && order < 0"></i>
                    </th>
                    <th class="play"></th>
                </tr>
            </thead>

            <tbody>
                <tr
                    v-for="item in items
                        | caseInsensitiveOrderBy sortKey order
                        | filterSongBy q
                        | limitBy numOfItems"
                    is="song-item"
                    data-track="{{ item.track }}"
                    data-song-id="{{ item.id }}"
                    track-by="id"
                    :song="item"
                    v-ref:rows
                    @click="rowClick(item.id, $event)"
                    draggable="true"
                    @dragstart="dragStart(item.id, $event)"
                    @dragleave="removeDroppableState"
                    @dragover.prevent="allowDrop(item.id, $event)"
                    @drop.stop.prevent="handleDrop(item.id, $event)"
                    @contextmenu.prevent="openContextMenu(item.id, $event)"
                >
                </tr>
            </tbody>
        </table>

        <song-menu v-ref:context-menu :songs="selectedSongs"></song-menu>
        <to-top-button :showing="showBackToTop"></to-top-button>
    </div>
</template>

<script>
    import { find, invokeMap, filter, map } from 'lodash';
    import isMobile from 'ismobilejs';
    import $ from 'jquery';

    import songItem from './song-item.vue';
    import songMenu from './song-menu.vue';
    import infiniteScroll from '../../mixins/infinite-scroll';
    import preferences from '../../stores/preference';
    import playlistStore from '../../stores/playlist';
    import queueStore from '../../stores/queue';
    import songStore from '../../stores/song';
    import favoriteStore from '../../stores/favorite';
    import playback from '../../services/playback';

    export default {
        props: ['items', 'type', 'playlist', 'selectedSongs', 'sortable'],
        mixins: [infiniteScroll],
        components: { songItem, songMenu },

        data() {
            console.log('reading sort pref');
            console.log(preferences.get('sorts'));

            return {
                lastSelectedRow: null,
                q: '', // The filter query
                sortKey: preferences.get(`sorts.${this.type}.key`, ''),
                order: preferences.get(`sorts.${this.type}.order`, 1),
                componentCache: {},
                sortingByAlbum: false,
                sortingByArtist: false,
            };
        },

        watch: {
            /**
             * Watch the items.
             */
            items() {
                if (this.sortable === false) {
                    this.sortKey = '';
                }

                // Dispatch this event for the parent to update the song count and duration status.
                this.$dispatch('songlist:changed', {
                    songCount: this.items.length,
                    totalLength: songStore.getLength(this.items, true),
                });
            },
        },

        methods: {
            /**
             * Handle sorting the song list.
             *
             * @param  {String} key The sort key. Can be 'title', 'album', 'artist', or 'fmtLength'
             */
            sort(key) {
                if (this.sortable === false) {
                    return;
                }

                this.sortKey = key;
                this.order = 0 - this.order;
                this.sortingByAlbum = Array.isArray(this.sortKey) && this.sortKey[0] === 'album.name';
                this.sortingByArtist = Array.isArray(this.sortKey) && this.sortKey[0] === 'album.artist.name';
                preferences.set('sorts', this.sortKey);
            },

            /**
             * Execute the corresponding reaction(s) when the user presses Delete.
             */
            handleDelete() {
                const songs = this.selectedSongs;

                if (!songs.length) {
                    return;
                }

                switch (this.type) {
                    case 'queue':
                        queueStore.unqueue(songs);
                        break;
                    case 'favorites':
                        favoriteStore.unlike(songs);
                        break;
                    case 'playlist':
                        playlistStore.removeSongs(this.playlist, songs);
                        break;
                    default:
                        break;
                }

                this.clearSelection();
            },

            /**
             * Execute the corresponding reaction(s) when the user presses Enter.
             *
             * @param {Object} e The keydown event.
             */
            handleEnter(e) {
                const songs = this.selectedSongs;

                if (!songs.length) {
                    return;
                }

                if (songs.length === 1) {
                    // Just play the song
                    playback.play(songs[0]);

                    return;
                }

                switch (this.type) {
                    case 'queue':
                        // Play the first song selected if we're in Queue screen.
                        playback.play(songs[0]);
                        break;
                    case 'favorites':
                    case 'playlist':
                    default:
                        //
                        // --------------------------------------------------------------------
                        // For other screens, follow this map:
                        //
                        //  • Enter: Queue songs to bottom
                        //  • Shift+Enter: Queues song to top
                        //  • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song
                        //  • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song
                        //
                        // Also, if there's only one song selected, play it right away.
                        // --------------------------------------------------------------------
                        //
                        queueStore.queue(songs, false, e.shiftKey);

                        this.$nextTick(() => {
                            this.$root.loadMainView('queue');

                            if (e.ctrlKey || e.metaKey || songs.length === 1) {
                                playback.play(songs[0]);
                            }
                        });

                        break;
                }

                this.clearSelection();
            },

            /**
             * Get the song-item component that's associated with a song ID.
             *
             * @param  {String} id The song ID.
             *
             * @return {Object}    The Vue compoenent
             */
            getComponentBySongId(id) {
                // A Vue component can be removed (as a result of filter for example), so we check for its $el as well.
                if (!this.componentCache[id] || !this.componentCache[id].$el) {
                    this.componentCache[id] = find(this.$refs.rows, { song: { id } });
                }

                return this.componentCache[id];
            },

            /**
             * Capture A keydown event and select all if applicable.
             *
             * @param {Object} e The keydown event.
             */
            handleA(e) {
                if (!e.metaKey && !e.ctrlKey) {
                    return;
                }

                invokeMap(this.$refs.rows, 'select');
                this.gatherSelected();
            },

            /**
             * Gather all selected songs.
             *
             * @return {Array.<Object>} An array of Song objects
             */
            gatherSelected() {
                const selectedRows = filter(this.$refs.rows, { selected: true });
                const ids = map(selectedRows, row => row.song.id);

                this.selectedSongs = songStore.byIds(ids);
            },

            /**
             * -----------------------------------------------------------
             * The next four methods are to deal with selection.
             *
             * Credits: http://stackoverflow.com/a/17966381/794641 by andyb
             * -----------------------------------------------------------
             */

            /**
             * Handle the click event on a row to perform selection.
             *
             * @param  {String} songId
             * @param  {Object} e
             */
            rowClick(songId, e) {
                const row = this.getComponentBySongId(songId);

                // If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
                if (isMobile.any) {
                    this.toggleRow(row);
                    this.gatherSelected();

                    return;
                }

                if (e.ctrlKey || e.metaKey) {
                    this.toggleRow(row);
                }

                if (e.button === 0) {
                    if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
                        this.clearSelection();
                        this.toggleRow(row);
                    }

                    if (e.shiftKey && this.lastSelectedRow && this.lastSelectedRow.$el) {
                        this.selectRowsBetweenIndexes([this.lastSelectedRow.$el.rowIndex, row.$el.rowIndex]);
                    }
                }

                this.gatherSelected();
            },

            /**
             * Toggle select/unslect a row.
             *
             * @param  {Object} row The song-item component
             */
            toggleRow(row) {
                row.toggleSelectedState();
                this.lastSelectedRow = row;
            },

            selectRowsBetweenIndexes(indexes) {
                indexes.sort((a, b) => a - b);

                const rows = $(this.$els.wrapper).find('tbody tr');

                for (let i = indexes[0]; i <= indexes[1]; ++i) {
                    this.getComponentBySongId($(rows[i - 1]).data('song-id')).select();
                }
            },

            /**
             * Clear the current selection on this song list.
             */
            clearSelection() {
                invokeMap(this.$refs.rows, 'deselect');
                this.gatherSelected();
            },

            /**
             * Enable dragging songs by capturing the dragstart event on a table row.
             * Even though the event is triggered on one row only, we'll collect other
             * selected rows, if any, as well.
             *
             * @param {Object} e The event.
             */
            dragStart(songId, e) {
                // If the user is dragging an unselected row, clear the current selection.
                const currentRow = this.getComponentBySongId(songId);
                if (!currentRow.selected) {
                    this.clearSelection();
                    currentRow.select();
                    this.gatherSelected();
                }

                this.$nextTick(() => {
                    // We can opt for something like application/x-koel.text+plain here to sound fancy,
                    // but forget it.
                    const songIds = map(this.selectedSongs, 'id');
                    e.dataTransfer.setData('text/plain', songIds);
                    e.dataTransfer.effectAllowed = 'move';

                    // Set a fancy drop image using our ghost element.
                    const $ghost = $('#dragGhost').text(`${songIds.length} song${songIds.length === 1 ? '' : 's'}`);
                    e.dataTransfer.setDragImage($ghost[0], 0, 0);
                });
            },

            /**
             * Add a "droppable" class and set the drop effect when other songs are dragged over a row.
             *
             * @param {String} songId
             * @param {Object} e The dragover event.
             */
            allowDrop(songId, e) {
                if (this.type !== 'queue') {
                    return;
                }

                $(e.target).parents('tr').addClass('droppable');
                e.dataTransfer.dropEffect = 'move';

                return false;
            },

            /**
             * Perform reordering songs upon dropping if the current song list is of type Queue.
             *
             * @param  {String} songId
             * @param  {Object} e
             */
            handleDrop(songId, e) {
                if (this.type !== 'queue') {
                    return;
                }

                if (!e.dataTransfer.getData('text/plain')) {
                    return false;
                }

                const songs = this.selectedSongs;

                if (!songs.length) {
                    return false;
                }

                queueStore.move(songs, songStore.byId(songId));

                return false;
            },

            /**
             * Remove the droppable state (and the styles) from a row.
             *
             * @param  {Object} e
             */
            removeDroppableState(e) {
                return $(e.target).parents('tr').removeClass('droppable');
            },

            openContextMenu(songId, e) {
                // If the user is right-clicking an unselected row,
                // clear the current selection and select it instead.
                const currentRow = this.getComponentBySongId(songId);
                if (!currentRow.selected) {
                    this.clearSelection();
                    currentRow.select();
                    this.gatherSelected();
                }

                this.$nextTick(() => {
                    this.$refs.contextMenu.open(e.pageY, e.pageX);
                });
            },
        },

        events: {
            /**
             * Listen to song:played event to do some logic.
             *
             * @param  {Object} song The current playing song.
             */
            'song:played': function (song) {
                // If the song is at the end of the current displayed items, load more.
                if (this.type === 'queue' && this.items.indexOf(song) >= this.numOfItems) {
                    this.displayMore();
                }

                // Scroll the item into view if it's lost into oblivion.
                if (this.type === 'queue') {
                    const $wrapper = $(this.$els.wrapper);
                    const $row = $wrapper.find(`.song-item[data-song-id="${song.id}"]`);

                    if (!$row.length) {
                        return;
                    }

                    if ($wrapper[0].getBoundingClientRect().top + $wrapper[0].getBoundingClientRect().height <
                        $row[0].getBoundingClientRect().top) {
                        $wrapper.scrollTop($wrapper.scrollTop() + $row.position().top);
                    }
                }

                return true;
            },

            /**
             * Listen to 'filter:changed' event to filter the current list.
             */
            'filter:changed': function (q) {
                this.q = q;
            },

            /**
             * Clears the current list's selection if the user has switched to another view.
             */
            'main-content-view:load': function () {
                this.clearSelection();
            },

            /**
             * Listens to the 'song:selection-changed' dispatched from a child song-item
             * to collect the selected songs.
             */
            'song:selection-changed': function () {
                this.gatherSelected();
            },

            /**
             * Listen to 'song:selection-clear' (often broadcasted from the direct parent)
             * to clear the selected songs.
             */
            'song:selection-clear': function () {
                this.clearSelection();
            },
        },
    };
</script>

<style lang="sass">
    @import "../../../sass/partials/_vars.scss";
    @import "../../../sass/partials/_mixins.scss";

    .song-list-wrap {
        position: relative;

        table {
            width: 100%;
        }

        tr.droppable {
            border-bottom-width: 3px;
            border-bottom-color: $colorGreen;
        }

        td, th {
            text-align: left;
            padding: 8px;
            vertical-align: middle;

            &.time {
                width: 72px;
                text-align: right;
            }

            &.track-number {
                min-width: 42px;
            }

            &.play {
                display: none;

                html.touchevents & {
                    display: block;
                    position: absolute;
                    top: 8px;
                    right: 4px;
                }
            }
        }

        th {
            color: $color2ndText;
            letter-spacing: 1px;
            text-transform: uppercase;
            cursor: pointer;

            i {
                color: $colorHighlight;
                font-size: 120%;
            }
        }

        /**
         * Since the Queue screen doesn't allow sorting, we reset the cursor style.
         */
        &.queue th {
            cursor: default;
        }


        @media only screen and (max-width: 768px) {
            table, tbody, tr {
                display: block;
            }

            thead, tfoot {
                display: none;
            }

            tr {
                padding: 8px 32px 8px 4px;
                position: relative;
            }

            td {
                display: inline;
                padding: 0;
                vertical-align: bottom;

                &.album, &.time, &.track-number {
                    display: none;
                }

                &.artist {
                    opacity: .5;
                    font-size: 90%;
                    padding: 0 4px;
                }
            }
        }
    }
</style>

Note: if you don't include my new get and set methods and use the other method described above, the outcome is the same. The value is set, but never retrieved when the page loads.

<!-- gh-comment-id:211358419 --> @alex-phillips commented on GitHub (Apr 18, 2016): preferences.js ``` javascript import { extend, has, each } from 'lodash'; import userStore from './user'; import ls from '../services/ls'; export default { storeKey: '', state: { volume: 7, notify: true, repeatMode: 'NO_REPEAT', showExtraPanel: true, confirmClosing: false, equalizer: { preamp: 0, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], }, artistsViewMode: null, albumsViewMode: null, sorts: { 'allSongs': { 'key': '', 'order': 1, }, }, }, /** * Init the store. * * @param {Object} user The user whose preferences we are managing. */ init(user = null) { if (!user) { user = userStore.current; } this.storeKey = `preferences_${user.id}`; extend(this.state, ls.get(this.storeKey, this.state)); this.setupProxy(); }, /** * Proxy the state properties, so that each can be directly accessed using the key. */ setupProxy() { each(Object.keys(this.state), key => { Object.defineProperty(this, key, { get: () => this.state[key], set: (value) => { this.state[key] = value; this.save(); }, configurable: true, }); }); }, set(key, val) { let keys = key.split('.'); let xary = this.state; while (key = keys.shift()) { if (keys.length === 0) { xary[key] = val; break; } if (!has(xary, key)) { xary[key] = {}; } xary = xary[key]; } this.save(); }, get(path, defaultVal) { if (defaultVal === undefined) { defaultVal = null; } let retval = this.state; path = path.split('.'); for (let i = 0; i < path.length; i++) { if (!has(retval, path[i])) { return defaultVal; } retval = retval[path[i]]; } return retval; }, save() { ls.set(this.storeKey, this.state); }, }; ``` song-list.vue ``` javascript <template> <div class="song-list-wrap main-scroll-wrap {{ type }}" v-el:wrapper tabindex="1" @scroll="scrolling" @keydown.delete.prevent.stop="handleDelete" @keydown.enter.prevent.stop="handleEnter" @keydown.a.prevent="handleA" > <table v-show="items.length"> <thead> <tr> <th @click="sort('track')" class="track-number"># <i class="fa fa-angle-down" v-show="sortKey === 'track' && order > 0"></i> <i class="fa fa-angle-up" v-show="sortKey === 'track' && order < 0"></i> </th> <th @click="sort('title')">Title <i class="fa fa-angle-down" v-show="sortKey === 'title' && order > 0"></i> <i class="fa fa-angle-up" v-show="sortKey === 'title' && order < 0"></i> </th> <th @click="sort(['album.artist.name', 'album.name', 'track'])">Artist <i class="fa fa-angle-down" v-show="sortingByArtist && order > 0"></i> <i class="fa fa-angle-up" v-show="sortingByArtist && order < 0"></i> </th> <th @click="sort(['album.name', 'track'])">Album <i class="fa fa-angle-down" v-show="sortingByAlbum && order > 0"></i> <i class="fa fa-angle-up" v-show="sortingByAlbum && order < 0"></i> </th> <th @click="sort('fmtLength')" class="time">Time <i class="fa fa-angle-down" v-show="sortKey === 'fmtLength' && order > 0"></i> <i class="fa fa-angle-up" v-show="sortKey === 'fmtLength' && order < 0"></i> </th> <th class="play"></th> </tr> </thead> <tbody> <tr v-for="item in items | caseInsensitiveOrderBy sortKey order | filterSongBy q | limitBy numOfItems" is="song-item" data-track="{{ item.track }}" data-song-id="{{ item.id }}" track-by="id" :song="item" v-ref:rows @click="rowClick(item.id, $event)" draggable="true" @dragstart="dragStart(item.id, $event)" @dragleave="removeDroppableState" @dragover.prevent="allowDrop(item.id, $event)" @drop.stop.prevent="handleDrop(item.id, $event)" @contextmenu.prevent="openContextMenu(item.id, $event)" > </tr> </tbody> </table> <song-menu v-ref:context-menu :songs="selectedSongs"></song-menu> <to-top-button :showing="showBackToTop"></to-top-button> </div> </template> <script> import { find, invokeMap, filter, map } from 'lodash'; import isMobile from 'ismobilejs'; import $ from 'jquery'; import songItem from './song-item.vue'; import songMenu from './song-menu.vue'; import infiniteScroll from '../../mixins/infinite-scroll'; import preferences from '../../stores/preference'; import playlistStore from '../../stores/playlist'; import queueStore from '../../stores/queue'; import songStore from '../../stores/song'; import favoriteStore from '../../stores/favorite'; import playback from '../../services/playback'; export default { props: ['items', 'type', 'playlist', 'selectedSongs', 'sortable'], mixins: [infiniteScroll], components: { songItem, songMenu }, data() { console.log('reading sort pref'); console.log(preferences.get('sorts')); return { lastSelectedRow: null, q: '', // The filter query sortKey: preferences.get(`sorts.${this.type}.key`, ''), order: preferences.get(`sorts.${this.type}.order`, 1), componentCache: {}, sortingByAlbum: false, sortingByArtist: false, }; }, watch: { /** * Watch the items. */ items() { if (this.sortable === false) { this.sortKey = ''; } // Dispatch this event for the parent to update the song count and duration status. this.$dispatch('songlist:changed', { songCount: this.items.length, totalLength: songStore.getLength(this.items, true), }); }, }, methods: { /** * Handle sorting the song list. * * @param {String} key The sort key. Can be 'title', 'album', 'artist', or 'fmtLength' */ sort(key) { if (this.sortable === false) { return; } this.sortKey = key; this.order = 0 - this.order; this.sortingByAlbum = Array.isArray(this.sortKey) && this.sortKey[0] === 'album.name'; this.sortingByArtist = Array.isArray(this.sortKey) && this.sortKey[0] === 'album.artist.name'; preferences.set('sorts', this.sortKey); }, /** * Execute the corresponding reaction(s) when the user presses Delete. */ handleDelete() { const songs = this.selectedSongs; if (!songs.length) { return; } switch (this.type) { case 'queue': queueStore.unqueue(songs); break; case 'favorites': favoriteStore.unlike(songs); break; case 'playlist': playlistStore.removeSongs(this.playlist, songs); break; default: break; } this.clearSelection(); }, /** * Execute the corresponding reaction(s) when the user presses Enter. * * @param {Object} e The keydown event. */ handleEnter(e) { const songs = this.selectedSongs; if (!songs.length) { return; } if (songs.length === 1) { // Just play the song playback.play(songs[0]); return; } switch (this.type) { case 'queue': // Play the first song selected if we're in Queue screen. playback.play(songs[0]); break; case 'favorites': case 'playlist': default: // // -------------------------------------------------------------------- // For other screens, follow this map: // // • Enter: Queue songs to bottom // • Shift+Enter: Queues song to top // • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song // • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song // // Also, if there's only one song selected, play it right away. // -------------------------------------------------------------------- // queueStore.queue(songs, false, e.shiftKey); this.$nextTick(() => { this.$root.loadMainView('queue'); if (e.ctrlKey || e.metaKey || songs.length === 1) { playback.play(songs[0]); } }); break; } this.clearSelection(); }, /** * Get the song-item component that's associated with a song ID. * * @param {String} id The song ID. * * @return {Object} The Vue compoenent */ getComponentBySongId(id) { // A Vue component can be removed (as a result of filter for example), so we check for its $el as well. if (!this.componentCache[id] || !this.componentCache[id].$el) { this.componentCache[id] = find(this.$refs.rows, { song: { id } }); } return this.componentCache[id]; }, /** * Capture A keydown event and select all if applicable. * * @param {Object} e The keydown event. */ handleA(e) { if (!e.metaKey && !e.ctrlKey) { return; } invokeMap(this.$refs.rows, 'select'); this.gatherSelected(); }, /** * Gather all selected songs. * * @return {Array.<Object>} An array of Song objects */ gatherSelected() { const selectedRows = filter(this.$refs.rows, { selected: true }); const ids = map(selectedRows, row => row.song.id); this.selectedSongs = songStore.byIds(ids); }, /** * ----------------------------------------------------------- * The next four methods are to deal with selection. * * Credits: http://stackoverflow.com/a/17966381/794641 by andyb * ----------------------------------------------------------- */ /** * Handle the click event on a row to perform selection. * * @param {String} songId * @param {Object} e */ rowClick(songId, e) { const row = this.getComponentBySongId(songId); // If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection. if (isMobile.any) { this.toggleRow(row); this.gatherSelected(); return; } if (e.ctrlKey || e.metaKey) { this.toggleRow(row); } if (e.button === 0) { if (!e.ctrlKey && !e.metaKey && !e.shiftKey) { this.clearSelection(); this.toggleRow(row); } if (e.shiftKey && this.lastSelectedRow && this.lastSelectedRow.$el) { this.selectRowsBetweenIndexes([this.lastSelectedRow.$el.rowIndex, row.$el.rowIndex]); } } this.gatherSelected(); }, /** * Toggle select/unslect a row. * * @param {Object} row The song-item component */ toggleRow(row) { row.toggleSelectedState(); this.lastSelectedRow = row; }, selectRowsBetweenIndexes(indexes) { indexes.sort((a, b) => a - b); const rows = $(this.$els.wrapper).find('tbody tr'); for (let i = indexes[0]; i <= indexes[1]; ++i) { this.getComponentBySongId($(rows[i - 1]).data('song-id')).select(); } }, /** * Clear the current selection on this song list. */ clearSelection() { invokeMap(this.$refs.rows, 'deselect'); this.gatherSelected(); }, /** * Enable dragging songs by capturing the dragstart event on a table row. * Even though the event is triggered on one row only, we'll collect other * selected rows, if any, as well. * * @param {Object} e The event. */ dragStart(songId, e) { // If the user is dragging an unselected row, clear the current selection. const currentRow = this.getComponentBySongId(songId); if (!currentRow.selected) { this.clearSelection(); currentRow.select(); this.gatherSelected(); } this.$nextTick(() => { // We can opt for something like application/x-koel.text+plain here to sound fancy, // but forget it. const songIds = map(this.selectedSongs, 'id'); e.dataTransfer.setData('text/plain', songIds); e.dataTransfer.effectAllowed = 'move'; // Set a fancy drop image using our ghost element. const $ghost = $('#dragGhost').text(`${songIds.length} song${songIds.length === 1 ? '' : 's'}`); e.dataTransfer.setDragImage($ghost[0], 0, 0); }); }, /** * Add a "droppable" class and set the drop effect when other songs are dragged over a row. * * @param {String} songId * @param {Object} e The dragover event. */ allowDrop(songId, e) { if (this.type !== 'queue') { return; } $(e.target).parents('tr').addClass('droppable'); e.dataTransfer.dropEffect = 'move'; return false; }, /** * Perform reordering songs upon dropping if the current song list is of type Queue. * * @param {String} songId * @param {Object} e */ handleDrop(songId, e) { if (this.type !== 'queue') { return; } if (!e.dataTransfer.getData('text/plain')) { return false; } const songs = this.selectedSongs; if (!songs.length) { return false; } queueStore.move(songs, songStore.byId(songId)); return false; }, /** * Remove the droppable state (and the styles) from a row. * * @param {Object} e */ removeDroppableState(e) { return $(e.target).parents('tr').removeClass('droppable'); }, openContextMenu(songId, e) { // If the user is right-clicking an unselected row, // clear the current selection and select it instead. const currentRow = this.getComponentBySongId(songId); if (!currentRow.selected) { this.clearSelection(); currentRow.select(); this.gatherSelected(); } this.$nextTick(() => { this.$refs.contextMenu.open(e.pageY, e.pageX); }); }, }, events: { /** * Listen to song:played event to do some logic. * * @param {Object} song The current playing song. */ 'song:played': function (song) { // If the song is at the end of the current displayed items, load more. if (this.type === 'queue' && this.items.indexOf(song) >= this.numOfItems) { this.displayMore(); } // Scroll the item into view if it's lost into oblivion. if (this.type === 'queue') { const $wrapper = $(this.$els.wrapper); const $row = $wrapper.find(`.song-item[data-song-id="${song.id}"]`); if (!$row.length) { return; } if ($wrapper[0].getBoundingClientRect().top + $wrapper[0].getBoundingClientRect().height < $row[0].getBoundingClientRect().top) { $wrapper.scrollTop($wrapper.scrollTop() + $row.position().top); } } return true; }, /** * Listen to 'filter:changed' event to filter the current list. */ 'filter:changed': function (q) { this.q = q; }, /** * Clears the current list's selection if the user has switched to another view. */ 'main-content-view:load': function () { this.clearSelection(); }, /** * Listens to the 'song:selection-changed' dispatched from a child song-item * to collect the selected songs. */ 'song:selection-changed': function () { this.gatherSelected(); }, /** * Listen to 'song:selection-clear' (often broadcasted from the direct parent) * to clear the selected songs. */ 'song:selection-clear': function () { this.clearSelection(); }, }, }; </script> <style lang="sass"> @import "../../../sass/partials/_vars.scss"; @import "../../../sass/partials/_mixins.scss"; .song-list-wrap { position: relative; table { width: 100%; } tr.droppable { border-bottom-width: 3px; border-bottom-color: $colorGreen; } td, th { text-align: left; padding: 8px; vertical-align: middle; &.time { width: 72px; text-align: right; } &.track-number { min-width: 42px; } &.play { display: none; html.touchevents & { display: block; position: absolute; top: 8px; right: 4px; } } } th { color: $color2ndText; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; i { color: $colorHighlight; font-size: 120%; } } /** * Since the Queue screen doesn't allow sorting, we reset the cursor style. */ &.queue th { cursor: default; } @media only screen and (max-width: 768px) { table, tbody, tr { display: block; } thead, tfoot { display: none; } tr { padding: 8px 32px 8px 4px; position: relative; } td { display: inline; padding: 0; vertical-align: bottom; &.album, &.time, &.track-number { display: none; } &.artist { opacity: .5; font-size: 90%; padding: 0 4px; } } } } </style> ``` Note: if you don't include my new `get` and `set` methods and use the other method described above, the outcome is the same. The value is set, but never retrieved when the page loads.
Author
Owner

@phanan commented on GitHub (Apr 18, 2016):

Yes, you're calling the preferences too early. Preferences are bound to the user, and only initialized after the user has logged in. In your case, the song-list's data() is called prior to that, hence the empty value.

<!-- gh-comment-id:211437532 --> @phanan commented on GitHub (Apr 18, 2016): Yes, you're calling the preferences too early. Preferences are bound to the user, and only initialized after the user has logged in. In your case, the `song-list`'s `data()` is called prior to that, hence the empty value.
Author
Owner

@alex-phillips commented on GitHub (Apr 18, 2016):

So should I just use local storage here?

<!-- gh-comment-id:211439069 --> @alex-phillips commented on GitHub (Apr 18, 2016): So should I just use local storage here?
Author
Owner

@phanan commented on GitHub (Apr 18, 2016):

No, of course not! You can trying watching the property instead, or listen to koel:ready event.

<!-- gh-comment-id:211441935 --> @phanan commented on GitHub (Apr 18, 2016): No, of course not! You can trying watching the property instead, or listen to `koel:ready` event.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/koel-koel#219
No description provided.