├── .gitignore ├── INSTALL ├── LICENSE ├── Makefile ├── NEWS ├── README.md ├── TODO ├── build-ccl.lisp ├── build-cl-launch.sh ├── build-sbcl.lisp ├── extra └── web │ ├── package.lisp │ ├── static │ ├── dark-hive │ │ ├── images │ │ │ ├── ui-bg_flat_30_cccccc_40x100.png │ │ │ ├── ui-bg_flat_50_5c5c5c_40x100.png │ │ │ ├── ui-bg_glass_40_ffc73d_1x400.png │ │ │ ├── ui-bg_highlight-hard_20_0972a5_1x100.png │ │ │ ├── ui-bg_highlight-soft_33_003147_1x100.png │ │ │ ├── ui-bg_highlight-soft_35_222222_1x100.png │ │ │ ├── ui-bg_highlight-soft_44_444444_1x100.png │ │ │ ├── ui-bg_highlight-soft_80_eeeeee_1x100.png │ │ │ ├── ui-bg_loop_25_000000_21x21.png │ │ │ ├── ui-icons_222222_256x240.png │ │ │ ├── ui-icons_4b8e0b_256x240.png │ │ │ ├── ui-icons_a83300_256x240.png │ │ │ ├── ui-icons_cccccc_256x240.png │ │ │ └── ui-icons_ffffff_256x240.png │ │ └── jquery-ui.custom.css │ ├── jquery-ui.js │ ├── jquery.js │ ├── play.png │ ├── style.css │ └── ui.js │ └── www.lisp ├── id3-utf16.diff ├── img-search.png ├── load-ccl.lisp ├── shuffletron ├── shuffletron.asd └── src ├── alarms.lisp ├── audio.lisp ├── global.lisp ├── help.lisp ├── library.lisp ├── main.lisp ├── packages.lisp ├── profiles.lisp ├── query.lisp ├── status-bar.lisp ├── tags.lisp ├── ui.lisp └── util.lisp /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.fasl 3 | shuffletron-bin 4 | libs 5 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | 2 | ------------------------------------------------------------------------ 3 | 4 | To install a binary release: 5 | 6 | make install 7 | 8 | Ideally, install rlwrap (http://utopia.knoware.nl/~hlub/rlwrap/) as 9 | well. In Debian Linux, this is provided by the rlwrap package. 10 | 11 | Binary releases are compiled with SBCL. 12 | 13 | ------------------------------------------------------------------------ 14 | 15 | To compile from source: 16 | 17 | Compiling Shuffletron from source requires the following: 18 | 19 | SBCL (or CCL) 20 | CFFI 21 | Osicat (at least version 0.6.0) 22 | Mixalot (at least version 0.0.2) 23 | 24 | Run 'make', then 'sudo make install'. 25 | 26 | The default make target compiles the program using SBCL. If you prefer 27 | CCL, type 'make shuffletron-ccl' or 'make shuffletron-ccl64' instead 28 | (see "Support for Clozure Common Lisp" below). 29 | 30 | ------------------------------------------------------------------------ 31 | 32 | Issues with libmpg123 on 32-bit machines: 33 | 34 | There are various thorny issues with recent versions of libmpg123 35 | on 32-bit x86 machines. The binary releases of Shuffletron come with 36 | appropriately compiled versions of the library which sidestep these 37 | issues. If compiling from source, you have a few options: 38 | 39 | * Use an old version of the library (such as 1.4.3) 40 | * Use the libmixlaot-mpg123 from a binary release (you'll have to 41 | uncomment several lines in the Makefile to install it, when starting 42 | from a source tarball) 43 | * Compile (or recompile) mpg123 with the correct settings. 44 | 45 | When compiling mpg123, you will at a minimum have to configure it 46 | with the --disable-largefile flag. If you encounter errors, adding 47 | --disable-aligncheck with a CFLAGS= -mstackrealign *should* fix it 48 | (I'm not 100% certain, because I hacked my SBCL to work around the 49 | issue, and don't use 32-bit Linux heavily anyway). 50 | 51 | As of mpg123-1.8.1, there's a nasty UTF-16 decoding bug in its ID3 52 | parser that will crash the player on certain files. The version of the 53 | library included in the binary releases (libmixalot-mpg123) include a 54 | patch fixing this issue. This patch is included as id3-utf16.diff. 55 | 56 | ------------------------------------------------------------------------ 57 | 58 | Support for Clozure Common Lisp: 59 | 60 | Due to a bug in CCL version 1.3 converning save-application, binaries 61 | compiled with this version of CCL will not work. Shuffletron can 62 | instead be used with this version of CCL by invoking lisp as follows: 63 | 64 | ccl -n -l load-ccl.lisp 65 | or 66 | ccl64 -n -l load-ccl.lisp 67 | 68 | In SVN versions of CCL later than 04/29/09, binaries compiled with the 69 | shuffletron-ccl and shuffletron-ccl64 make targets should work perfectly. 70 | Note that the "shuffletron" wrapper script and the install target still 71 | assume the binary is named "shuffletron-bin". In order to install a 72 | version built with CCL, you'll have to rename the compiled executable to 73 | "shuffletron-bin". 74 | 75 | ------------------------------------------------------------------------ 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ;;;; Shuffletron, a music player. 2 | 3 | ;;;; Copyright (c) 2009,2010 Andy Hefner 4 | 5 | ;;;; Permission is hereby granted, free of charge, to any person obtaining 6 | ;;;; a copy of this software and associated documentation files (the 7 | ;;;; "Software"), to deal in the Software without restriction, including 8 | ;;;; without limitation the rights to use, copy, modify, merge, publish, 9 | ;;;; distribute, sublicense, and/or sellcopies of the Software, and to 10 | ;;;; permit persons to whom the Software is furnished to do so, subject 11 | ;;;; to the following conditions: 12 | 13 | ;;;; The above copyright notice and this permission notice shall be 14 | ;;;; included in all copies or substantial portions of the Software. 15 | 16 | ;;;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ;;;; EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | ;;;; OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | ;;;; NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 20 | ;;;; HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 21 | ;;;; WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 22 | ;;;; FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 23 | ;;;; OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SBCL?=sbcl 2 | CCL?=ccl 3 | CCL64?=ccl64 4 | 5 | # Note: If you edit the install prefix, you may also have to edit the 6 | # 'shuffletron' wrapper script so it can find its libraries. 7 | 8 | PREFIX=/usr/local 9 | 10 | # I preferred to build it with --no-userinit so as not to pull in 11 | # unspecified random junk, but this is the easiest way to make it work 12 | # with Quicklisp. 13 | 14 | # SBCLFLAGS=--noinform --no-userinit 15 | 16 | SBCLFLAGS?=--noinform 17 | 18 | export LD_LIBRARY_PATH=$(CURDIR)/libs/ 19 | 20 | all: shuffletron-bin 21 | 22 | tidy: 23 | rm -f *~ *.fasl *.lx*fsl \#*\# src/*~ src/*.fasl src/*.lx*fsl src/\#*\# 24 | 25 | clean: tidy 26 | rm -f shuffletron-bin shuffletron-ccl shuffletron-ccl64 27 | rm -f libs/gen* 28 | 29 | install: 30 | install -m 0755 shuffletron shuffletron-bin $(PREFIX)/bin 31 | mkdir -p $(PREFIX)/lib/shuffletron 32 | install -m 0755 libs/*.so* $(PREFIX)/lib/shuffletron 33 | 34 | .PHONY: install all clean distclean 35 | 36 | shuffletron-bin: build-sbcl.lisp src/*.lisp 37 | $(SBCL) $(SBCLFLAGS) --disable-debugger \ 38 | --eval "(require :asdf)" \ 39 | --eval "(load \"build-sbcl.lisp\")" 40 | 41 | shuffletron-ccl64: shuffletron.lisp build-ccl.lisp 42 | $(CCL64) -n -e "(require :asdf)" \ 43 | -e "(load \"build-ccl.lisp\")" 44 | 45 | shuffletron-ccl: shuffletron.lisp build-ccl.lisp 46 | $(CCL) -n -e "(require :asdf)" \ 47 | -e "(load \"build-ccl.lisp\")" 48 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | * Changes in Shuffletron 0.0.5 relative to Shuffletron 0.0.4: 2 | ** New feature: Mac OS X support 3 | ** New feature: 'next' command preserves current song in loop mode, while 'skip' discards it. 4 | ** New feature: 'ignore' tag hides files from library/queries. To reveal ignored files, do "tagged ignore". 5 | ** New feature: Multiple profile/library support, chosen from the command line. See --help for details. 6 | ** New feature: 'all' command adds whole selection to queue (synonym for "+0-") 7 | ** New feature: tagall/untagall commands, for applying tags to the entire selection. 8 | ** New feature: 'ls' command (alias for 'show') 9 | ** New feature: 'prescan' command toggles prescan of file before playback (needed for accurate seek, undesirably on huge files or slow filesystems) 10 | ** New feature: 'seek' command supports relative times via +/- prefix before the time 11 | ** New feature: Divergence of 'skip' versus 'next' commands within loop mode: 'next' enqueues the current song before continuing, 'skip' does not. 12 | ** Useful symbols are now exported from the SHUFFLETRON package, for the sake of hacking extensions. 13 | ** Fix printing of dates by the alarm feature. 14 | ** Fix directory scan on some OSes/filesystems (e.g. sshfs) where readdir doesn't provide a d_type. 15 | ** Random command changed to choose from within the current selection, not the whole library. 16 | ** Source code reorganization - no longer one huge sprawling file! 17 | 18 | * Changes in Shuffletron 0.0.4 relative to Shuffletron 0.0.3: 19 | ** Correct counting of file numbers while scanning library. 20 | ** Notes about building, and an issue with recent libmpg123 versions on 32-bit Intel. 21 | 22 | * Changes in Shuffletron 0.0.3 relative to Shuffletron 0.0.2: 23 | ** Binaries might really work this time 24 | ** New commands: killtag, qdrop, qtag, fromqueue, toqueue, random, stop, play 25 | 26 | * Shuffletron 0.0.2: 27 | ** First public release -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shuffletron is a music player based on search and tagging, with a terminal UI, for Linux, written in Common Lisp. 2 | 3 | 4 | Shuffletron is a text-mode music player oriented around search and 5 | tagging. Its principle of operation is simple: search for songs, then 6 | play them. 7 | 8 | ![](img-search.png) 9 | 10 | # Build 11 | 12 | First make the binary for your Lisp: 13 | 14 | make shuffletron-bin # sbcl 15 | # make shuffletron-ccl 16 | # make shuffletron-ccl64 17 | 18 | Install the binary and link the libraries: 19 | 20 | sudo make install 21 | 22 | Run it via the script: 23 | 24 | ./shuffletron 25 | 26 | ## Dependencies 27 | 28 | On Debian, you first need a Lisp implementation. In doubt, install SBCL: 29 | 30 | apt install sbcl 31 | 32 | You need those dependencies for Flac and Ogg support: 33 | 34 | apt install libflac-dev 35 | apt install libvorbis-dev 36 | 37 | Alternatively you could remove the related mixalot packages in the `shuffletron.asd`: 38 | 39 | :depends-on (:osicat :mixalot :mixalot-mp3 :mixalot-vorbis :mixalot-flac) 40 | 41 | # Usage 42 | 43 | Searches are performed by typing a `/` followed by the search string. 44 | 45 | If ID3 tags are present, songs after a search are presented in the following form: 46 | 47 | Artist, [Album,] [Track:] Title 48 | 49 | If ID3 information on the artist and title is not available, the 50 | filename is printed instead. 51 | 52 | In the leftmost column is some subset of the letters 'f', 'a', 'b', 53 | and 't'. These indicate which fields matched the query string, as 54 | follows: 55 | 56 | f: Filename 57 | a: Artist 58 | b: Album 59 | t: Title 60 | 61 | Successive searches refine the result of previous searches, and the 62 | prompt indicates the number of items you're currently searching 63 | within. If there had been more than 50 matches, they would not be 64 | printed by default, but you could use the `show` command at any time 65 | to print them. 66 | 67 | Following this is a column of numbers, starting from zero. These allow 68 | you to choose songs to play as comma (or space) delimited numbers or 69 | ranges of numbers. If the song is already in the queue, the number is 70 | highlighted in bold white text. Here, I decide to play song 8 then 0-3 71 | by entering this at the prompt: 72 | 73 | 9 matches> 8, 0-3 74 | 75 | The currently playing song is interrupted, and the chosen songs are 76 | added to the head of the playback queue. To see the contents of the 77 | queue, use the 'queue' command: 78 | 79 | ``` 80 | 9 matches> queue 81 | (0) Chromeo, She's In Control, 10: Ah Oui Comme Ca 82 | (1) "........................" 1: My And My Man 83 | (2) "........................" 2: Needy Girl 84 | (3) "........................" 3: You're So Gangsta 85 | ``` 86 | 87 | Notice that the prompt changed from "library>" to "9 matches>" 88 | after our initial search. Also note that the `queue` command 89 | doesn't disrupt the current search results (this is why numbering in 90 | the queue listing is surrounded with parentheses, to indicate that 91 | entering numbers for playback does not refer to them). 92 | 93 | To add songs to the queue without interrupting the current song, 94 | prefix the song list with `+` (to append) or `pre` (to prepend). 95 | 96 | The queue can be cleared with the `clear` command, and the `skip` 97 | command skips the current song and advances to the next song in the 98 | queue. The `next` command is similar, but differs when looping is 99 | enabled: 'next' retains the current song at the end of the queue so it 100 | will play again, 'skip' does not. 101 | 102 | The `loop` command toggles looping mode. In looping mode, songs are 103 | returned to the end of the queue when they finish playing, or when 104 | they are bypassed using the 'next' command. 105 | 106 | When you've completed a search, a single blank line moves backward 107 | through the search history, eventually returning to the "library>" 108 | prompt. 109 | 110 | If you've imported a large library, the ID3 tags may not have been 111 | scanned. In this case, the program will suggest that you run the 112 | scanid3 command. Scanning ID3 tags can be very time consuming, as 113 | each file must be opened and read from. Once scanned, ID3 information 114 | is remembered by caching it in the `~/.shuffletron/id3-cache` file, so 115 | you only need to do this the first time you run the program. ID3 tags 116 | of new files are scanned automatically when the program starts unless 117 | there are more than 1,000 new files. 118 | 119 | Additional help topics: 120 | 121 | help commands 122 | help examples 123 | help alarms 124 | 125 | # Licence 126 | 127 | MIT 128 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Features: 2 | * Playlists 3 | * Last.fm/Scrobbling Support 4 | 5 | Bugs: 6 | * Search markings (underlining) not getting cleared, also wrong after navigating history 7 | 8 | Other: 9 | * Docs update 10 | * Verify Ogg/FLAC support works on non-Linux platforms, conditionalize them if there are problems. 11 | -------------------------------------------------------------------------------- /build-ccl.lisp: -------------------------------------------------------------------------------- 1 | ;;;; Not sure where CCL users collect their systems. Some folks 2 | ;;;; set this up in their init file. If so, sorry! 3 | 4 | (push '(MERGE-PATHNAMES ".sbcl/systems/" (USER-HOMEDIR-PATHNAME)) 5 | asdf:*central-registry*) 6 | 7 | (trace ccl:close-shared-library) 8 | (trace ccl::shared-library-at) 9 | 10 | (push :shuffletron-deploy *features*) 11 | (load "shuffletron.asd") 12 | (asdf:oos 'asdf:compile-op :shuffletron) 13 | (asdf:oos 'asdf:load-op :shuffletron) 14 | 15 | (print ccl::*shared-libraries*) 16 | (trace cffi:close-foreign-library) 17 | 18 | (format t "Gathering generated objects:~%") 19 | (loop for library in (cffi:list-foreign-libraries :type :grovel-wrapper) 20 | for n upfrom 0 21 | as filename = (cffi:foreign-library-pathname library) 22 | as newname = (format nil "gen~D" n) 23 | do 24 | (format t "~D: ~A~%" n filename) 25 | (alexandria:copy-file filename 26 | (merge-pathnames 27 | (make-pathname :name newname 28 | :directory '(:relative "libs") 29 | :type "so") 30 | *load-pathname*)) 31 | (cffi:close-foreign-library library) 32 | (cffi:load-foreign-library (format nil "~A.so" newname))) 33 | 34 | (ccl:save-application #+x86-64 "shuffletron-ccl64" #-x86-64 "shuffletron-ccl" 35 | :toplevel-function #'shuffletron:run 36 | :mode #o755 37 | :prepend-kernel t) 38 | 39 | -------------------------------------------------------------------------------- /build-cl-launch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cl-launch -Q -s shuffletron -i "(shuffletron:run)" -v --output shuffletron-bin 3 | 4 | -------------------------------------------------------------------------------- /build-sbcl.lisp: -------------------------------------------------------------------------------- 1 | (in-package :cl-user) 2 | 3 | (push :shuffletron-deploy *features*) 4 | 5 | ;;; Xach says never to do this: 6 | (load "shuffletron.asd") 7 | ;;; (Sorry, Xach!) 8 | 9 | (asdf:oos 'asdf:compile-op :shuffletron) 10 | (asdf:oos 'asdf:load-op :shuffletron) 11 | 12 | ;;; Don't need this nonsense: 13 | #+linux (sb-alien:unload-shared-object "librt.so") 14 | #+linux (sb-alien:unload-shared-object "librt.so.1") 15 | 16 | ;;; Round up generated shared objects and put them in libs subdirectory: 17 | #+linux 18 | (let ((libdir (merge-pathnames 19 | (make-pathname :directory '(:relative "libs")) 20 | *load-pathname*))) 21 | (format t "Gathering generated objects:~%") 22 | (ensure-directories-exist libdir) 23 | (loop for library in (cffi:list-foreign-libraries :type :grovel-wrapper) 24 | for n upfrom 0 25 | as filename = (cffi:foreign-library-pathname library) 26 | as newname = (format nil "gen~D" n) 27 | do 28 | ;; cffi-grovel previously didn't give generated libraries 29 | ;; meaningful or guaranteed unique names, so I rename them 30 | ;; myself. 31 | (format t "~D: ~A~%" n filename) 32 | (alexandria:copy-file filename 33 | (merge-pathnames 34 | (make-pathname :name newname :type "so") 35 | libdir)) 36 | (cffi:close-foreign-library library) 37 | (cffi:load-foreign-library (format nil "~A.so" newname)))) 38 | 39 | (print sb-sys:*shared-objects*) 40 | 41 | (sb-ext:save-lisp-and-die "shuffletron-bin" 42 | :executable t 43 | :toplevel #'shuffletron:run 44 | :save-runtime-options t) 45 | 46 | -------------------------------------------------------------------------------- /extra/web/package.lisp: -------------------------------------------------------------------------------- 1 | (defpackage :shuffletron-www 2 | (:use :common-lisp :shuffletron :cl-who :hunchentoot)) 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_flat_30_cccccc_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_flat_30_cccccc_40x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_flat_50_5c5c5c_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_flat_50_5c5c5c_40x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_glass_40_ffc73d_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_glass_40_ffc73d_1x400.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_highlight-hard_20_0972a5_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_highlight-hard_20_0972a5_1x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_highlight-soft_33_003147_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_highlight-soft_33_003147_1x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_highlight-soft_35_222222_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_highlight-soft_35_222222_1x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_highlight-soft_44_444444_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_highlight-soft_44_444444_1x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_highlight-soft_80_eeeeee_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_highlight-soft_80_eeeeee_1x100.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-bg_loop_25_000000_21x21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-bg_loop_25_000000_21x21.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-icons_4b8e0b_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-icons_4b8e0b_256x240.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-icons_a83300_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-icons_a83300_256x240.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-icons_cccccc_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-icons_cccccc_256x240.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/images/ui-icons_ffffff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/dark-hive/images/ui-icons_ffffff_256x240.png -------------------------------------------------------------------------------- /extra/web/static/dark-hive/jquery-ui.custom.css: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery UI CSS Framework 3 | * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) 4 | * Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses. 5 | */ 6 | 7 | /* Layout helpers 8 | ----------------------------------*/ 9 | .ui-helper-hidden { display: none; } 10 | .ui-helper-hidden-accessible { position: absolute; left: -99999999px; } 11 | .ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; } 12 | .ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; } 13 | .ui-helper-clearfix { display: inline-block; } 14 | /* required comment for clearfix to work in Opera \*/ 15 | * html .ui-helper-clearfix { height:1%; } 16 | .ui-helper-clearfix { display:block; } 17 | /* end clearfix */ 18 | .ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); } 19 | 20 | 21 | /* Interaction Cues 22 | ----------------------------------*/ 23 | .ui-state-disabled { cursor: default !important; } 24 | 25 | 26 | /* Icons 27 | ----------------------------------*/ 28 | 29 | /* states and images */ 30 | .ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; } 31 | 32 | 33 | /* Misc visuals 34 | ----------------------------------*/ 35 | 36 | /* Overlays */ 37 | .ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } 38 | 39 | 40 | /* 41 | * jQuery UI CSS Framework 42 | * Copyright (c) 2010 AUTHORS.txt (http://jqueryui.com/about) 43 | * Dual licensed under the MIT (MIT-LICENSE.txt) and GPL (GPL-LICENSE.txt) licenses. 44 | * To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,%20Arial,%20sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=6px&bgColorHeader=444444&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=44&borderColorHeader=333333&fcHeader=ffffff&iconColorHeader=ffffff&bgColorContent=000000&bgTextureContent=14_loop.png&bgImgOpacityContent=25&borderColorContent=555555&fcContent=ffffff&iconColorContent=cccccc&bgColorDefault=222222&bgTextureDefault=03_highlight_soft.png&bgImgOpacityDefault=35&borderColorDefault=444444&fcDefault=eeeeee&iconColorDefault=cccccc&bgColorHover=003147&bgTextureHover=03_highlight_soft.png&bgImgOpacityHover=33&borderColorHover=0b93d5&fcHover=ffffff&iconColorHover=ffffff&bgColorActive=0972a5&bgTextureActive=04_highlight_hard.png&bgImgOpacityActive=20&borderColorActive=26b3f7&fcActive=ffffff&iconColorActive=222222&bgColorHighlight=eeeeee&bgTextureHighlight=03_highlight_soft.png&bgImgOpacityHighlight=80&borderColorHighlight=cccccc&fcHighlight=2e7db2&iconColorHighlight=4b8e0b&bgColorError=ffc73d&bgTextureError=02_glass.png&bgImgOpacityError=40&borderColorError=ffb73d&fcError=111111&iconColorError=a83300&bgColorOverlay=5c5c5c&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=50&opacityOverlay=80&bgColorShadow=cccccc&bgTextureShadow=01_flat.png&bgImgOpacityShadow=30&opacityShadow=60&thicknessShadow=7px&offsetTopShadow=-7px&offsetLeftShadow=-7px&cornerRadiusShadow=8px 45 | */ 46 | 47 | 48 | /* Component containers 49 | ----------------------------------*/ 50 | .ui-widget { font-family: Verdana, Arial, sans-serif; font-size: 1.1em; } 51 | .ui-widget .ui-widget { font-size: 1em; } 52 | .ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana, Arial, sans-serif; font-size: 1em; } 53 | .ui-widget-content { border: 1px solid #555555; background: #000000 url(images/ui-bg_loop_25_000000_21x21.png) 50% 50% repeat; color: #ffffff; } 54 | .ui-widget-content a { color: #ffffff; } 55 | .ui-widget-header { border: 1px solid #333333; background: #444444 url(images/ui-bg_highlight-soft_44_444444_1x100.png) 50% 50% repeat-x; color: #ffffff; font-weight: bold; } 56 | .ui-widget-header a { color: #ffffff; } 57 | 58 | /* Interaction states 59 | ----------------------------------*/ 60 | .ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #444444; background: #222222 url(images/ui-bg_highlight-soft_35_222222_1x100.png) 50% 50% repeat-x; font-weight: normal; color: #eeeeee; } 61 | .ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #eeeeee; text-decoration: none; } 62 | .ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #0b93d5; background: #003147 url(images/ui-bg_highlight-soft_33_003147_1x100.png) 50% 50% repeat-x; font-weight: normal; color: #ffffff; } 63 | .ui-state-hover a, .ui-state-hover a:hover { color: #ffffff; text-decoration: none; } 64 | .ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #26b3f7; background: #0972a5 url(images/ui-bg_highlight-hard_20_0972a5_1x100.png) 50% 50% repeat-x; font-weight: normal; color: #ffffff; } 65 | .ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #ffffff; text-decoration: none; } 66 | .ui-widget :active { outline: none; } 67 | 68 | /* Interaction Cues 69 | ----------------------------------*/ 70 | .ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #cccccc; background: #eeeeee url(images/ui-bg_highlight-soft_80_eeeeee_1x100.png) 50% top repeat-x; color: #2e7db2; } 71 | .ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #2e7db2; } 72 | .ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #ffb73d; background: #ffc73d url(images/ui-bg_glass_40_ffc73d_1x400.png) 50% 50% repeat-x; color: #111111; } 73 | .ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #111111; } 74 | .ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #111111; } 75 | .ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; } 76 | .ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; } 77 | .ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; } 78 | 79 | /* Icons 80 | ----------------------------------*/ 81 | 82 | /* states and images */ 83 | .ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_cccccc_256x240.png); } 84 | .ui-widget-content .ui-icon {background-image: url(images/ui-icons_cccccc_256x240.png); } 85 | .ui-widget-header .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); } 86 | .ui-state-default .ui-icon { background-image: url(images/ui-icons_cccccc_256x240.png); } 87 | .ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); } 88 | .ui-state-active .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } 89 | .ui-state-highlight .ui-icon {background-image: url(images/ui-icons_4b8e0b_256x240.png); } 90 | .ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_a83300_256x240.png); } 91 | 92 | /* positioning */ 93 | .ui-icon-carat-1-n { background-position: 0 0; } 94 | .ui-icon-carat-1-ne { background-position: -16px 0; } 95 | .ui-icon-carat-1-e { background-position: -32px 0; } 96 | .ui-icon-carat-1-se { background-position: -48px 0; } 97 | .ui-icon-carat-1-s { background-position: -64px 0; } 98 | .ui-icon-carat-1-sw { background-position: -80px 0; } 99 | .ui-icon-carat-1-w { background-position: -96px 0; } 100 | .ui-icon-carat-1-nw { background-position: -112px 0; } 101 | .ui-icon-carat-2-n-s { background-position: -128px 0; } 102 | .ui-icon-carat-2-e-w { background-position: -144px 0; } 103 | .ui-icon-triangle-1-n { background-position: 0 -16px; } 104 | .ui-icon-triangle-1-ne { background-position: -16px -16px; } 105 | .ui-icon-triangle-1-e { background-position: -32px -16px; } 106 | .ui-icon-triangle-1-se { background-position: -48px -16px; } 107 | .ui-icon-triangle-1-s { background-position: -64px -16px; } 108 | .ui-icon-triangle-1-sw { background-position: -80px -16px; } 109 | .ui-icon-triangle-1-w { background-position: -96px -16px; } 110 | .ui-icon-triangle-1-nw { background-position: -112px -16px; } 111 | .ui-icon-triangle-2-n-s { background-position: -128px -16px; } 112 | .ui-icon-triangle-2-e-w { background-position: -144px -16px; } 113 | .ui-icon-arrow-1-n { background-position: 0 -32px; } 114 | .ui-icon-arrow-1-ne { background-position: -16px -32px; } 115 | .ui-icon-arrow-1-e { background-position: -32px -32px; } 116 | .ui-icon-arrow-1-se { background-position: -48px -32px; } 117 | .ui-icon-arrow-1-s { background-position: -64px -32px; } 118 | .ui-icon-arrow-1-sw { background-position: -80px -32px; } 119 | .ui-icon-arrow-1-w { background-position: -96px -32px; } 120 | .ui-icon-arrow-1-nw { background-position: -112px -32px; } 121 | .ui-icon-arrow-2-n-s { background-position: -128px -32px; } 122 | .ui-icon-arrow-2-ne-sw { background-position: -144px -32px; } 123 | .ui-icon-arrow-2-e-w { background-position: -160px -32px; } 124 | .ui-icon-arrow-2-se-nw { background-position: -176px -32px; } 125 | .ui-icon-arrowstop-1-n { background-position: -192px -32px; } 126 | .ui-icon-arrowstop-1-e { background-position: -208px -32px; } 127 | .ui-icon-arrowstop-1-s { background-position: -224px -32px; } 128 | .ui-icon-arrowstop-1-w { background-position: -240px -32px; } 129 | .ui-icon-arrowthick-1-n { background-position: 0 -48px; } 130 | .ui-icon-arrowthick-1-ne { background-position: -16px -48px; } 131 | .ui-icon-arrowthick-1-e { background-position: -32px -48px; } 132 | .ui-icon-arrowthick-1-se { background-position: -48px -48px; } 133 | .ui-icon-arrowthick-1-s { background-position: -64px -48px; } 134 | .ui-icon-arrowthick-1-sw { background-position: -80px -48px; } 135 | .ui-icon-arrowthick-1-w { background-position: -96px -48px; } 136 | .ui-icon-arrowthick-1-nw { background-position: -112px -48px; } 137 | .ui-icon-arrowthick-2-n-s { background-position: -128px -48px; } 138 | .ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; } 139 | .ui-icon-arrowthick-2-e-w { background-position: -160px -48px; } 140 | .ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; } 141 | .ui-icon-arrowthickstop-1-n { background-position: -192px -48px; } 142 | .ui-icon-arrowthickstop-1-e { background-position: -208px -48px; } 143 | .ui-icon-arrowthickstop-1-s { background-position: -224px -48px; } 144 | .ui-icon-arrowthickstop-1-w { background-position: -240px -48px; } 145 | .ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; } 146 | .ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; } 147 | .ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; } 148 | .ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; } 149 | .ui-icon-arrowreturn-1-w { background-position: -64px -64px; } 150 | .ui-icon-arrowreturn-1-n { background-position: -80px -64px; } 151 | .ui-icon-arrowreturn-1-e { background-position: -96px -64px; } 152 | .ui-icon-arrowreturn-1-s { background-position: -112px -64px; } 153 | .ui-icon-arrowrefresh-1-w { background-position: -128px -64px; } 154 | .ui-icon-arrowrefresh-1-n { background-position: -144px -64px; } 155 | .ui-icon-arrowrefresh-1-e { background-position: -160px -64px; } 156 | .ui-icon-arrowrefresh-1-s { background-position: -176px -64px; } 157 | .ui-icon-arrow-4 { background-position: 0 -80px; } 158 | .ui-icon-arrow-4-diag { background-position: -16px -80px; } 159 | .ui-icon-extlink { background-position: -32px -80px; } 160 | .ui-icon-newwin { background-position: -48px -80px; } 161 | .ui-icon-refresh { background-position: -64px -80px; } 162 | .ui-icon-shuffle { background-position: -80px -80px; } 163 | .ui-icon-transfer-e-w { background-position: -96px -80px; } 164 | .ui-icon-transferthick-e-w { background-position: -112px -80px; } 165 | .ui-icon-folder-collapsed { background-position: 0 -96px; } 166 | .ui-icon-folder-open { background-position: -16px -96px; } 167 | .ui-icon-document { background-position: -32px -96px; } 168 | .ui-icon-document-b { background-position: -48px -96px; } 169 | .ui-icon-note { background-position: -64px -96px; } 170 | .ui-icon-mail-closed { background-position: -80px -96px; } 171 | .ui-icon-mail-open { background-position: -96px -96px; } 172 | .ui-icon-suitcase { background-position: -112px -96px; } 173 | .ui-icon-comment { background-position: -128px -96px; } 174 | .ui-icon-person { background-position: -144px -96px; } 175 | .ui-icon-print { background-position: -160px -96px; } 176 | .ui-icon-trash { background-position: -176px -96px; } 177 | .ui-icon-locked { background-position: -192px -96px; } 178 | .ui-icon-unlocked { background-position: -208px -96px; } 179 | .ui-icon-bookmark { background-position: -224px -96px; } 180 | .ui-icon-tag { background-position: -240px -96px; } 181 | .ui-icon-home { background-position: 0 -112px; } 182 | .ui-icon-flag { background-position: -16px -112px; } 183 | .ui-icon-calendar { background-position: -32px -112px; } 184 | .ui-icon-cart { background-position: -48px -112px; } 185 | .ui-icon-pencil { background-position: -64px -112px; } 186 | .ui-icon-clock { background-position: -80px -112px; } 187 | .ui-icon-disk { background-position: -96px -112px; } 188 | .ui-icon-calculator { background-position: -112px -112px; } 189 | .ui-icon-zoomin { background-position: -128px -112px; } 190 | .ui-icon-zoomout { background-position: -144px -112px; } 191 | .ui-icon-search { background-position: -160px -112px; } 192 | .ui-icon-wrench { background-position: -176px -112px; } 193 | .ui-icon-gear { background-position: -192px -112px; } 194 | .ui-icon-heart { background-position: -208px -112px; } 195 | .ui-icon-star { background-position: -224px -112px; } 196 | .ui-icon-link { background-position: -240px -112px; } 197 | .ui-icon-cancel { background-position: 0 -128px; } 198 | .ui-icon-plus { background-position: -16px -128px; } 199 | .ui-icon-plusthick { background-position: -32px -128px; } 200 | .ui-icon-minus { background-position: -48px -128px; } 201 | .ui-icon-minusthick { background-position: -64px -128px; } 202 | .ui-icon-close { background-position: -80px -128px; } 203 | .ui-icon-closethick { background-position: -96px -128px; } 204 | .ui-icon-key { background-position: -112px -128px; } 205 | .ui-icon-lightbulb { background-position: -128px -128px; } 206 | .ui-icon-scissors { background-position: -144px -128px; } 207 | .ui-icon-clipboard { background-position: -160px -128px; } 208 | .ui-icon-copy { background-position: -176px -128px; } 209 | .ui-icon-contact { background-position: -192px -128px; } 210 | .ui-icon-image { background-position: -208px -128px; } 211 | .ui-icon-video { background-position: -224px -128px; } 212 | .ui-icon-script { background-position: -240px -128px; } 213 | .ui-icon-alert { background-position: 0 -144px; } 214 | .ui-icon-info { background-position: -16px -144px; } 215 | .ui-icon-notice { background-position: -32px -144px; } 216 | .ui-icon-help { background-position: -48px -144px; } 217 | .ui-icon-check { background-position: -64px -144px; } 218 | .ui-icon-bullet { background-position: -80px -144px; } 219 | .ui-icon-radio-off { background-position: -96px -144px; } 220 | .ui-icon-radio-on { background-position: -112px -144px; } 221 | .ui-icon-pin-w { background-position: -128px -144px; } 222 | .ui-icon-pin-s { background-position: -144px -144px; } 223 | .ui-icon-play { background-position: 0 -160px; } 224 | .ui-icon-pause { background-position: -16px -160px; } 225 | .ui-icon-seek-next { background-position: -32px -160px; } 226 | .ui-icon-seek-prev { background-position: -48px -160px; } 227 | .ui-icon-seek-end { background-position: -64px -160px; } 228 | .ui-icon-seek-start { background-position: -80px -160px; } 229 | /* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */ 230 | .ui-icon-seek-first { background-position: -80px -160px; } 231 | .ui-icon-stop { background-position: -96px -160px; } 232 | .ui-icon-eject { background-position: -112px -160px; } 233 | .ui-icon-volume-off { background-position: -128px -160px; } 234 | .ui-icon-volume-on { background-position: -144px -160px; } 235 | .ui-icon-power { background-position: 0 -176px; } 236 | .ui-icon-signal-diag { background-position: -16px -176px; } 237 | .ui-icon-signal { background-position: -32px -176px; } 238 | .ui-icon-battery-0 { background-position: -48px -176px; } 239 | .ui-icon-battery-1 { background-position: -64px -176px; } 240 | .ui-icon-battery-2 { background-position: -80px -176px; } 241 | .ui-icon-battery-3 { background-position: -96px -176px; } 242 | .ui-icon-circle-plus { background-position: 0 -192px; } 243 | .ui-icon-circle-minus { background-position: -16px -192px; } 244 | .ui-icon-circle-close { background-position: -32px -192px; } 245 | .ui-icon-circle-triangle-e { background-position: -48px -192px; } 246 | .ui-icon-circle-triangle-s { background-position: -64px -192px; } 247 | .ui-icon-circle-triangle-w { background-position: -80px -192px; } 248 | .ui-icon-circle-triangle-n { background-position: -96px -192px; } 249 | .ui-icon-circle-arrow-e { background-position: -112px -192px; } 250 | .ui-icon-circle-arrow-s { background-position: -128px -192px; } 251 | .ui-icon-circle-arrow-w { background-position: -144px -192px; } 252 | .ui-icon-circle-arrow-n { background-position: -160px -192px; } 253 | .ui-icon-circle-zoomin { background-position: -176px -192px; } 254 | .ui-icon-circle-zoomout { background-position: -192px -192px; } 255 | .ui-icon-circle-check { background-position: -208px -192px; } 256 | .ui-icon-circlesmall-plus { background-position: 0 -208px; } 257 | .ui-icon-circlesmall-minus { background-position: -16px -208px; } 258 | .ui-icon-circlesmall-close { background-position: -32px -208px; } 259 | .ui-icon-squaresmall-plus { background-position: -48px -208px; } 260 | .ui-icon-squaresmall-minus { background-position: -64px -208px; } 261 | .ui-icon-squaresmall-close { background-position: -80px -208px; } 262 | .ui-icon-grip-dotted-vertical { background-position: 0 -224px; } 263 | .ui-icon-grip-dotted-horizontal { background-position: -16px -224px; } 264 | .ui-icon-grip-solid-vertical { background-position: -32px -224px; } 265 | .ui-icon-grip-solid-horizontal { background-position: -48px -224px; } 266 | .ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; } 267 | .ui-icon-grip-diagonal-se { background-position: -80px -224px; } 268 | 269 | 270 | /* Misc visuals 271 | ----------------------------------*/ 272 | 273 | /* Corner radius */ 274 | .ui-corner-tl { -moz-border-radius-topleft: 6px; -webkit-border-top-left-radius: 6px; border-top-left-radius: 6px; } 275 | .ui-corner-tr { -moz-border-radius-topright: 6px; -webkit-border-top-right-radius: 6px; border-top-right-radius: 6px; } 276 | .ui-corner-bl { -moz-border-radius-bottomleft: 6px; -webkit-border-bottom-left-radius: 6px; border-bottom-left-radius: 6px; } 277 | .ui-corner-br { -moz-border-radius-bottomright: 6px; -webkit-border-bottom-right-radius: 6px; border-bottom-right-radius: 6px; } 278 | .ui-corner-top { -moz-border-radius-topleft: 6px; -webkit-border-top-left-radius: 6px; border-top-left-radius: 6px; -moz-border-radius-topright: 6px; -webkit-border-top-right-radius: 6px; border-top-right-radius: 6px; } 279 | .ui-corner-bottom { -moz-border-radius-bottomleft: 6px; -webkit-border-bottom-left-radius: 6px; border-bottom-left-radius: 6px; -moz-border-radius-bottomright: 6px; -webkit-border-bottom-right-radius: 6px; border-bottom-right-radius: 6px; } 280 | .ui-corner-right { -moz-border-radius-topright: 6px; -webkit-border-top-right-radius: 6px; border-top-right-radius: 6px; -moz-border-radius-bottomright: 6px; -webkit-border-bottom-right-radius: 6px; border-bottom-right-radius: 6px; } 281 | .ui-corner-left { -moz-border-radius-topleft: 6px; -webkit-border-top-left-radius: 6px; border-top-left-radius: 6px; -moz-border-radius-bottomleft: 6px; -webkit-border-bottom-left-radius: 6px; border-bottom-left-radius: 6px; } 282 | .ui-corner-all { -moz-border-radius: 6px; -webkit-border-radius: 6px; border-radius: 6px; } 283 | 284 | /* Overlays */ 285 | .ui-widget-overlay { background: #5c5c5c url(images/ui-bg_flat_50_5c5c5c_40x100.png) 50% 50% repeat-x; opacity: .80;filter:Alpha(Opacity=80); } 286 | .ui-widget-shadow { margin: -7px 0 0 -7px; padding: 7px; background: #cccccc url(images/ui-bg_flat_30_cccccc_40x100.png) 50% 50% repeat-x; opacity: .60;filter:Alpha(Opacity=60); -moz-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }/* Resizable 287 | ----------------------------------*/ 288 | .ui-resizable { position: relative;} 289 | .ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block;} 290 | .ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } 291 | .ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; } 292 | .ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; } 293 | .ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; } 294 | .ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; } 295 | .ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; } 296 | .ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; } 297 | .ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; } 298 | .ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/* Button 299 | ----------------------------------*/ 300 | 301 | .ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */ 302 | .ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */ 303 | button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */ 304 | .ui-button-icons-only { width: 3.4em; } 305 | button.ui-button-icons-only { width: 3.7em; } 306 | 307 | /*button text element */ 308 | .ui-button .ui-button-text { display: block; line-height: 1.4; } 309 | .ui-button-text-only .ui-button-text { padding: .4em 1em; } 310 | .ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; } 311 | .ui-button-text-icon .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; } 312 | .ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; } 313 | /* no icon support for input elements, provide padding by default */ 314 | input.ui-button { padding: .4em 1em; } 315 | 316 | /*button icon element(s) */ 317 | .ui-button-icon-only .ui-icon, .ui-button-text-icon .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; } 318 | .ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; } 319 | .ui-button-text-icon .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; } 320 | .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } 321 | 322 | /*button sets*/ 323 | .ui-buttonset { margin-right: 7px; } 324 | .ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; } 325 | 326 | /* workarounds */ 327 | button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */ 328 | 329 | 330 | 331 | 332 | 333 | /* Tabs 334 | ----------------------------------*/ 335 | .ui-tabs { position: relative; padding: .2em; zoom: 1; } /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as "fixed") */ 336 | .ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; } 337 | .ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap; } 338 | .ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; } 339 | .ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; } 340 | .ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; } 341 | .ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */ 342 | .ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; } 343 | .ui-tabs .ui-tabs-hide { display: none !important; } 344 | /* Progressbar 345 | ----------------------------------*/ 346 | .ui-progressbar { height:2em; text-align: left; } 347 | .ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; } -------------------------------------------------------------------------------- /extra/web/static/jquery.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery JavaScript Library v1.4.2 3 | * http://jquery.com/ 4 | * 5 | * Copyright 2010, John Resig 6 | * Dual licensed under the MIT or GPL Version 2 licenses. 7 | * http://jquery.org/license 8 | * 9 | * Includes Sizzle.js 10 | * http://sizzlejs.com/ 11 | * Copyright 2010, The Dojo Foundation 12 | * Released under the MIT, BSD, and GPL Licenses. 13 | * 14 | * Date: Sat Feb 13 22:33:48 2010 -0500 15 | */ 16 | (function(A,w){function ma(){if(!c.isReady){try{s.documentElement.doScroll("left")}catch(a){setTimeout(ma,1);return}c.ready()}}function Qa(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function X(a,b,d,f,e,j){var i=a.length;if(typeof b==="object"){for(var o in b)X(a,o,b[o],f,e,d);return a}if(d!==w){f=!j&&f&&c.isFunction(d);for(o=0;o)[^>]*$|^#([\w-]+)$/,Ua=/^.[^:#\[\.,]*$/,Va=/\S/, 21 | Wa=/^(\s|\u00A0)+|(\s|\u00A0)+$/g,Xa=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,P=navigator.userAgent,xa=false,Q=[],L,$=Object.prototype.toString,aa=Object.prototype.hasOwnProperty,ba=Array.prototype.push,R=Array.prototype.slice,ya=Array.prototype.indexOf;c.fn=c.prototype={init:function(a,b){var d,f;if(!a)return this;if(a.nodeType){this.context=this[0]=a;this.length=1;return this}if(a==="body"&&!b){this.context=s;this[0]=s.body;this.selector="body";this.length=1;return this}if(typeof a==="string")if((d=Ta.exec(a))&& 22 | (d[1]||!b))if(d[1]){f=b?b.ownerDocument||b:s;if(a=Xa.exec(a))if(c.isPlainObject(b)){a=[s.createElement(a[1])];c.fn.attr.call(a,b,true)}else a=[f.createElement(a[1])];else{a=sa([d[1]],[f]);a=(a.cacheable?a.fragment.cloneNode(true):a.fragment).childNodes}return c.merge(this,a)}else{if(b=s.getElementById(d[2])){if(b.id!==d[2])return T.find(a);this.length=1;this[0]=b}this.context=s;this.selector=a;return this}else if(!b&&/^\w+$/.test(a)){this.selector=a;this.context=s;a=s.getElementsByTagName(a);return c.merge(this, 23 | a)}else return!b||b.jquery?(b||T).find(a):c(b).find(a);else if(c.isFunction(a))return T.ready(a);if(a.selector!==w){this.selector=a.selector;this.context=a.context}return c.makeArray(a,this)},selector:"",jquery:"1.4.2",length:0,size:function(){return this.length},toArray:function(){return R.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this.slice(a)[0]:this[a]},pushStack:function(a,b,d){var f=c();c.isArray(a)?ba.apply(f,a):c.merge(f,a);f.prevObject=this;f.context=this.context;if(b=== 24 | "find")f.selector=this.selector+(this.selector?" ":"")+d;else if(b)f.selector=this.selector+"."+b+"("+d+")";return f},each:function(a,b){return c.each(this,a,b)},ready:function(a){c.bindReady();if(c.isReady)a.call(s,c);else Q&&Q.push(a);return this},eq:function(a){return a===-1?this.slice(a):this.slice(a,+a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(R.apply(this,arguments),"slice",R.call(arguments).join(","))},map:function(a){return this.pushStack(c.map(this, 25 | function(b,d){return a.call(b,d,b)}))},end:function(){return this.prevObject||c(null)},push:ba,sort:[].sort,splice:[].splice};c.fn.init.prototype=c.fn;c.extend=c.fn.extend=function(){var a=arguments[0]||{},b=1,d=arguments.length,f=false,e,j,i,o;if(typeof a==="boolean"){f=a;a=arguments[1]||{};b=2}if(typeof a!=="object"&&!c.isFunction(a))a={};if(d===b){a=this;--b}for(;b
a"; 34 | var e=d.getElementsByTagName("*"),j=d.getElementsByTagName("a")[0];if(!(!e||!e.length||!j)){c.support={leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(j.getAttribute("style")),hrefNormalized:j.getAttribute("href")==="/a",opacity:/^0.55$/.test(j.style.opacity),cssFloat:!!j.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:s.createElement("select").appendChild(s.createElement("option")).selected, 35 | parentNode:d.removeChild(d.appendChild(s.createElement("div"))).parentNode===null,deleteExpando:true,checkClone:false,scriptEval:false,noCloneEvent:true,boxModel:null};b.type="text/javascript";try{b.appendChild(s.createTextNode("window."+f+"=1;"))}catch(i){}a.insertBefore(b,a.firstChild);if(A[f]){c.support.scriptEval=true;delete A[f]}try{delete b.test}catch(o){c.support.deleteExpando=false}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function k(){c.support.noCloneEvent= 36 | false;d.detachEvent("onclick",k)});d.cloneNode(true).fireEvent("onclick")}d=s.createElement("div");d.innerHTML="";a=s.createDocumentFragment();a.appendChild(d.firstChild);c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var k=s.createElement("div");k.style.width=k.style.paddingLeft="1px";s.body.appendChild(k);c.boxModel=c.support.boxModel=k.offsetWidth===2;s.body.removeChild(k).style.display="none"});a=function(k){var n= 37 | s.createElement("div");k="on"+k;var r=k in n;if(!r){n.setAttribute(k,"return;");r=typeof n[k]==="function"}return r};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=e=j=null}})();c.props={"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};var G="jQuery"+J(),Ya=0,za={};c.extend({cache:{},expando:G,noData:{embed:true,object:true, 38 | applet:true},data:function(a,b,d){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var f=a[G],e=c.cache;if(!f&&typeof b==="string"&&d===w)return null;f||(f=++Ya);if(typeof b==="object"){a[G]=f;e[f]=c.extend(true,{},b)}else if(!e[f]){a[G]=f;e[f]={}}a=e[f];if(d!==w)a[b]=d;return typeof b==="string"?a[b]:a}},removeData:function(a,b){if(!(a.nodeName&&c.noData[a.nodeName.toLowerCase()])){a=a==A?za:a;var d=a[G],f=c.cache,e=f[d];if(b){if(e){delete e[b];c.isEmptyObject(e)&&c.removeData(a)}}else{if(c.support.deleteExpando)delete a[c.expando]; 39 | else a.removeAttribute&&a.removeAttribute(c.expando);delete f[d]}}}});c.fn.extend({data:function(a,b){if(typeof a==="undefined"&&this.length)return c.data(this[0]);else if(typeof a==="object")return this.each(function(){c.data(this,a)});var d=a.split(".");d[1]=d[1]?"."+d[1]:"";if(b===w){var f=this.triggerHandler("getData"+d[1]+"!",[d[0]]);if(f===w&&this.length)f=c.data(this[0],a);return f===w&&d[1]?this.data(d[0]):f}else return this.trigger("setData"+d[1]+"!",[d[0],b]).each(function(){c.data(this, 40 | a,b)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var f=c.data(a,b);if(!d)return f||[];if(!f||c.isArray(d))f=c.data(a,b,c.makeArray(d));else f.push(d);return f}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),f=d.shift();if(f==="inprogress")f=d.shift();if(f){b==="fx"&&d.unshift("inprogress");f.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b=== 41 | w)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]||a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var Aa=/[\n\t]/g,ca=/\s+/,Za=/\r/g,$a=/href|src|style/,ab=/(button|input)/i,bb=/(button|input|object|select|textarea)/i, 42 | cb=/^(a|area)$/i,Ba=/radio|checkbox/;c.fn.extend({attr:function(a,b){return X(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(n){var r=c(this);r.addClass(a.call(this,n,r.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ca),d=0,f=this.length;d-1)return true;return false},val:function(a){if(a===w){var b=this[0];if(b){if(c.nodeName(b,"option"))return(b.attributes.value||{}).specified?b.value:b.text;if(c.nodeName(b,"select")){var d=b.selectedIndex,f=[],e=b.options;b=b.type==="select-one";if(d<0)return null;var j=b?d:0;for(d=b?d+1:e.length;j=0;else if(c.nodeName(this,"select")){var u=c.makeArray(r);c("option",this).each(function(){this.selected= 47 | c.inArray(c(this).val(),u)>=0});if(!u.length)this.selectedIndex=-1}else this.value=r}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},attr:function(a,b,d,f){if(!a||a.nodeType===3||a.nodeType===8)return w;if(f&&b in c.attrFn)return c(a)[b](d);f=a.nodeType!==1||!c.isXMLDoc(a);var e=d!==w;b=f&&c.props[b]||b;if(a.nodeType===1){var j=$a.test(b);if(b in a&&f&&!j){if(e){b==="type"&&ab.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed"); 48 | a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&b.specified?b.value:bb.test(a.nodeName)||cb.test(a.nodeName)&&a.href?0:w;return a[b]}if(!c.support.style&&f&&b==="style"){if(e)a.style.cssText=""+d;return a.style.cssText}e&&a.setAttribute(b,""+d);a=!c.support.hrefNormalized&&f&&j?a.getAttribute(b,2):a.getAttribute(b);return a===null?w:a}return c.style(a,b,d)}});var O=/\.(.*)$/,db=function(a){return a.replace(/[^\w\s\.\|`]/g, 49 | function(b){return"\\"+b})};c.event={add:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){if(a.setInterval&&a!==A&&!a.frameElement)a=A;var e,j;if(d.handler){e=d;d=e.handler}if(!d.guid)d.guid=c.guid++;if(j=c.data(a)){var i=j.events=j.events||{},o=j.handle;if(!o)j.handle=o=function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(o.elem,arguments):w};o.elem=a;b=b.split(" ");for(var k,n=0,r;k=b[n++];){j=e?c.extend({},e):{handler:d,data:f};if(k.indexOf(".")>-1){r=k.split("."); 50 | k=r.shift();j.namespace=r.slice(0).sort().join(".")}else{r=[];j.namespace=""}j.type=k;j.guid=d.guid;var u=i[k],z=c.event.special[k]||{};if(!u){u=i[k]=[];if(!z.setup||z.setup.call(a,f,r,o)===false)if(a.addEventListener)a.addEventListener(k,o,false);else a.attachEvent&&a.attachEvent("on"+k,o)}if(z.add){z.add.call(a,j);if(!j.handler.guid)j.handler.guid=d.guid}u.push(j);c.event.global[k]=true}a=null}}},global:{},remove:function(a,b,d,f){if(!(a.nodeType===3||a.nodeType===8)){var e,j=0,i,o,k,n,r,u,z=c.data(a), 51 | C=z&&z.events;if(z&&C){if(b&&b.type){d=b.handler;b=b.type}if(!b||typeof b==="string"&&b.charAt(0)==="."){b=b||"";for(e in C)c.event.remove(a,e+b)}else{for(b=b.split(" ");e=b[j++];){n=e;i=e.indexOf(".")<0;o=[];if(!i){o=e.split(".");e=o.shift();k=new RegExp("(^|\\.)"+c.map(o.slice(0).sort(),db).join("\\.(?:.*\\.)?")+"(\\.|$)")}if(r=C[e])if(d){n=c.event.special[e]||{};for(B=f||0;B=0){a.type= 53 | e=e.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();c.event.global[e]&&c.each(c.cache,function(){this.events&&this.events[e]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===8)return w;a.result=w;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(f=c.data(d,"handle"))&&f.apply(d,b);f=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+e]&&d["on"+e].apply(d,b)===false)a.result=false}catch(j){}if(!a.isPropagationStopped()&& 54 | f)c.event.trigger(a,b,f,true);else if(!a.isDefaultPrevented()){f=a.target;var i,o=c.nodeName(f,"a")&&e==="click",k=c.event.special[e]||{};if((!k._default||k._default.call(d,a)===false)&&!o&&!(f&&f.nodeName&&c.noData[f.nodeName.toLowerCase()])){try{if(f[e]){if(i=f["on"+e])f["on"+e]=null;c.event.triggered=true;f[e]()}}catch(n){}if(i)f["on"+e]=i;c.event.triggered=false}}},handle:function(a){var b,d,f,e;a=arguments[0]=c.event.fix(a||A.event);a.currentTarget=this;b=a.type.indexOf(".")<0&&!a.exclusive; 55 | if(!b){d=a.type.split(".");a.type=d.shift();f=new RegExp("(^|\\.)"+d.slice(0).sort().join("\\.(?:.*\\.)?")+"(\\.|$)")}e=c.data(this,"events");d=e[a.type];if(e&&d){d=d.slice(0);e=0;for(var j=d.length;e-1?c.map(a.options,function(f){return f.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d},fa=function(a,b){var d=a.target,f,e;if(!(!da.test(d.nodeName)||d.readOnly)){f=c.data(d,"_change_data");e=Fa(d);if(a.type!=="focusout"||d.type!=="radio")c.data(d,"_change_data", 63 | e);if(!(f===w||e===f))if(f!=null||e){a.type="change";return c.event.trigger(a,b,d)}}};c.event.special.change={filters:{focusout:fa,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return fa.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return fa.call(this,a)},beforeactivate:function(a){a=a.target;c.data(a, 64 | "_change_data",Fa(a))}},setup:function(){if(this.type==="file")return false;for(var a in ea)c.event.add(this,a+".specialChange",ea[a]);return da.test(this.nodeName)},teardown:function(){c.event.remove(this,".specialChange");return da.test(this.nodeName)}};ea=c.event.special.change.filters}s.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(f){f=c.event.fix(f);f.type=b;return c.event.handle.call(this,f)}c.event.special[b]={setup:function(){this.addEventListener(a, 65 | d,true)},teardown:function(){this.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,f,e){if(typeof d==="object"){for(var j in d)this[b](j,f,d[j],e);return this}if(c.isFunction(f)){e=f;f=w}var i=b==="one"?c.proxy(e,function(k){c(this).unbind(k,i);return e.apply(this,arguments)}):e;if(d==="unload"&&b!=="one")this.one(d,f,e);else{j=0;for(var o=this.length;j0){y=t;break}}t=t[g]}m[q]=y}}}var f=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^[\]]*\]|['"][^'"]*['"]|[^[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g, 71 | e=0,j=Object.prototype.toString,i=false,o=true;[0,0].sort(function(){o=false;return 0});var k=function(g,h,l,m){l=l||[];var q=h=h||s;if(h.nodeType!==1&&h.nodeType!==9)return[];if(!g||typeof g!=="string")return l;for(var p=[],v,t,y,S,H=true,M=x(h),I=g;(f.exec(""),v=f.exec(I))!==null;){I=v[3];p.push(v[1]);if(v[2]){S=v[3];break}}if(p.length>1&&r.exec(g))if(p.length===2&&n.relative[p[0]])t=ga(p[0]+p[1],h);else for(t=n.relative[p[0]]?[h]:k(p.shift(),h);p.length;){g=p.shift();if(n.relative[g])g+=p.shift(); 72 | t=ga(g,t)}else{if(!m&&p.length>1&&h.nodeType===9&&!M&&n.match.ID.test(p[0])&&!n.match.ID.test(p[p.length-1])){v=k.find(p.shift(),h,M);h=v.expr?k.filter(v.expr,v.set)[0]:v.set[0]}if(h){v=m?{expr:p.pop(),set:z(m)}:k.find(p.pop(),p.length===1&&(p[0]==="~"||p[0]==="+")&&h.parentNode?h.parentNode:h,M);t=v.expr?k.filter(v.expr,v.set):v.set;if(p.length>0)y=z(t);else H=false;for(;p.length;){var D=p.pop();v=D;if(n.relative[D])v=p.pop();else D="";if(v==null)v=h;n.relative[D](y,v,M)}}else y=[]}y||(y=t);y||k.error(D|| 73 | g);if(j.call(y)==="[object Array]")if(H)if(h&&h.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&E(h,y[g])))l.push(t[g])}else for(g=0;y[g]!=null;g++)y[g]&&y[g].nodeType===1&&l.push(t[g]);else l.push.apply(l,y);else z(y,l);if(S){k(S,q,l,m);k.uniqueSort(l)}return l};k.uniqueSort=function(g){if(B){i=o;g.sort(B);if(i)for(var h=1;h":function(g,h){var l=typeof h==="string";if(l&&!/\W/.test(h)){h=h.toLowerCase();for(var m=0,q=g.length;m=0))l||m.push(v);else if(l)h[p]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()}, 80 | CHILD:function(g){if(g[1]==="nth"){var h=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&&"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=h[1]+(h[2]||1)-0;g[3]=h[3]-0}g[0]=e++;return g},ATTR:function(g,h,l,m,q,p){h=g[1].replace(/\\/g,"");if(!p&&n.attrMap[h])g[1]=n.attrMap[h];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,h,l,m,q){if(g[1]==="not")if((f.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=k(g[3],null,null,h);else{g=k.filter(g[3],h,l,true^q);l||m.push.apply(m, 81 | g);return false}else if(n.match.POS.test(g[0])||n.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true);return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,h,l){return!!k(l[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)}, 82 | text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"===g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}}, 83 | setFilters:{first:function(g,h){return h===0},last:function(g,h,l,m){return h===m.length-1},even:function(g,h){return h%2===0},odd:function(g,h){return h%2===1},lt:function(g,h,l){return hl[3]-0},nth:function(g,h,l){return l[3]-0===h},eq:function(g,h,l){return l[3]-0===h}},filter:{PSEUDO:function(g,h,l,m){var q=h[1],p=n.filters[q];if(p)return p(g,l,h,m);else if(q==="contains")return(g.textContent||g.innerText||a([g])||"").indexOf(h[3])>=0;else if(q==="not"){h= 84 | h[3];l=0;for(m=h.length;l=0}},ID:function(g,h){return g.nodeType===1&&g.getAttribute("id")===h},TAG:function(g,h){return h==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===h},CLASS:function(g,h){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(h)>-1},ATTR:function(g,h){var l=h[1];g=n.attrHandle[l]?n.attrHandle[l](g):g[l]!=null?g[l]:g.getAttribute(l);l=g+"";var m=h[2];h=h[4];return g==null?m==="!=":m=== 86 | "="?l===h:m==="*="?l.indexOf(h)>=0:m==="~="?(" "+l+" ").indexOf(h)>=0:!h?l&&g!==false:m==="!="?l!==h:m==="^="?l.indexOf(h)===0:m==="$="?l.substr(l.length-h.length)===h:m==="|="?l===h||l.substr(0,h.length+1)===h+"-":false},POS:function(g,h,l,m){var q=n.setFilters[h[2]];if(q)return q(g,l,h,m)}}},r=n.match.POS;for(var u in n.match){n.match[u]=new RegExp(n.match[u].source+/(?![^\[]*\])(?![^\(]*\))/.source);n.leftMatch[u]=new RegExp(/(^(?:.|\r|\n)*?)/.source+n.match[u].source.replace(/\\(\d+)/g,function(g, 87 | h){return"\\"+(h-0+1)}))}var z=function(g,h){g=Array.prototype.slice.call(g,0);if(h){h.push.apply(h,g);return h}return g};try{Array.prototype.slice.call(s.documentElement.childNodes,0)}catch(C){z=function(g,h){h=h||[];if(j.call(g)==="[object Array]")Array.prototype.push.apply(h,g);else if(typeof g.length==="number")for(var l=0,m=g.length;l";var l=s.documentElement;l.insertBefore(g,l.firstChild);if(s.getElementById(h)){n.find.ID=function(m,q,p){if(typeof q.getElementById!=="undefined"&&!p)return(q=q.getElementById(m[1]))?q.id===m[1]||typeof q.getAttributeNode!=="undefined"&& 90 | q.getAttributeNode("id").nodeValue===m[1]?[q]:w:[]};n.filter.ID=function(m,q){var p=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&p&&p.nodeValue===q}}l.removeChild(g);l=g=null})();(function(){var g=s.createElement("div");g.appendChild(s.createComment(""));if(g.getElementsByTagName("*").length>0)n.find.TAG=function(h,l){l=l.getElementsByTagName(h[1]);if(h[1]==="*"){h=[];for(var m=0;l[m];m++)l[m].nodeType===1&&h.push(l[m]);l=h}return l};g.innerHTML=""; 91 | if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")n.attrHandle.href=function(h){return h.getAttribute("href",2)};g=null})();s.querySelectorAll&&function(){var g=k,h=s.createElement("div");h.innerHTML="

";if(!(h.querySelectorAll&&h.querySelectorAll(".TEST").length===0)){k=function(m,q,p,v){q=q||s;if(!v&&q.nodeType===9&&!x(q))try{return z(q.querySelectorAll(m),p)}catch(t){}return g(m,q,p,v)};for(var l in g)k[l]=g[l];h=null}}(); 92 | (function(){var g=s.createElement("div");g.innerHTML="
";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){n.order.splice(1,0,"CLASS");n.find.CLASS=function(h,l,m){if(typeof l.getElementsByClassName!=="undefined"&&!m)return l.getElementsByClassName(h[1])};g=null}}})();var E=s.compareDocumentPosition?function(g,h){return!!(g.compareDocumentPosition(h)&16)}: 93 | function(g,h){return g!==h&&(g.contains?g.contains(h):true)},x=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false},ga=function(g,h){var l=[],m="",q;for(h=h.nodeType?[h]:h;q=n.match.PSEUDO.exec(g);){m+=q[0];g=g.replace(n.match.PSEUDO,"")}g=n.relative[g]?g+"*":g;q=0;for(var p=h.length;q=0===d})};c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,f=0,e=this.length;f0)for(var j=d;j0},closest:function(a,b){if(c.isArray(a)){var d=[],f=this[0],e,j= 96 | {},i;if(f&&a.length){e=0;for(var o=a.length;e-1:c(f).is(e)){d.push({selector:i,elem:f});delete j[i]}}f=f.parentNode}}return d}var k=c.expr.match.POS.test(a)?c(a,b||this.context):null;return this.map(function(n,r){for(;r&&r.ownerDocument&&r!==b;){if(k?k.index(r)>-1:c(r).is(a))return r;r=r.parentNode}return null})},index:function(a){if(!a||typeof a=== 97 | "string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){a=typeof a==="string"?c(a,b||this.context):c.makeArray(a);b=c.merge(this.get(),a);return this.pushStack(qa(a[0])||qa(b[0])?b:c.unique(b))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode", 98 | d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")? 99 | a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,b){c.fn[a]=function(d,f){var e=c.map(this,b,d);eb.test(a)||(f=d);if(f&&typeof f==="string")e=c.filter(f,e);e=this.length>1?c.unique(e):e;if((this.length>1||gb.test(f))&&fb.test(a))e=e.reverse();return this.pushStack(e,a,R.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return c.find.matches(a,b)},dir:function(a,b,d){var f=[];for(a=a[b];a&&a.nodeType!==9&&(d===w||a.nodeType!==1||!c(a).is(d));){a.nodeType=== 100 | 1&&f.push(a);a=a[b]}return f},nth:function(a,b,d){b=b||1;for(var f=0;a;a=a[d])if(a.nodeType===1&&++f===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var Ja=/ jQuery\d+="(?:\d+|null)"/g,V=/^\s+/,Ka=/(<([\w:]+)[^>]*?)\/>/g,hb=/^(?:area|br|col|embed|hr|img|input|link|meta|param)$/i,La=/<([\w:]+)/,ib=/"},F={option:[1,""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"],col:[2,"","
"],area:[1,"",""],_default:[0,"",""]};F.optgroup=F.option;F.tbody=F.tfoot=F.colgroup=F.caption=F.thead;F.th=F.td;if(!c.support.htmlSerialize)F._default=[1,"div
","
"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d= 102 | c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==w)return this.empty().append((this[0]&&this[0].ownerDocument||s).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this}, 103 | wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})}, 104 | prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b, 105 | this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,f;(f=this[d])!=null;d++)if(!a||c.filter(a,[f]).length){if(!b&&f.nodeType===1){c.cleanData(f.getElementsByTagName("*"));c.cleanData([f])}f.parentNode&&f.parentNode.removeChild(f)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild); 106 | return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,f=this.ownerDocument;if(!d){d=f.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(Ja,"").replace(/=([^="'>\s]+\/)>/g,'="$1">').replace(V,"")],f)[0]}else return this.cloneNode(true)});if(a===true){ra(this,b);ra(this.find("*"),b.find("*"))}return b},html:function(a){if(a===w)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(Ja, 107 | ""):null;else if(typeof a==="string"&&!ta.test(a)&&(c.support.leadingWhitespace||!V.test(a))&&!F[(La.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Ka,Ma);try{for(var b=0,d=this.length;b0||e.cacheable||this.length>1?k.cloneNode(true):k)}o.length&&c.each(o,Qa)}return this}});c.fragments={};c.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var f=[];d=c(d);var e=this.length===1&&this[0].parentNode;if(e&&e.nodeType===11&&e.childNodes.length===1&&d.length===1){d[b](this[0]); 111 | return this}else{e=0;for(var j=d.length;e0?this.clone(true):this).get();c.fn[b].apply(c(d[e]),i);f=f.concat(i)}return this.pushStack(f,a,d.selector)}}});c.extend({clean:function(a,b,d,f){b=b||s;if(typeof b.createElement==="undefined")b=b.ownerDocument||b[0]&&b[0].ownerDocument||s;for(var e=[],j=0,i;(i=a[j])!=null;j++){if(typeof i==="number")i+="";if(i){if(typeof i==="string"&&!jb.test(i))i=b.createTextNode(i);else if(typeof i==="string"){i=i.replace(Ka,Ma);var o=(La.exec(i)||["", 112 | ""])[1].toLowerCase(),k=F[o]||F._default,n=k[0],r=b.createElement("div");for(r.innerHTML=k[1]+i+k[2];n--;)r=r.lastChild;if(!c.support.tbody){n=ib.test(i);o=o==="table"&&!n?r.firstChild&&r.firstChild.childNodes:k[1]===""&&!n?r.childNodes:[];for(k=o.length-1;k>=0;--k)c.nodeName(o[k],"tbody")&&!o[k].childNodes.length&&o[k].parentNode.removeChild(o[k])}!c.support.leadingWhitespace&&V.test(i)&&r.insertBefore(b.createTextNode(V.exec(i)[0]),r.firstChild);i=r.childNodes}if(i.nodeType)e.push(i);else e= 113 | c.merge(e,i)}}if(d)for(j=0;e[j];j++)if(f&&c.nodeName(e[j],"script")&&(!e[j].type||e[j].type.toLowerCase()==="text/javascript"))f.push(e[j].parentNode?e[j].parentNode.removeChild(e[j]):e[j]);else{e[j].nodeType===1&&e.splice.apply(e,[j+1,0].concat(c.makeArray(e[j].getElementsByTagName("script"))));d.appendChild(e[j])}return e},cleanData:function(a){for(var b,d,f=c.cache,e=c.event.special,j=c.support.deleteExpando,i=0,o;(o=a[i])!=null;i++)if(d=o[c.expando]){b=f[d];if(b.events)for(var k in b.events)e[k]? 114 | c.event.remove(o,k):Ca(o,k,b.handle);if(j)delete o[c.expando];else o.removeAttribute&&o.removeAttribute(c.expando);delete f[d]}}});var kb=/z-?index|font-?weight|opacity|zoom|line-?height/i,Na=/alpha\([^)]*\)/,Oa=/opacity=([^)]*)/,ha=/float/i,ia=/-([a-z])/ig,lb=/([A-Z])/g,mb=/^-?\d+(?:px)?$/i,nb=/^-?\d/,ob={position:"absolute",visibility:"hidden",display:"block"},pb=["Left","Right"],qb=["Top","Bottom"],rb=s.defaultView&&s.defaultView.getComputedStyle,Pa=c.support.cssFloat?"cssFloat":"styleFloat",ja= 115 | function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){return X(this,a,b,true,function(d,f,e){if(e===w)return c.curCSS(d,f);if(typeof e==="number"&&!kb.test(f))e+="px";c.style(d,f,e)})};c.extend({style:function(a,b,d){if(!a||a.nodeType===3||a.nodeType===8)return w;if((b==="width"||b==="height")&&parseFloat(d)<0)d=w;var f=a.style||a,e=d!==w;if(!c.support.opacity&&b==="opacity"){if(e){f.zoom=1;b=parseInt(d,10)+""==="NaN"?"":"alpha(opacity="+d*100+")";a=f.filter||c.curCSS(a,"filter")||"";f.filter= 116 | Na.test(a)?a.replace(Na,b):b}return f.filter&&f.filter.indexOf("opacity=")>=0?parseFloat(Oa.exec(f.filter)[1])/100+"":""}if(ha.test(b))b=Pa;b=b.replace(ia,ja);if(e)f[b]=d;return f[b]},css:function(a,b,d,f){if(b==="width"||b==="height"){var e,j=b==="width"?pb:qb;function i(){e=b==="width"?a.offsetWidth:a.offsetHeight;f!=="border"&&c.each(j,function(){f||(e-=parseFloat(c.curCSS(a,"padding"+this,true))||0);if(f==="margin")e+=parseFloat(c.curCSS(a,"margin"+this,true))||0;else e-=parseFloat(c.curCSS(a, 117 | "border"+this+"Width",true))||0})}a.offsetWidth!==0?i():c.swap(a,ob,i);return Math.max(0,Math.round(e))}return c.curCSS(a,b,d)},curCSS:function(a,b,d){var f,e=a.style;if(!c.support.opacity&&b==="opacity"&&a.currentStyle){f=Oa.test(a.currentStyle.filter||"")?parseFloat(RegExp.$1)/100+"":"";return f===""?"1":f}if(ha.test(b))b=Pa;if(!d&&e&&e[b])f=e[b];else if(rb){if(ha.test(b))b="float";b=b.replace(lb,"-$1").toLowerCase();e=a.ownerDocument.defaultView;if(!e)return null;if(a=e.getComputedStyle(a,null))f= 118 | a.getPropertyValue(b);if(b==="opacity"&&f==="")f="1"}else if(a.currentStyle){d=b.replace(ia,ja);f=a.currentStyle[b]||a.currentStyle[d];if(!mb.test(f)&&nb.test(f)){b=e.left;var j=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;e.left=d==="fontSize"?"1em":f||0;f=e.pixelLeft+"px";e.left=b;a.runtimeStyle.left=j}}return f},swap:function(a,b,d){var f={};for(var e in b){f[e]=a.style[e];a.style[e]=b[e]}d.call(a);for(e in b)a.style[e]=f[e]}});if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b= 119 | a.offsetWidth,d=a.offsetHeight,f=a.nodeName.toLowerCase()==="tr";return b===0&&d===0&&!f?true:b>0&&d>0&&!f?false:c.curCSS(a,"display")==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var sb=J(),tb=//gi,ub=/select|textarea/i,vb=/color|date|datetime|email|hidden|month|number|password|range|search|tel|text|time|url|week/i,N=/=\?(&|$)/,ka=/\?/,wb=/(\?|&)_=.*?(&|$)/,xb=/^(\w+:)?\/\/([^\/?#]+)/,yb=/%20/g,zb=c.fn.load;c.fn.extend({load:function(a,b,d){if(typeof a!== 120 | "string")return zb.call(this,a);else if(!this.length)return this;var f=a.indexOf(" ");if(f>=0){var e=a.slice(f,a.length);a=a.slice(0,f)}f="GET";if(b)if(c.isFunction(b)){d=b;b=null}else if(typeof b==="object"){b=c.param(b,c.ajaxSettings.traditional);f="POST"}var j=this;c.ajax({url:a,type:f,dataType:"html",data:b,complete:function(i,o){if(o==="success"||o==="notmodified")j.html(e?c("
").append(i.responseText.replace(tb,"")).find(e):i.responseText);d&&j.each(d,[i.responseText,o,i])}});return this}, 121 | serialize:function(){return c.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?c.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||ub.test(this.nodeName)||vb.test(this.type))}).map(function(a,b){a=c(this).val();return a==null?null:c.isArray(a)?c.map(a,function(d){return{name:b.name,value:d}}):{name:b.name,value:a}}).get()}});c.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "), 122 | function(a,b){c.fn[b]=function(d){return this.bind(b,d)}});c.extend({get:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b=null}return c.ajax({type:"GET",url:a,data:b,success:d,dataType:f})},getScript:function(a,b){return c.get(a,null,b,"script")},getJSON:function(a,b,d){return c.get(a,b,d,"json")},post:function(a,b,d,f){if(c.isFunction(b)){f=f||d;d=b;b={}}return c.ajax({type:"POST",url:a,data:b,success:d,dataType:f})},ajaxSetup:function(a){c.extend(c.ajaxSettings,a)},ajaxSettings:{url:location.href, 123 | global:true,type:"GET",contentType:"application/x-www-form-urlencoded",processData:true,async:true,xhr:A.XMLHttpRequest&&(A.location.protocol!=="file:"||!A.ActiveXObject)?function(){return new A.XMLHttpRequest}:function(){try{return new A.ActiveXObject("Microsoft.XMLHTTP")}catch(a){}},accepts:{xml:"application/xml, text/xml",html:"text/html",script:"text/javascript, application/javascript",json:"application/json, text/javascript",text:"text/plain",_default:"*/*"}},lastModified:{},etag:{},ajax:function(a){function b(){e.success&& 124 | e.success.call(k,o,i,x);e.global&&f("ajaxSuccess",[x,e])}function d(){e.complete&&e.complete.call(k,x,i);e.global&&f("ajaxComplete",[x,e]);e.global&&!--c.active&&c.event.trigger("ajaxStop")}function f(q,p){(e.context?c(e.context):c.event).trigger(q,p)}var e=c.extend(true,{},c.ajaxSettings,a),j,i,o,k=a&&a.context||e,n=e.type.toUpperCase();if(e.data&&e.processData&&typeof e.data!=="string")e.data=c.param(e.data,e.traditional);if(e.dataType==="jsonp"){if(n==="GET")N.test(e.url)||(e.url+=(ka.test(e.url)? 125 | "&":"?")+(e.jsonp||"callback")+"=?");else if(!e.data||!N.test(e.data))e.data=(e.data?e.data+"&":"")+(e.jsonp||"callback")+"=?";e.dataType="json"}if(e.dataType==="json"&&(e.data&&N.test(e.data)||N.test(e.url))){j=e.jsonpCallback||"jsonp"+sb++;if(e.data)e.data=(e.data+"").replace(N,"="+j+"$1");e.url=e.url.replace(N,"="+j+"$1");e.dataType="script";A[j]=A[j]||function(q){o=q;b();d();A[j]=w;try{delete A[j]}catch(p){}z&&z.removeChild(C)}}if(e.dataType==="script"&&e.cache===null)e.cache=false;if(e.cache=== 126 | false&&n==="GET"){var r=J(),u=e.url.replace(wb,"$1_="+r+"$2");e.url=u+(u===e.url?(ka.test(e.url)?"&":"?")+"_="+r:"")}if(e.data&&n==="GET")e.url+=(ka.test(e.url)?"&":"?")+e.data;e.global&&!c.active++&&c.event.trigger("ajaxStart");r=(r=xb.exec(e.url))&&(r[1]&&r[1]!==location.protocol||r[2]!==location.host);if(e.dataType==="script"&&n==="GET"&&r){var z=s.getElementsByTagName("head")[0]||s.documentElement,C=s.createElement("script");C.src=e.url;if(e.scriptCharset)C.charset=e.scriptCharset;if(!j){var B= 127 | false;C.onload=C.onreadystatechange=function(){if(!B&&(!this.readyState||this.readyState==="loaded"||this.readyState==="complete")){B=true;b();d();C.onload=C.onreadystatechange=null;z&&C.parentNode&&z.removeChild(C)}}}z.insertBefore(C,z.firstChild);return w}var E=false,x=e.xhr();if(x){e.username?x.open(n,e.url,e.async,e.username,e.password):x.open(n,e.url,e.async);try{if(e.data||a&&a.contentType)x.setRequestHeader("Content-Type",e.contentType);if(e.ifModified){c.lastModified[e.url]&&x.setRequestHeader("If-Modified-Since", 128 | c.lastModified[e.url]);c.etag[e.url]&&x.setRequestHeader("If-None-Match",c.etag[e.url])}r||x.setRequestHeader("X-Requested-With","XMLHttpRequest");x.setRequestHeader("Accept",e.dataType&&e.accepts[e.dataType]?e.accepts[e.dataType]+", */*":e.accepts._default)}catch(ga){}if(e.beforeSend&&e.beforeSend.call(k,x,e)===false){e.global&&!--c.active&&c.event.trigger("ajaxStop");x.abort();return false}e.global&&f("ajaxSend",[x,e]);var g=x.onreadystatechange=function(q){if(!x||x.readyState===0||q==="abort"){E|| 129 | d();E=true;if(x)x.onreadystatechange=c.noop}else if(!E&&x&&(x.readyState===4||q==="timeout")){E=true;x.onreadystatechange=c.noop;i=q==="timeout"?"timeout":!c.httpSuccess(x)?"error":e.ifModified&&c.httpNotModified(x,e.url)?"notmodified":"success";var p;if(i==="success")try{o=c.httpData(x,e.dataType,e)}catch(v){i="parsererror";p=v}if(i==="success"||i==="notmodified")j||b();else c.handleError(e,x,i,p);d();q==="timeout"&&x.abort();if(e.async)x=null}};try{var h=x.abort;x.abort=function(){x&&h.call(x); 130 | g("abort")}}catch(l){}e.async&&e.timeout>0&&setTimeout(function(){x&&!E&&g("timeout")},e.timeout);try{x.send(n==="POST"||n==="PUT"||n==="DELETE"?e.data:null)}catch(m){c.handleError(e,x,null,m);d()}e.async||g();return x}},handleError:function(a,b,d,f){if(a.error)a.error.call(a.context||a,b,d,f);if(a.global)(a.context?c(a.context):c.event).trigger("ajaxError",[b,a,f])},active:0,httpSuccess:function(a){try{return!a.status&&location.protocol==="file:"||a.status>=200&&a.status<300||a.status===304||a.status=== 131 | 1223||a.status===0}catch(b){}return false},httpNotModified:function(a,b){var d=a.getResponseHeader("Last-Modified"),f=a.getResponseHeader("Etag");if(d)c.lastModified[b]=d;if(f)c.etag[b]=f;return a.status===304||a.status===0},httpData:function(a,b,d){var f=a.getResponseHeader("content-type")||"",e=b==="xml"||!b&&f.indexOf("xml")>=0;a=e?a.responseXML:a.responseText;e&&a.documentElement.nodeName==="parsererror"&&c.error("parsererror");if(d&&d.dataFilter)a=d.dataFilter(a,b);if(typeof a==="string")if(b=== 132 | "json"||!b&&f.indexOf("json")>=0)a=c.parseJSON(a);else if(b==="script"||!b&&f.indexOf("javascript")>=0)c.globalEval(a);return a},param:function(a,b){function d(i,o){if(c.isArray(o))c.each(o,function(k,n){b||/\[\]$/.test(i)?f(i,n):d(i+"["+(typeof n==="object"||c.isArray(n)?k:"")+"]",n)});else!b&&o!=null&&typeof o==="object"?c.each(o,function(k,n){d(i+"["+k+"]",n)}):f(i,o)}function f(i,o){o=c.isFunction(o)?o():o;e[e.length]=encodeURIComponent(i)+"="+encodeURIComponent(o)}var e=[];if(b===w)b=c.ajaxSettings.traditional; 133 | if(c.isArray(a)||a.jquery)c.each(a,function(){f(this.name,this.value)});else for(var j in a)d(j,a[j]);return e.join("&").replace(yb,"+")}});var la={},Ab=/toggle|show|hide/,Bb=/^([+-]=)?([\d+-.]+)(.*)$/,W,va=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]];c.fn.extend({show:function(a,b){if(a||a===0)return this.animate(K("show",3),a,b);else{a=0;for(b=this.length;a").appendTo("body");f=e.css("display");if(f==="none")f="block";e.remove();la[d]=f}c.data(this[a],"olddisplay",f)}}a=0;for(b=this.length;a=0;f--)if(d[f].elem===this){b&&d[f](true);d.splice(f,1)}});b||this.dequeue();return this}});c.each({slideDown:K("show",1),slideUp:K("hide",1),slideToggle:K("toggle",1),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"}},function(a,b){c.fn[a]=function(d,f){return this.animate(b,d,f)}});c.extend({speed:function(a,b,d){var f=a&&typeof a==="object"?a:{complete:d||!d&&b||c.isFunction(a)&&a,duration:a,easing:d&&b||b&&!c.isFunction(b)&&b};f.duration=c.fx.off?0:typeof f.duration=== 139 | "number"?f.duration:c.fx.speeds[f.duration]||c.fx.speeds._default;f.old=f.complete;f.complete=function(){f.queue!==false&&c(this).dequeue();c.isFunction(f.old)&&f.old.call(this)};return f},easing:{linear:function(a,b,d,f){return d+f*a},swing:function(a,b,d,f){return(-Math.cos(a*Math.PI)/2+0.5)*f+d}},timers:[],fx:function(a,b,d){this.options=b;this.elem=a;this.prop=d;if(!b.orig)b.orig={}}});c.fx.prototype={update:function(){this.options.step&&this.options.step.call(this.elem,this.now,this);(c.fx.step[this.prop]|| 140 | c.fx.step._default)(this);if((this.prop==="height"||this.prop==="width")&&this.elem.style)this.elem.style.display="block"},cur:function(a){if(this.elem[this.prop]!=null&&(!this.elem.style||this.elem.style[this.prop]==null))return this.elem[this.prop];return(a=parseFloat(c.css(this.elem,this.prop,a)))&&a>-10000?a:parseFloat(c.curCSS(this.elem,this.prop))||0},custom:function(a,b,d){function f(j){return e.step(j)}this.startTime=J();this.start=a;this.end=b;this.unit=d||this.unit||"px";this.now=this.start; 141 | this.pos=this.state=0;var e=this;f.elem=this.elem;if(f()&&c.timers.push(f)&&!W)W=setInterval(c.fx.tick,13)},show:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.show=true;this.custom(this.prop==="width"||this.prop==="height"?1:0,this.cur());c(this.elem).show()},hide:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.hide=true;this.custom(this.cur(),0)},step:function(a){var b=J(),d=true;if(a||b>=this.options.duration+this.startTime){this.now= 142 | this.end;this.pos=this.state=1;this.update();this.options.curAnim[this.prop]=true;for(var f in this.options.curAnim)if(this.options.curAnim[f]!==true)d=false;if(d){if(this.options.display!=null){this.elem.style.overflow=this.options.overflow;a=c.data(this.elem,"olddisplay");this.elem.style.display=a?a:this.options.display;if(c.css(this.elem,"display")==="none")this.elem.style.display="block"}this.options.hide&&c(this.elem).hide();if(this.options.hide||this.options.show)for(var e in this.options.curAnim)c.style(this.elem, 143 | e,this.options.orig[e]);this.options.complete.call(this.elem)}return false}else{e=b-this.startTime;this.state=e/this.options.duration;a=this.options.easing||(c.easing.swing?"swing":"linear");this.pos=c.easing[this.options.specialEasing&&this.options.specialEasing[this.prop]||a](this.state,e,0,1,this.options.duration);this.now=this.start+(this.end-this.start)*this.pos;this.update()}return true}};c.extend(c.fx,{tick:function(){for(var a=c.timers,b=0;b
"; 149 | a.insertBefore(b,a.firstChild);d=b.firstChild;f=d.firstChild;e=d.nextSibling.firstChild.firstChild;this.doesNotAddBorder=f.offsetTop!==5;this.doesAddBorderForTableAndCells=e.offsetTop===5;f.style.position="fixed";f.style.top="20px";this.supportsFixedPosition=f.offsetTop===20||f.offsetTop===15;f.style.position=f.style.top="";d.style.overflow="hidden";d.style.position="relative";this.subtractsBorderForOverflowNotVisible=f.offsetTop===-5;this.doesNotIncludeMarginInBodyOffset=a.offsetTop!==j;a.removeChild(b); 150 | c.offset.initialize=c.noop},bodyOffset:function(a){var b=a.offsetTop,d=a.offsetLeft;c.offset.initialize();if(c.offset.doesNotIncludeMarginInBodyOffset){b+=parseFloat(c.curCSS(a,"marginTop",true))||0;d+=parseFloat(c.curCSS(a,"marginLeft",true))||0}return{top:b,left:d}},setOffset:function(a,b,d){if(/static/.test(c.curCSS(a,"position")))a.style.position="relative";var f=c(a),e=f.offset(),j=parseInt(c.curCSS(a,"top",true),10)||0,i=parseInt(c.curCSS(a,"left",true),10)||0;if(c.isFunction(b))b=b.call(a, 151 | d,e);d={top:b.top-e.top+j,left:b.left-e.left+i};"using"in b?b.using.call(a,d):f.css(d)}};c.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),d=this.offset(),f=/^body|html$/i.test(b[0].nodeName)?{top:0,left:0}:b.offset();d.top-=parseFloat(c.curCSS(a,"marginTop",true))||0;d.left-=parseFloat(c.curCSS(a,"marginLeft",true))||0;f.top+=parseFloat(c.curCSS(b[0],"borderTopWidth",true))||0;f.left+=parseFloat(c.curCSS(b[0],"borderLeftWidth",true))||0;return{top:d.top- 152 | f.top,left:d.left-f.left}},offsetParent:function(){return this.map(function(){for(var a=this.offsetParent||s.body;a&&!/^body|html$/i.test(a.nodeName)&&c.css(a,"position")==="static";)a=a.offsetParent;return a})}});c.each(["Left","Top"],function(a,b){var d="scroll"+b;c.fn[d]=function(f){var e=this[0],j;if(!e)return null;if(f!==w)return this.each(function(){if(j=wa(this))j.scrollTo(!a?f:c(j).scrollLeft(),a?f:c(j).scrollTop());else this[d]=f});else return(j=wa(e))?"pageXOffset"in j?j[a?"pageYOffset": 153 | "pageXOffset"]:c.support.boxModel&&j.document.documentElement[d]||j.document.body[d]:e[d]}});c.each(["Height","Width"],function(a,b){var d=b.toLowerCase();c.fn["inner"+b]=function(){return this[0]?c.css(this[0],d,false,"padding"):null};c.fn["outer"+b]=function(f){return this[0]?c.css(this[0],d,false,f?"margin":"border"):null};c.fn[d]=function(f){var e=this[0];if(!e)return f==null?null:this;if(c.isFunction(f))return this.each(function(j){var i=c(this);i[d](f.call(this,j,i[d]()))});return"scrollTo"in 154 | e&&e.document?e.document.compatMode==="CSS1Compat"&&e.document.documentElement["client"+b]||e.document.body["client"+b]:e.nodeType===9?Math.max(e.documentElement["client"+b],e.body["scroll"+b],e.documentElement["scroll"+b],e.body["offset"+b],e.documentElement["offset"+b]):f===w?c.css(e,d):this.css(d,typeof f==="string"?f:f+"px")}});A.jQuery=A.$=c})(window); 155 | -------------------------------------------------------------------------------- /extra/web/static/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/extra/web/static/play.png -------------------------------------------------------------------------------- /extra/web/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: black; 3 | color: white; 4 | font: 15px sans-serif; 5 | } 6 | 7 | h1 { 8 | color: #bbb; 9 | } 10 | 11 | td { 12 | color: white; 13 | background: #282828; 14 | padding: 3px 7px 3px 7px; 15 | } 16 | 17 | td.ARTIST { 18 | background: #720; 19 | vertical-align: top; 20 | } 21 | 22 | td.ALBUM { 23 | background: #8d8030; 24 | vertical-align: top; 25 | } 26 | 27 | td.DUMMY { 28 | background: black; 29 | } 30 | 31 | td.TRACK { 32 | text-align: right; 33 | } 34 | 35 | td.TITLE { 36 | background: #333; 37 | } 38 | 39 | td.FILENAME { 40 | background: #2c2c2c; 41 | } 42 | 43 | td.colhead { 44 | background: #444; 45 | } 46 | 47 | a.PlayLink { 48 | color: #ffff80; 49 | } 50 | 51 | -------------------------------------------------------------------------------- /extra/web/static/ui.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function(){ 2 | // Substitute async handler for playback 3 | $(".PlayLink").click(function(event){ 4 | var link = $(this); 5 | event.preventDefault(); 6 | $.get(this.href, function(){ 7 | parent.header.location.reload(); 8 | /*alert("success!");*/}) 9 | link.fadeOut('fast', function() { 10 | link.fadeIn('slow'); }); 11 | }) 12 | }) 13 | 14 | 15 | -------------------------------------------------------------------------------- /extra/web/www.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron-www) 2 | 3 | (defvar *server* (hunchentoot:start-server :port 4242)) 4 | 5 | (eval-when (:compile-toplevel :load-toplevel :execute) 6 | (defparameter *jqui-theme-name* "dark-hive")) 7 | 8 | (defmacro with-html ((title &key refresh) &body body) 9 | `(with-html-output-to-string (*standard-output* nil :prologue t) 10 | ,(and refresh '(:head (:meta :http-equiv "refresh" :content "2"))) 11 | (:html 12 | (:head (:title ,title) 13 | (:link :type "text/css" :href "static/style.css" :rel "Stylesheet") 14 | (:link :type "text/css" 15 | ;; It's really stupid that jQuery UI puts the version number in the 16 | ;; CSS file name. I've renamed mine. Probably a bad idea. 17 | :href (format nil "static/~A/jquery-ui.custom.css" *jqui-theme-name*) 18 | :rel "Stylesheet") 19 | (:script :type "text/javascript" :src "static/jquery.js") 20 | (:script :type "text/javascript" :src "static/jquery-ui.js") 21 | (:script :type "text/javascript" :src "static/ui.js") 22 | (:script :type "text/javascript" 23 | " 24 | ")) 25 | (:body ,@body)))) 26 | 27 | (defun present-song (song &key streamer) 28 | (let* ((start-time (song-start-time song)) 29 | (tags (song-tags song))) 30 | (with-html-output (*standard-output*) 31 | (when song 32 | (htm 33 | (:h1 "Now Playing") 34 | (:table 35 | (:tr (:td "File") 36 | (:td (esc (format nil "~A" (song-local-path song))))) 37 | (:tr (:td "Position") 38 | (:td (format t "~A of ~A" 39 | (time->string 40 | (round 41 | (mixalot:streamer-position streamer *mixer*) 42 | (mixalot:mixer-rate *mixer*))) 43 | (time->string 44 | (round 45 | (mixalot:streamer-length streamer *mixer*) 46 | (mixalot:mixer-rate *mixer*)))))) 47 | (when start-time 48 | (htm 49 | (:tr (:td "Skip Intro") 50 | (:td (esc (time->string start-time)))))) 51 | (flet ((field (name key) 52 | (if (getf (song-id3 song) key) 53 | (htm 54 | (:tr (:td (esc name)) 55 | (:td (esc (princ-to-string 56 | (getf (song-id3 song) key))))))))) 57 | (field "Artist" :artist) 58 | (field "Album" :album) 59 | (field "Title" :title) 60 | (field "Track" :track) 61 | (field "Genre" :genre) 62 | (field "Comment" :comment)) 63 | (when tags 64 | (htm 65 | (:tr 66 | (:td "Tagged") 67 | (:td (esc (format t "~{~A~^, ~}" 68 | (mapcar #'decode-as-filename 69 | tags))))))))))))) 70 | 71 | (defun present-songs (songs &key controls controller) 72 | (declare (optimize (debug 3))) 73 | (with-html-output (*standard-output*) 74 | (:table 75 | (:tr 76 | (:td :class "colhead" "Artist") 77 | (:td :class "colhead" "Album") 78 | (when controls 79 | (htm (:td :class "DUMMY" ""))) 80 | (:td :class "colhead" "Track") 81 | (:td :class "colhead" "Title") 82 | (:td :class "colhead" "Genre")) 83 | (loop with keys = '(:artist :album :track :title :genre) 84 | with artist-group-len = nil 85 | with album-group-len = nil 86 | for (song . rest) on songs 87 | for n upfrom 0 88 | as id3 = (song-id3 song) 89 | as prev = (vector nil nil nil nil) 90 | as fields = (loop for key in keys collect (getf id3 key)) 91 | as title = (elt fields 3) 92 | as artist = (elt fields 0) 93 | as album = (elt fields 1) 94 | as sufficient = (and title artist) 95 | do 96 | (htm 97 | (:tr 98 | ;; Compute artist and album groupings 99 | (flet ((count-group (this-prop property) 100 | (loop for other in rest 101 | as other-prop = (getf (song-id3 other) property) 102 | as matching = (and other-prop (string-equal other-prop this-prop)) 103 | summing (if matching 1 0) into num-matches 104 | until (not matching) 105 | finally 106 | (htm (:td :class (symbol-name property) :rowspan (1+ num-matches) 107 | (esc this-prop))) 108 | (return num-matches))) 109 | (update-count (count) 110 | (cond 111 | ((eql 0 count) nil) 112 | ((null count) nil) 113 | (t (1- count)))) 114 | (draw-playback-control () 115 | (when controls 116 | (htm 117 | (:td 118 | (:a :href (format nil "~Aplay=~D" controller n) 119 | :class "PlayLink" 120 | (:img :src "static/play.png" :alt "Play"))))))) 121 | 122 | (when (or artist album) 123 | (when (not artist-group-len) 124 | (setf artist-group-len (count-group artist :artist))) 125 | 126 | (when (not album-group-len) 127 | (setf album-group-len (count-group album :album)))) 128 | 129 | (setf artist-group-len (update-count artist-group-len)) 130 | (setf album-group-len (update-count album-group-len)) 131 | 132 | (draw-playback-control) 133 | 134 | (cond 135 | ((not sufficient) 136 | 137 | (htm (:td :class "FILENAME" :colspan 5 (esc (song-local-path song))))) 138 | (t 139 | (flet ((col (class prop) 140 | (htm (:td :class class (esc (princ-to-string (or prop ""))))))) 141 | 142 | (col "TRACK" (elt fields 2)) 143 | (col "TITLE" (elt fields 3)) 144 | (col "GENRE" (elt fields 4)))))))))))) 145 | 146 | (defun present-playqueue () 147 | (with-playqueue () 148 | (let ((queue (copy-list *playqueue*))) 149 | (with-html-output (*standard-output*) 150 | (unless (emptyp queue) 151 | (htm (:h1 "Queue")) 152 | (present-songs queue)))))) 153 | 154 | (define-easy-handler (page/now :uri "/now") 155 | () 156 | (let* ((current *current-stream*) 157 | (song (and current (song-of current)))) 158 | (with-html ("Shuffletron" :refresh t) 159 | (cond 160 | (song 161 | (present-song song :streamer current)) 162 | (t (htm (:p "No song playing.")))) 163 | (present-playqueue)))) 164 | 165 | (define-easy-handler (page/status :uri "/status") 166 | () 167 | (let* ((current *current-stream*) 168 | ;;(queue-size (with-playqueue () (length *playqueue*))) 169 | (song (and current (song-of current))) 170 | (id3 (and song (song-id3 song)))) 171 | (with-html ("Shuffletron Status" :refresh t) 172 | (cond 173 | ((not song) (htm (:div "No song playing."))) 174 | (song 175 | (htm 176 | (:table :class "nowplaying" 177 | (htm (:tr (:td "Playing") (:td (esc (or (getf id3 :title) (song-local-path song)))))) 178 | (when (getf id3 :artist) 179 | (htm (:tr (:td "Artist") (:td (esc (getf id3 :artist)))))) 180 | (when (getf id3 :album) 181 | (htm (:tr (:td "Album") (:td (esc (getf id3 :album))))))))))))) 182 | 183 | #+NIL 184 | (define-easy-handler (page/tagged :uri "/tagged") 185 | (tag-name)) 186 | 187 | (defvar *session-map* (make-hash-table)) 188 | (defvar *next-session* 1) 189 | (defvar *slock* (bordeaux-threads:make-lock "web session lock")) 190 | 191 | (defun new-session-id () 192 | (bordeaux-threads:with-lock-held (*slock*) 193 | (incf *next-session*))) 194 | 195 | (defun find-session (id) 196 | (bordeaux-threads:with-lock-held (*slock*) 197 | (gethash id *session-map*))) 198 | 199 | (defclass session () 200 | ((songs :reader songs-of :initarg :songs) 201 | (id :accessor session-id :initarg :id))) 202 | 203 | (defun register-session (id session) 204 | (unless (numberp id) (setf id (new-session-id))) 205 | (bordeaux-threads:with-lock-held (*slock*) 206 | (setf (session-id session) id 207 | (gethash id *session-map*) session)) 208 | (values session id)) 209 | 210 | (defun library-session () 211 | (bordeaux-threads:with-lock-held (*slock*) 212 | (or (gethash 0 *session-map*) 213 | (setf (gethash 0 *session-map*) 214 | (make-instance 'session :id 0 :songs *filtered-library*))))) 215 | 216 | (defun ensure-session (id?) 217 | (or (find-session id?) (library-session))) 218 | 219 | (defun new-session (songs) 220 | (register-session nil (make-instance 'session :songs songs))) 221 | 222 | (defvar *session* nil) 223 | 224 | (defun safe-vref (vector index &optional default) 225 | (if (and (integerp index) 226 | (>= index 0) 227 | (< index (length vector))) 228 | (aref vector index) 229 | default)) 230 | 231 | (define-easy-handler (page/search :uri "/search") 232 | (term ;;tagged 233 | (id :parameter-type 'integer) 234 | (play :parameter-type 'integer)) 235 | 236 | (let* ((*session* (ensure-session id)) 237 | (*selection* (songs-of *session*)) 238 | (old-selection *selection*) 239 | (play-song (safe-vref *selection* play))) 240 | (when play-song (play-song play-song)) 241 | (format *trace-output* "----- SEARCH ~A ~A" term play) 242 | 243 | ;; Establish new session, applying search term. 244 | (unless (emptyp term) 245 | (refine-query term)) 246 | 247 | (with-html ("Shuffletron") 248 | (:h1 (write-string (if (querying-library-p) "Search Library" "Search Results"))) 249 | (unless (emptyp term) 250 | (when (emptyp *selection*) 251 | (htm (:p "No matches searching for \"" (esc term) "\"")))) 252 | (unless (eq *selection* old-selection) 253 | (setf *session* (new-session *selection*))) 254 | 255 | ;; Present UI 256 | (:p (:form :method :get 257 | (write-string (if (querying-library-p) "Find: " "Refine: ")) 258 | (:input :type :text :name "term") 259 | (:input :type :hidden 260 | :name "id" 261 | :value (session-id *session*))) 262 | (:form :method :get 263 | (:input :type :submit :name "reset" :value "Reset") 264 | (:input :type :hidden 265 | :name "id" 266 | :value 0))) 267 | 268 | (if (< (length *selection*) 500) 269 | (present-songs (coerce *selection* 'list) :controls t 270 | :controller (format nil "/search?id=~D&" 271 | (session-id *session*))) 272 | (htm 273 | (:p "Songs matching query: " 274 | (write (length *selection*)))))))) 275 | 276 | (define-easy-handler (page/tags :uri "/tags") 277 | () 278 | (with-html ("Shuffletron") 279 | (:h1 "Tags") 280 | (:p 281 | (loop for (tag . count) in (tag-count-pairs *filtered-library*) do 282 | (htm (:b (esc (decode-as-filename tag))) 283 | (unless (eql count 1) 284 | (htm (format t "(~:D)" count))) 285 | " "))))) 286 | 287 | ;;;; Grand unified view 288 | 289 | (define-easy-handler (page/master :uri "/") 290 | () 291 | (with-html-output-to-string (*standard-output* nil :prologue t) 292 | (:html 293 | (:head (:title "Shuffletron")) 294 | (:frameset :rows "96,*" 295 | (:frame :name "header" :src "status" :noresize "noresize" :scrolling "no") 296 | (:frame :body "main" :src "search" :scrolling "yes"))))) 297 | 298 | 299 | 300 | ;;;; Testing Only 301 | 302 | (define-easy-handler (page/foo :uri "/foo") 303 | () 304 | (format *trace-output* "~&FOO v3 invoked.~%") 305 | (setf (content-type) "text/plain") 306 | "This is a test.") 307 | 308 | (define-easy-handler (test/json1 :uri "/jsontest1") 309 | () 310 | (let ((random (random 1000000))) 311 | (format *trace-output* "~&jsontest1 has a random number, ~A~%" random) 312 | (setf (content-type) "text/plain") 313 | (format nil "{ \"name\": \"JSON test\", \"value\":~D }" random))) 314 | 315 | 316 | 317 | 318 | 319 | ;;;; Server setup 320 | 321 | (defvar *here* 322 | (load-time-value 323 | (make-pathname :name nil :type nil :version nil 324 | :defaults (or #.*compile-file-pathname* *load-pathname*)))) 325 | 326 | 327 | (setf *dispatch-table* 328 | (list 'dispatch-easy-handlers 329 | (create-folder-dispatcher-and-handler "/static/" (merge-pathnames #p"static/" *here*)))) 330 | 331 | (setf hunchentoot:*show-lisp-backtraces-p* t) 332 | (setf hunchentoot:*show-lisp-errors-p* t) 333 | 334 | ;;(hunchentoot:start-server :port 4242) 335 | -------------------------------------------------------------------------------- /id3-utf16.diff: -------------------------------------------------------------------------------- 1 | Index: src/libmpg123/id3.c 2 | =================================================================== 3 | --- src/libmpg123/id3.c (revision 2219) 4 | +++ src/libmpg123/id3.c (working copy) 5 | @@ -797,6 +797,7 @@ 6 | static void convert_utf16(mpg123_string *sb, unsigned char* s, size_t l, int str_be) 7 | { 8 | size_t i; 9 | + size_t n = l & ~1; 10 | unsigned char *p; 11 | size_t length = 0; /* the resulting UTF-8 length */ 12 | /* Determine real length... extreme case can be more than utf-16 length. */ 13 | @@ -804,15 +805,13 @@ 14 | size_t low = 1; 15 | debug1("convert_utf16 with length %lu", (unsigned long)l); 16 | 17 | - if(l < 1){ mpg123_set_string(sb, ""); return; } 18 | - 19 | if(!str_be) /* little-endian */ 20 | { 21 | high = 1; /* The second byte is the high byte. */ 22 | low = 0; /* The first byte is the low byte. */ 23 | } 24 | /* first: get length, check for errors -- stop at first one */ 25 | - for(i=0; i < l-1; i+=2) 26 | + for(i=0; i < n; i+=2) 27 | { 28 | unsigned long point = ((unsigned long) s[i+high]<<8) + s[i+low]; 29 | if((point & 0xd800) == 0xd800) /* lead surrogate */ 30 | @@ -827,7 +826,7 @@ 31 | else /* if no valid pair, break here */ 32 | { 33 | debug1("Invalid UTF16 surrogate pair at %li.", (unsigned long)i); 34 | - l = i; /* Forget the half pair, END! */ 35 | + n = i; /* Forget the half pair, END! */ 36 | break; 37 | } 38 | } 39 | @@ -838,7 +837,7 @@ 40 | 41 | /* Now really convert, skip checks as these have been done just before. */ 42 | p = (unsigned char*) sb->p; /* Signedness doesn't matter but it shows I thought about the non-issue */ 43 | - for(i=0; i < l-1; i+=2) 44 | + for(i=0; i < n; i+=2) 45 | { 46 | unsigned long codepoint = ((unsigned long) s[i+high]<<8) + s[i+low]; 47 | if((codepoint & 0xd800) == 0xd800) /* lead surrogate */ 48 | -------------------------------------------------------------------------------- /img-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahefner/shuffletron/49e8e8d96c28244605fb4ad29a46e4e966729568/img-search.png -------------------------------------------------------------------------------- /load-ccl.lisp: -------------------------------------------------------------------------------- 1 | 2 | #.(require :asdf) 3 | 4 | (push '(MERGE-PATHNAMES ".sbcl/systems/" (USER-HOMEDIR-PATHNAME)) 5 | asdf:*central-registry*) 6 | 7 | (load "shuffletron.asd") 8 | (asdf:oos 'asdf:compile-op :shuffletron) 9 | (asdf:oos 'asdf:load-op :shuffletron) 10 | 11 | (shuffletron:run) 12 | 13 | 14 | -------------------------------------------------------------------------------- /shuffletron: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | RLWRAP=`which rlwrap` 4 | 5 | DIR=`dirname $0` 6 | 7 | # You'll never see messages echoed here unless something fails, 8 | # because Shuffletron currently clears the screen at startup. Oops. 9 | 10 | if [ -r $DIR/libs/libmixalot-mpg123.so.0 ] ; then 11 | echo "Using local shared library directory." 12 | export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:./libs/:/usr/local/lib/shuffletron 13 | else 14 | export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib/shuffletron 15 | fi 16 | 17 | if [ -z $RLWRAP ] ; then 18 | echo "** Can't find rlwrap. It's recommended you install this, in order to" 19 | echo " have full command editing and history." 20 | $0-bin 21 | else 22 | rlwrap $0-bin $@ 23 | fi 24 | -------------------------------------------------------------------------------- /shuffletron.asd: -------------------------------------------------------------------------------- 1 | (asdf:defsystem :shuffletron 2 | :name "Shuffletron" 3 | :description "Music player" 4 | :version "0.0.5" 5 | :author "Andy Hefner " 6 | :license "MIT-style license" 7 | :depends-on (:osicat :mixalot :mixalot-mp3 :mixalot-vorbis :mixalot-flac) 8 | :components ((:module src 9 | :serial t 10 | :components ((:file "packages") 11 | (:file "util") 12 | (:file "global") 13 | (:file "help") 14 | (:file "profiles") 15 | (:file "library") 16 | (:file "query") 17 | (:file "tags") 18 | (:file "audio") 19 | (:file "ui") 20 | (:file "alarms") 21 | (:file "main") 22 | (:file "status-bar"))))) 23 | -------------------------------------------------------------------------------- /src/alarms.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;; Time parsers: 4 | 5 | (defun parse-timespec (string) 6 | "Parse a time/duration, in one of three formats (seconds, m:ss, h:mm:ss)" 7 | (disjunction (string) 8 | ;; Seconds format (a single integer): 9 | (prog1 (num in) (eof in)) 10 | ;; mm:ss format (seconds must be modulo 60): 11 | (+ (* 60 (prog1 (num in) (colon in))) 12 | (* 1 (prog1 (mod60 in) (eof in)))) 13 | ;; h:mm:ss format (minutes and seconds must be modulo 60): 14 | (+ (* 3600 (prog1 (num in) (colon in))) 15 | (* 60 (prog1 (mod60 in) (colon in))) 16 | (* 1 (prog1 (mod60 in) (eof in)))))) 17 | 18 | (defun 12hour (in) (let ((n (num in))) (val (and (< 0 n 13) (mod n 12))))) 19 | 20 | (defun parse-12-hour-format (in) 21 | "Parse numeric portions (hour or h:mm) of 12-hour time format, returning a count in minutes." 22 | (val 23 | (disjunction () 24 | ;; h:mm format 25 | (+ (prog1 (* 60 (12hour in)) (colon in)) 26 | (mod60 in)) 27 | ;; Bare time in hours: 28 | (* 60 (12hour in))))) 29 | 30 | (defun parse-daytime (in) 31 | "Parse string as a time of day (AM/PM), for the alarm 32 | clock. Returns time in minutes from midnight." 33 | (disjunction () 34 | ;; If there's no time, default to AM 35 | (prog1 (parse-12-hour-format in) (eof in)) 36 | ;; AM time 37 | (prog1 (parse-12-hour-format in) 38 | (whitespace in) 39 | (disjunction () (match in "a.m.") (match in "am.") (match in "am")) 40 | (eof in)) 41 | ;; PM time 42 | (+ (prog1 (parse-12-hour-format in) 43 | (whitespace in) 44 | (disjunction () (match in "p.m.") (match in "pm.") (match in "pm")) 45 | (eof in)) 46 | 720))) 47 | 48 | (defun utime->string (utime) 49 | (multiple-value-bind (second minute hour date month year day) 50 | (decode-universal-time utime) 51 | (declare (ignore second)) 52 | (format nil "~A ~A ~D ~D:~2,'0D ~A ~D" 53 | (nth day '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun")) 54 | (nth (1- month) '("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")) 55 | date 56 | (1+ (mod (1- hour) 12)) 57 | minute 58 | (if (>= hour 12) "PM" "AM") 59 | year))) 60 | 61 | (defun print-time () 62 | (format t "~A~%" (utime->string (get-universal-time)))) 63 | 64 | (defun daytime->alarm-time (daytime) 65 | "Translate a daytime (in minutes) to a universal time for the 66 | alarm. Since the daytime doesn't specify a date, we choose tomorrow 67 | rather than today if the date would be less than the current time." 68 | (let* ((current-time (get-universal-time)) 69 | (decoded (multiple-value-list (decode-universal-time current-time))) 70 | (minutes (second decoded)) 71 | (hours (third decoded)) 72 | (current (+ minutes (* hours 60)))) 73 | (cond 74 | ((< current daytime) 75 | (encode-universal-time 0 (mod daytime 60) (truncate daytime 60) 76 | (fourth decoded) (fifth decoded) (sixth decoded))) 77 | (t (multiple-value-bind (s m h date month year) ; Get tomorrow's date. 78 | (decode-universal-time (+ current-time 86400)) 79 | (declare (ignore s m h)) 80 | (encode-universal-time 0 (mod daytime 60) (truncate daytime 60) 81 | date month year)))))) 82 | 83 | (defun parse-relative-time (in) 84 | (disjunction () 85 | ;; Time in minutes: 86 | (prog1 (* 60 (num in)) 87 | (whitespace in) 88 | (val (disjunction () 89 | (match in "minutes") (match in "minute") 90 | (match in "mins") (match in "min") (match in "m"))) 91 | (eof in)) 92 | ;; Time in hours: 93 | (prog1 (* 3600 (num in)) 94 | (whitespace in) 95 | (val (disjunction () 96 | (match in "hours") (match in "hour") 97 | (match in "hr") (match in "h")))) 98 | ;; Time in h:mm format: 99 | (+ (* 3600 (prog1 (num in) (colon in))) 100 | (* 60 (mod60 in))))) 101 | 102 | (defun parse-alarm-args (args) 103 | "Parse the arguments to the alarm command, returning NIL or a universal time." 104 | (disjunction (args) 105 | ;; State the time directly: 106 | (daytime->alarm-time (val (parse-daytime in))) 107 | ;; Syntactic sugar for stated time: 108 | (and (match in "at ") 109 | (whitespace in) 110 | (daytime->alarm-time (val (parse-daytime in)))) 111 | ;; Relative time offset: 112 | (and (match in "in ") 113 | (whitespace in) 114 | (+ (get-universal-time) (val (parse-relative-time in)))))) 115 | 116 | (defvar *alarm-thread* nil) 117 | 118 | (defvar *wakeup-time* nil 119 | "Time to wake up if alarm clock is enabled.") 120 | 121 | (defun trigger-alarm () 122 | ;; When the alarm goes off, unpause the player if it's paused. If it 123 | ;; isn't paused but there are songs in the queue, play the next 124 | ;; song. If the queue is empty, queue up ten random songs and play 125 | ;; one. 126 | (setf *wakeup-time* nil) 127 | (unless (unpause) 128 | (with-playqueue () 129 | (unless (or *playqueue* (emptyp *filtered-library*)) 130 | (loop repeat 10 do (push (alexandria:random-elt *filtered-library*) *playqueue*)))) 131 | (play-next-song))) 132 | 133 | (defun alarm-thread-toplevel () 134 | (unwind-protect 135 | (loop with interval = 60 136 | as wakeup = *wakeup-time* 137 | as remaining = (and wakeup (- wakeup (get-universal-time))) 138 | do 139 | (cond 140 | ((null wakeup) (sleep interval)) 141 | ((<= remaining 0) (trigger-alarm)) 142 | (t (sleep (min remaining interval)) 143 | (update-status-bar)))) 144 | (setf *alarm-thread* nil))) 145 | 146 | (defun set-alarm (utime) 147 | (setf *wakeup-time* utime) 148 | (unless *alarm-thread* 149 | (setf *alarm-thread* (bordeaux-threads:make-thread #'alarm-thread-toplevel)))) 150 | 151 | (defun do-set-alarm (args) 152 | (cond 153 | ((null args) 154 | (let ((wakeup *wakeup-time*)) 155 | (if wakeup 156 | (format t "Alarm set for ~A~%" (utime->string wakeup)) 157 | (format t "The alarm is not set.~%")))) 158 | ((member args '("off" "never" "delete" "disable" "cancel" "clear" "reset") :test #'string-equal) 159 | (setf *wakeup-time* nil) 160 | (format t "Disabled alarm.~%")) 161 | (t (let ((time (parse-alarm-args args))) 162 | (cond 163 | ((null time) (format t "Unable to parse as time: ~W~%" args)) 164 | (t (set-alarm time) 165 | (format t "Alarm set for ~A~%" (utime->string time)))))))) 166 | -------------------------------------------------------------------------------- /src/audio.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | (defvar *mixer* nil) 4 | 5 | (defclass shuffletron-stream-mixin () 6 | ((song :accessor song-of :initarg :song) 7 | (stopped :accessor stopped :initform nil))) 8 | 9 | ;;; Should use a proxy approach, so we don't have to define N 10 | ;;; subclasses. Won't work without some small changes to Mixalot - 11 | ;;; presently, a proxying stream can't tell when the proxied stream is 12 | ;;; finished. Fix later. 13 | 14 | (defclass shuffletron-mp3-stream (mixalot-mp3:mp3-streamer shuffletron-stream-mixin) ()) 15 | (defclass shuffletron-ogg-stream (mixalot-vorbis:vorbis-streamer shuffletron-stream-mixin) ()) 16 | (defclass shuffletron-flac-stream (mixalot-flac:flac-streamer shuffletron-stream-mixin) ()) 17 | 18 | (defun audio-init () 19 | (setf *mixer* (create-mixer :rate 44100))) 20 | 21 | ;;; Lock discipline: If you need both locks, take the playqueue lock first. 22 | ;;; This locking business is very dodgy. 23 | 24 | (defvar *cslock* (bordeaux-threads:make-lock "current stream")) 25 | (defvar *i-took-the-cs-look* nil) 26 | (defvar *current-stream* nil) 27 | 28 | (defmacro with-stream-control (() &body body) 29 | `(let ((*i-took-the-cs-look* t)) 30 | (bordeaux-threads:with-lock-held (*cslock*) ,@body))) 31 | 32 | (defmacro when-playing ((name) &body body) 33 | `(let ((,name *current-stream*)) 34 | (when ,name ,@body))) 35 | 36 | (defvar *pqlock* (bordeaux-threads:make-lock "play queue")) 37 | (defvar *playqueue* nil) 38 | 39 | (defvar *loop-mode* nil) 40 | 41 | (defvar *wakeup-time* nil 42 | "Time to wake up if alarm clock is enabled.") 43 | 44 | (defmacro with-playqueue (() &body body) 45 | `(bordeaux-threads:with-lock-held (*pqlock*) 46 | (when *i-took-the-cs-look* 47 | (format t "~&You took the PQ lock inside the CS lock. Don't do that.~%") 48 | #+SBCL (sb-debug:print-backtrace) 49 | #+CCL (ccl:print-call-history :detailed-p nil)) 50 | ,@body)) 51 | 52 | (defun end-stream (stream) 53 | ;; TODO: Fade out nicely. 54 | (setf (stopped stream) t) 55 | (mixer-remove-streamer *mixer* stream) 56 | (setf *current-stream* nil) 57 | (update-status-bar)) 58 | 59 | (defun finish-stream (stream) 60 | (when *loop-mode* 61 | (with-playqueue () 62 | (setf *playqueue* (append *playqueue* (list (song-of stream)))))) 63 | (end-stream stream)) 64 | 65 | (defun make-streamer (song) 66 | (ecase (music-file-type (song-full-path song)) 67 | (:mp3 (mixalot-mp3:make-mp3-streamer 68 | (song-full-path song) 69 | :class 'shuffletron-mp3-stream 70 | :song song 71 | :prescan (pref "prescan" t))) 72 | (:ogg (mixalot-vorbis:make-vorbis-streamer 73 | (song-full-path song) 74 | :class 'shuffletron-ogg-stream 75 | :song song)) 76 | (:flac (mixalot-flac:make-flac-streamer 77 | (song-full-path song) 78 | :class 'shuffletron-flac-stream 79 | :song song)))) 80 | 81 | (defun play-song (song) 82 | "Start a song playing, overriding the existing song. Returns the new 83 | stream if successful, or NIL if the song could not be played." 84 | (when-playing (stream) (end-stream stream)) 85 | (prog1 86 | (handler-case 87 | (with-stream-control () 88 | (when *current-stream* (finish-stream *current-stream*)) 89 | (let ((new (make-streamer song)) 90 | (start-at (song-start-time song))) 91 | (setf *current-stream* new) 92 | (mixer-add-streamer *mixer* *current-stream*) 93 | (when start-at 94 | ;; Race conditions are fun. 95 | (streamer-seek new *mixer* (* start-at (mixer-rate *mixer*)))) 96 | ;; Success: Return the streamer. 97 | new)) 98 | (error (err) 99 | (princ err) 100 | ;; Failure: Return NIL. 101 | nil)) 102 | 103 | (update-status-bar))) 104 | 105 | (defun play-songs (songs) 106 | "Prepend songs to the queue and play the first one immediately." 107 | (when-playing (stream) (end-stream stream)) 108 | (with-playqueue () 109 | (setf *playqueue* (concatenate 'list songs *playqueue*))) 110 | (play-next-song)) 111 | 112 | (defun add-songs (songs) 113 | "Append songs to queue, beginning playback if stopped." 114 | (with-playqueue () 115 | (setf *playqueue* (concatenate 'list *playqueue* songs))) 116 | (unless (current-song-playing) (play-next-song))) 117 | 118 | (defun skip-song () 119 | (when-playing (stream) (end-stream stream)) 120 | (play-next-song)) 121 | 122 | (defun play-next-song () 123 | ;; If a song is playing, finish it first. This will ensure that a 124 | ;; looping song is put back on the (potentially empty) queue before 125 | ;; we try to pop the next song from the queue. 126 | (when-playing (stream) (finish-stream stream)) 127 | (with-playqueue () 128 | (cond 129 | (*playqueue* 130 | ;; Try songs from the queue until one starts successfully. 131 | ;; In loop mode, songs that fail to start are dropped from the queue. 132 | (loop as next = (pop *playqueue*) 133 | until (or (null next) (play-song next)))) 134 | ;; If there's no song in the queue, finish the current song. 135 | (t (with-stream-control () 136 | (when *current-stream* (finish-stream *current-stream*))))))) 137 | 138 | 139 | (defmethod streamer-cleanup :after ((stream shuffletron-stream-mixin) mixer) 140 | (declare (ignore mixer)) 141 | ;; The STOPPED flag distinguishes whether playback was interrupted 142 | ;; by the user, versus having reached the end of the song. If we're 143 | ;; supposed to loop, this determines who is responsible for making 144 | ;; that happen. 145 | (when (and *loop-mode* (not (stopped stream))) 146 | (with-playqueue () 147 | (setf *playqueue* (append *playqueue* (list (song-of stream)))))) 148 | ;; If stopped is set, someone else can be expected to start up the 149 | ;; next song. Otherwise, we have to do it ourselves. 150 | (unless (stopped stream) 151 | ;; If the song completed: 152 | (with-stream-control () 153 | (when (eq stream *current-stream*) 154 | (setf *current-stream* nil))) 155 | ;; We do this call in new thread, because we are in the mixer 156 | ;; thread here, and scanning the next file could take long enough 157 | ;; to stall it. 158 | (bordeaux-threads:make-thread 159 | (lambda () (play-next-song))))) 160 | 161 | (defun toggle-pause () 162 | (with-stream-control () 163 | (when *current-stream* 164 | (if (streamer-paused-p *current-stream* *mixer*) 165 | (streamer-unpause *current-stream* *mixer*) 166 | (streamer-pause *current-stream* *mixer*))))) 167 | 168 | (defun unpause () 169 | (with-stream-control () 170 | (when *current-stream* 171 | (when (streamer-paused-p *current-stream* *mixer*) 172 | (streamer-unpause *current-stream* *mixer*) 173 | t)))) 174 | 175 | (defun current-song-playing () 176 | (when-playing (stream) (song-of stream))) 177 | 178 | (defun playqueue-and-current () 179 | (let ((song (current-song-playing))) 180 | (if song 181 | (cons song *playqueue*) 182 | *playqueue*))) 183 | 184 | (defun queue-remove-songs (songs) 185 | (let ((set (build-sequence-table songs #'identity #'eq))) 186 | (with-playqueue () 187 | (setf *playqueue* 188 | (remove-if (lambda (song) (gethash song set)) *playqueue*))))) 189 | 190 | (defun queue-remove-indices (indices) 191 | (with-playqueue () 192 | (setf *playqueue* (list-remove-by-index *playqueue* indices)))) 193 | 194 | (defun list-remove-by-index (list indices) 195 | (loop with seq = list 196 | with list = (sort (remove-duplicates indices) #'<) 197 | for index upfrom 0 198 | for song in seq 199 | unless (and (eql index (car list)) (pop list)) 200 | collect song)) 201 | 202 | (defun stop-command () 203 | (with-playqueue () 204 | (with-stream-control () 205 | (when *current-stream* 206 | ;; Put a stopped song on the head of the queue, so that a 207 | ;; subsequent 'play' will start it from the beginning. 208 | (push (song-of *current-stream*) *playqueue*) 209 | (end-stream *current-stream*))))) 210 | 211 | (defun play-command () 212 | (unless (unpause) 213 | (or (current-song-playing) (play-next-song)))) 214 | -------------------------------------------------------------------------------- /src/global.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;;; Global state, related macros, whatever. 4 | 5 | (defparameter *shuffletron-version* 6 | (asdf:component-version (asdf:find-system "shuffletron"))) 7 | 8 | (defvar *argv* nil) 9 | (defvar *debug-mode* nil) 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/help.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | (defun print-usage-message () 4 | (format t "Usage: shuffletron [options] 5 | --profile name Use alternate library/profile 6 | --list List available profiles 7 | --version Display program version 8 | --help Display this message 9 | ")) 10 | 11 | (defun print-help () 12 | (format t " 13 | Shuffletron is a text-mode music player oriented around search and 14 | tagging. Its principle of operation is simple: search for songs, then 15 | play them. Searches are performed by typing a / followed by the search 16 | string: 17 | 18 | library> /chromeo 19 | fa 0 Chromeo, She's In Control, 10: Ah Oui Comme Ca 20 | fa 1 \"........................\" 1: My And My Man 21 | fa 2 \"........................\" 2: Needy Girl 22 | fa 3 \"........................\" 3: You're So Gangsta 23 | fa 4 \"........................\" 4: Woman Friend 24 | fa 5 \"........................\" 7: Since You Were Gone 25 | fa 6 \"........................\" 8: Way Too Much 26 | fa 7 \"........................\" 9: Mercury Tears 27 | f b 8 DJ Mehdi, I Am Somebody featuring Chromeo, 2: I Am Somebody (Montreal Version) 28 | 9 matches> 29 | 30 | If ID3 tags are present, songs are presented in the following form: 31 | 32 | Artist, [Album,] [Track:] Title 33 | 34 | Although not shown above, artist names are color coded red, album 35 | names yellow, and song titles white. In successive lines with the 36 | same artist or album and artist, the redundant fields are elided. If 37 | ID3 information on the artist and title is not available, the filename 38 | is printed instead. 39 | 40 | In the leftmost column is some subset of the letters 'f', 'a', 'b', 41 | and 't'. These indicate which fields matched the query string, as 42 | follows: 43 | 44 | f: Filename 45 | a: Artist 46 | b: Album 47 | t: Title 48 | 49 | Following this is a column of numbers, starting from zero. These allow 50 | you to choose songs to play as comma (or space) delimited numbers or 51 | ranges of numbers. If the song is already in the queue, the number is 52 | highlighted in bold white text. Here, I decide to play song 8 then 0-3 53 | by entering this at the prompt: 54 | 55 | 9 matches> 8, 0-3 56 | 57 | The currently playing song is interrupted, and the chosen songs are 58 | added to the head of the playback queue. To see the contents of the 59 | queue, use the 'queue' command: 60 | 61 | 9 matches> queue 62 | (0) Chromeo, She's In Control, 10: Ah Oui Comme Ca 63 | (1) \"........................\" 1: My And My Man 64 | (2) \"........................\" 2: Needy Girl 65 | (3) \"........................\" 3: You're So Gangsta 66 | 67 | Notice that the prompt changed from \"library>\" to \"\9 matches>\" 68 | after our initial search. Successive searches refine the result of 69 | previous searches, and the prompt indicates the number of items you're 70 | currently searching within. If there had been more than 50 matches, 71 | they would not be printed by default, but you could use the 'show' 72 | command at any time to print them. Also note that the 'queue' command 73 | doesn't disrupt the current search results (this is why numbering in 74 | the queue listing is surrounded with parentheses, to indicate that 75 | entering numbers for playback does not refer to them). 76 | 77 | To add songs to the queue without interrupting the current song, 78 | prefix the song list with \"+\" (to append) or \"pre\" (to prepend). 79 | 80 | The queue can be cleared with the 'clear' command, and the 'skip' 81 | command skips the current song and advances to the next song in the 82 | queue. The 'next' command is similar, but differs when looping is 83 | enabled: 'next' retains the current song at the end of the queue so it 84 | will play again, 'skip' does not. 85 | 86 | The \"loop\" command toggles looping mode. In looping mode, songs are 87 | returned to the end of the queue when they finish playing, or when 88 | they are bypassed using the 'next' command. 89 | 90 | When you've completed a search, a single blank line moves backward 91 | through the search history, eventually returning to the \"library>\" 92 | prompt. 93 | 94 | If you've imported a large library, the ID3 tags may not have been 95 | scanned. In this case, the program will suggest that you run the 96 | scanid3 command. Scanning ID3 tags can be very time consuming, as 97 | each file must be opened and read from. Once scanned, ID3 information 98 | is remembered by caching it in the ~~/.shuffletron/id3-cache file, so 99 | you only need to do this the first time you run the program. ID3 tags 100 | of new files are scanned automatically when the program starts unless 101 | there are more than 1,000 new files. 102 | 103 | Additional help topics: 104 | help commands 105 | help examples 106 | help alarms 107 | 108 | ")) 109 | 110 | (defun print-commands () 111 | (format t " 112 | Command list: 113 | 114 | /[query] Search library for [query]. 115 | show Print search matches, highlighting songs in queue. 116 | back Undo last search. 117 | [songs] Play list of songs. 118 | all Play all songs in selection (equivalent to \"0-\") 119 | +[songs] Append list of songs to queue. 120 | pre[songs] Prepend list of songs to queue. 121 | random Play a random song from the current selection. 122 | random QUERY Play a random song matching QUERY 123 | shuffle SONGS Play songs in random order. 124 | 125 | queue Print queue contents and current song playing. 126 | shuffle Randomize order of songs in queue. 127 | clear Clear the queue (current song continues playing) 128 | loop Toggle loop mode (loop through songs in queue) 129 | qdrop Remove last song from queue 130 | qdrop RANGES Remove songs from queue 131 | qtag TAGS Apply tags to all songs in queue 132 | fromqueue Transfer queue to selection 133 | toqueue Replace queue with selection 134 | 135 | now Print name of song currently playing. 136 | play Resume playing 137 | stop Stop playing (current song pushed to head of queue) 138 | pause Toggle paused/unpaused. 139 | skip Skip currently playing song. If looping is enabled, this 140 | song won't played again. 141 | next Advance to next song. If looping is enabled, the current 142 | song will be enqueued. 143 | repeat N Add N repetitions of currently playing song to head of queue. 144 | seek TIME Seek to time (in [h:]m:ss format, or a number in seconds) 145 | seek +TIME Seek forward 146 | seek -TIME Seek backward 147 | startat TIME Always start playback at a given time (to skip long intros) 148 | 149 | tag List tags of currently playing song. 150 | tag TAGS Add one or more textual tags to the current song. 151 | untag TAGS Remove the given tags from the currently playing song. 152 | tagged TAGS Search for files having any of specified tags. 153 | tags List all tags (and # occurrences) within current query. 154 | killtag TAGS Remove all occurances of the given tags 155 | tagall TAGS Apply tags to all selected songs 156 | untagall TAGS Remove given tags from all selected songs 157 | 158 | time Print current time 159 | alarm Set alarm (see \"help alarms\") 160 | 161 | scanid3 Scan new files for ID3 tags 162 | prescan Toggle file prescanning (useful if file IO is slow) 163 | exit Exit the program. 164 | 165 | help [topic] Help 166 | ")) 167 | 168 | (defun print-examples () 169 | (format t " 170 | How to find and play a song, then return to library mode: 171 | 172 | library> /vampire sushi 173 | f t 0 Old Time Relijun, Witchcraft Rebellion, 3: Vampire Sushi 174 | 175 | 1 matches> 0 176 | 1 matches> 177 | library> 178 | 179 | How to refine search results: 180 | 181 | library> /beatles 182 | 223 matches> /window 183 | fa t 0 Beatles, The, Abbey Road, 13: She Came In Through The Bathroom Window 184 | 1 matches> 185 | 186 | How to play your entire library in shuffle mode: 187 | 188 | First, ensure you are at the \"library\" prompt. If the prompt reads 189 | differently, hit enter until it reappears. 190 | 191 | library> shuffle 0- 192 | 193 | ")) 194 | 195 | (defun print-alarm-help () 196 | (format t " 197 | The \"alarm\" command provides an alarm clock feature which will play 198 | music when the scheduled wakeup time is reached. There is a single 199 | wakeup time, and when it is reached the wakeup time is cleared. When 200 | the alarm is triggered, the music player will do one of the following: 201 | 202 | 1) If playback is paused, unpause the player. 203 | 2) Otherwise, prepend ten random songs to queue and play them. 204 | 205 | With no argument, the \"alarm\" command prints the current wakeup 206 | time. An argument to the command specifies the wakeup time. This can 207 | be done in a variety of formats: 208 | 209 | alarm at 7:45 am # \"at\" is optional and doesn't change the meaning 210 | alarm 7:45 am 211 | alarm 9 pm 212 | alarm 7 # If AM/PM not specified, assumes AM 213 | alarm in 5 minutes # Relative alarm times, in minutes or hours 214 | alarm in 10m # minutes, minute, mins, min, , m are synonyms 215 | alarm in 7 hours # hours, hour, hr, h are synonyms 216 | alarm in 8h 217 | alarm in 7:29 # h:mm format - seven hours, twenty-nine minutes 218 | alarm reset # off/never/delete/disable/cancel/clear/reset 219 | 220 | If the player is already playing when the alarm goes off, the song 221 | already playing will be interrupted by the next song in the queue. 222 | ")) 223 | 224 | ;;; TODO: Somewhere, the help should mention the 'ignore' tag, but 225 | ;;; there's currently no prose dedicated to tagging where it would 226 | ;;; make sense to mention it. 227 | -------------------------------------------------------------------------------- /src/library.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | (defvar *library* nil) 4 | (defvar *filtered-library* nil "The library, excluding songs tagged 'ignore'") 5 | (defvar *local-path->song* (make-hash-table :test 'equal)) 6 | (define-symbol-macro *library-base* (pref "library-base")) 7 | 8 | (defstruct song full-path local-path tags smashed properties matchprops id3 id3-p) 9 | 10 | (defun init-library () 11 | (setf *library* (make-array 0 :fill-pointer 0 :adjustable t))) 12 | 13 | (defun match-extension (filename extension) 14 | (not (mismatch filename extension :test #'char-equal :start1 (- (length filename) (length extension))))) 15 | 16 | (defun music-file-type (filename) 17 | (or (and (match-extension filename ".mp3") :mp3) 18 | (and (match-extension filename ".ogg") :ogg) 19 | (and (match-extension filename ".flac") :flac))) 20 | 21 | (defvar *library-progress* 0) 22 | 23 | (defun smash-string (string) 24 | (substitute #\Space #\_ (string-downcase string))) 25 | 26 | (defun carriage-return () (format t "~C" (code-char 13))) 27 | 28 | (defun add-song-file (full-filename relative-filename) 29 | (let ((song (make-song :full-path full-filename 30 | :local-path relative-filename 31 | :smashed (smash-string relative-filename) 32 | :tags nil))) 33 | (vector-push-extend song *library*) 34 | (setf (gethash (song-local-path song) *local-path->song*) song))) 35 | 36 | (defun library-scan (path) 37 | (let ((*library-progress* 0)) 38 | (clrhash *local-path->song*) 39 | (when (probe-file path) 40 | (walk path 41 | (lambda (filename) 42 | (when (music-file-type filename) 43 | (incf *library-progress*) 44 | (when (zerop (mod *library-progress* 10)) 45 | (carriage-return) 46 | (format t "Scanning. ~:D files.." *library-progress*) 47 | (force-output)) 48 | (add-song-file filename (relative-to path filename))))) 49 | t))) 50 | 51 | (defun songs-needing-id3-scan () (count-if-not #'song-id3-p *library*)) 52 | 53 | (defun save-metadata-cache () 54 | (setf (pref "id3-cache") 55 | (map 'vector (lambda (song) (list (song-local-path song) 56 | (song-id3-p song) 57 | (song-id3 song))) 58 | *library*)) 59 | (values)) 60 | 61 | (defun load-metadata-cache () 62 | (loop for (name id3-p id3) across (pref "id3-cache" #()) 63 | as song = (gethash name *local-path->song*) 64 | when (and song id3-p) 65 | do (setf (song-id3-p song) t 66 | (song-id3 song) id3))) 67 | 68 | (defun get-song-metadata (absolute-path) 69 | (ignore-errors ; I'm a bad person. 70 | (case (music-file-type absolute-path) 71 | (:mp3 (mpg123:get-tags-from-file absolute-path :no-utf8 t)) 72 | ;; FIXME: Audit OGG/FLAC paths for unicode insanity. 73 | (:ogg (vorbisfile:get-vorbis-tags-from-file absolute-path)) 74 | (:flac (flac:get-flac-tags-from-file absolute-path))))) 75 | 76 | (defun scan-file-metadata (&key verbose adjective) 77 | (format t "~&Scanning file metadata (~:D files).~%" (songs-needing-id3-scan)) 78 | (when verbose (fresh-line)) 79 | (loop with pending = (and verbose (songs-needing-id3-scan)) 80 | with n = 1 81 | for song across *library* 82 | unless (song-id3-p song) do 83 | (when verbose 84 | (carriage-return) 85 | (format t "Reading ~Atags: ~:D of ~:D" (or adjective "") n pending) 86 | (force-output)) 87 | (setf (song-id3 song) (get-song-metadata (song-full-path song)) 88 | (song-matchprops song) nil 89 | (song-id3-p song) t) 90 | (incf n) 91 | finally 92 | (when (and pending (not (zerop pending))) (terpri))) 93 | (save-metadata-cache)) 94 | 95 | (defun compute-filtered-library () 96 | (setf *filtered-library* (remove-if (lambda (song) (find "ignore" (song-tags song) :test #'string=)) *library*))) 97 | -------------------------------------------------------------------------------- /src/main.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | (defun configure-library-path () 4 | "Prompt user and verify library path" 5 | (init-library) 6 | (block nil 7 | (let ((path *library-base*)) 8 | (unless (and (stringp path) 9 | (> (length path) 0)) 10 | (format t "~&Enter library path: ") 11 | (setf path (join-paths (getline) ""))) 12 | (when (and path (null (probe-file path))) 13 | (format t "~&Can't open directory ~A~%" path) 14 | (return)) 15 | (setf path (osicat-sys:native-namestring (probe-file path))) 16 | (when (and path (not (library-scan path))) 17 | (format t "Unable to scan \"~A\"~%" path) 18 | (return)) 19 | (when (emptyp *library*) 20 | (format t "No playable files found in \"~A\"~%" path)) 21 | ;; Success! 22 | path))) 23 | 24 | (defun init () 25 | (setf *package* (find-package :shuffletron)) 26 | (parse-command-line-args) 27 | (format t "~&This is Shuffletron ~A~%" *shuffletron-version*) 28 | (setf *random-state* (make-random-state t)) 29 | (loop 30 | do (setf *library-base* (configure-library-path)) 31 | until *library-base*) 32 | (format t "~CLibrary contains ~:D files. ~%" 33 | (code-char 13) (length *library*)) 34 | (load-tags) 35 | (compute-filtered-library) 36 | (load-metadata-cache) 37 | (reset-query) 38 | ;; Scan tags of new files automatically, unless there's a ton of them. 39 | (let ((need (songs-needing-id3-scan))) 40 | (cond 41 | ((zerop need)) 42 | ((> need 1000) 43 | (format t "~:D new songs need to be scanned for ID3 tags. To do this now, 44 | type \"scanid3\". It may take a moment.~%" 45 | (songs-needing-id3-scan))) 46 | (t (scan-file-metadata :verbose t :adjective "new "))))) 47 | 48 | (defun spooky-init () 49 | (let ((stream #+sbcl (sb-sys:make-fd-stream 1 :external-format :latin1 :output t :input nil) 50 | #+ccl (ccl::make-fd-stream 1 :direction :io :sharing :lock :encoding :iso-8859-1))) 51 | (setf *standard-output* stream) 52 | (setf *error-output* stream) 53 | #+sbcl 54 | (sb-sys:enable-interrupt 55 | sb-unix:sigint 56 | (lambda (&rest args) 57 | (declare (ignore args)) 58 | (sb-ext:exit :abort t))))) 59 | 60 | (defun quit () 61 | ;; (format t "Bye.~%") 62 | (finish-output) 63 | #+sbcl (sb-ext:exit :abort t) 64 | #+ccl (ccl:quit)) 65 | 66 | (defun eval* (string) 67 | "Read a form from STRING, evaluate it in the Shuffletron 68 | package and print the result." 69 | (print (eval (read-from-string string))) 70 | (terpri)) 71 | 72 | (defun show-current-query () 73 | (if (emptyp *selection*) 74 | (format t " Nothing matches the current query.~%") 75 | (show-song-matches *selection* :mode :query :highlight-queue t))) 76 | 77 | (defun do-seek (args) 78 | (let* ((current *current-stream*) 79 | (mode-char (and args (find (elt args 0) "+-"))) 80 | (time-arg (if mode-char 81 | (string-trim " " (subseq args 1)) 82 | args)) 83 | (seconds (and args (parse-timespec time-arg))) 84 | (samples (and seconds (* (mixer-rate *mixer*) seconds))) 85 | (base (if (not mode-char) 86 | samples 87 | (and current (streamer-position current *mixer*)))) 88 | (offset (cond ((eql mode-char #\+) samples) 89 | ((eql mode-char #\-) (- samples)) 90 | (t 0))) 91 | (time (and base offset (+ base offset)))) 92 | (cond 93 | ((null current) (format t "No song is playing.~%")) 94 | ((null time) (format t "Seek to where?~%")) 95 | (time (streamer-seek current *mixer* (max time 0))) 96 | (t nil)))) 97 | 98 | (defun parse-and-execute (line) 99 | (let* ((sepidx (position #\Space line)) 100 | (command (subseq line 0 sepidx)) 101 | (args (and sepidx (string-trim " " (subseq line sepidx))))) 102 | (cond 103 | 104 | ;; Back: restore previous selection. 105 | ;; A blank input line is a synonym for the "back" command. 106 | ((or (emptyp line) (string= line "back")) 107 | (cond 108 | (*selection-history* (setf *selection* (pop *selection-history*))) 109 | (t (reset-query)))) 110 | 111 | ;; Lisp evaluation 112 | ((string= command "eval") 113 | (eval* args)) 114 | 115 | ((equal (subseq line 0 1) "(") 116 | (eval* line)) 117 | 118 | ;; Input starting with a forward slash refines the current query. 119 | ((char= (aref line 0) #\/) (refine-query (subseq line 1))) 120 | 121 | ;; Show all matches 122 | ((or (string= line "show") (string= line "ls")) 123 | (show-current-query)) 124 | 125 | ;; Quit 126 | ((or (string= line "quit") (string= line "exit")) (quit)) 127 | 128 | ;; Play songs now (first is played, subsequent are added to queue 129 | ((digit-char-p (aref line 0)) 130 | (play-songs (selection-songs line))) 131 | 132 | ;; Play all songs now 133 | ((string= line "all") 134 | (play-songs *selection*)) 135 | 136 | ;; Append songs and end of playqueue 137 | ((and (> (length line) 1) (char= #\+ (aref line 0))) 138 | (add-songs (selection-songs (subseq line 1)))) 139 | 140 | ;; Prepend songs to playqueue 141 | ((and (>= (length line) 4) 142 | (string= "pre" (subseq line 0 3)) 143 | (or (digit-char-p (aref line 3)) 144 | (char= #\Space (aref line 3)))) 145 | (with-playqueue () 146 | (setf *playqueue* (concatenate 'list 147 | (selection-songs (subseq line 3)) 148 | *playqueue*))) 149 | (unless (current-song-playing) (play-next-song))) 150 | 151 | ;; Skip current song. If looping, don't play this again. 152 | ((string= line "skip") 153 | (skip-song) 154 | (show-current-song)) 155 | 156 | ;; Advance to next song in queue. Differs from 'skip' only if 157 | ;; looping is enabled: whereas 'skip' drops the song from the 158 | ;; queue, 'next' puts it at the end of the queue. 159 | ((string= line "next") 160 | (play-next-song) 161 | (show-current-song)) 162 | 163 | ;; Pause playback 164 | ((string= line "pause") 165 | (toggle-pause) 166 | (update-status-bar)) 167 | 168 | ;; Stop 169 | ((string= line "stop") 170 | (stop-command)) 171 | 172 | ;; Play 173 | ((string= line "play") 174 | (play-command)) 175 | 176 | ;; Seek 177 | ((string= command "seek") (do-seek args)) 178 | 179 | ;; Start at 180 | ((string= command "startat") 181 | (let* ((time (and args (parse-timespec args))) 182 | (playing (current-song-playing)) 183 | (cur-start (and playing (song-start-time playing)))) 184 | (cond 185 | ((and cur-start (null time)) 186 | (format t "Start time for the current song is ~A~%" 187 | (time->string cur-start))) 188 | ((and playing (null time)) 189 | (format t "No start time for this song is set.~%")) 190 | ((not playing) (format t "No song is playing.~%")) 191 | ((null time) (format t "Set start time to when?~%")) 192 | (t (setf (song-start-time playing) time))))) 193 | 194 | ;; Random 195 | ((string= line "random") 196 | (cond 197 | ((emptyp *filtered-library*) (format t "The library is empty.~%")) 198 | ((emptyp *library*) (format t "All songs in the library are ignored.~%")) 199 | (t (play-song (alexandria:random-elt (if (emptyp *selection*) *filtered-library* *selection*))))) 200 | (show-current-song)) 201 | 202 | ;; Random from query 203 | ((string= command "random") 204 | (let ((matches (query args))) 205 | (cond 206 | ((emptyp matches) 207 | (format t "No songs match query.~%")) 208 | (t (play-song (alexandria:random-elt matches)) 209 | (show-current-song))))) 210 | 211 | ;; Show playqueue 212 | ((string= line "queue") (show-playqueue)) 213 | 214 | ;; Show current song 215 | ((string= line "now") (show-current-song)) 216 | 217 | ;; Add tags to current file 218 | ((and (string= command "tag") args) 219 | (tag-current-song args)) 220 | 221 | ;; Show tags 222 | ((and (string= command "tag") (null args)) 223 | (show-current-song-tags)) 224 | 225 | ;; Add tag to all songs in selection 226 | ((string= command "tagall") 227 | (let ((tags (parse-tag-list args))) 228 | (cond 229 | ((null tags) (format t "~&Apply which tags to selected songs?~%")) 230 | (t (dolist (tag tags) (tag-songs *selection* tag)))))) 231 | 232 | ;; Remove tag from all songs in selection 233 | ((string= command "untagall") 234 | (let ((tags (parse-tag-list args))) 235 | (cond 236 | ((null tags) (format t "~&Remove which tags from selected songs?~%")) 237 | (t (dolist (tag tags) (untag-songs *selection* tag)))))) 238 | 239 | ;; Remove tags from current file 240 | ((string= command "untag") 241 | (untag-current-song args)) 242 | 243 | ;; Show songs matching tag(s) 244 | ((string= command "tagged") 245 | (if args 246 | (set-selection (songs-matching-tags (parse-tag-list args))) 247 | (format t "Search for which tags?~%"))) 248 | 249 | ;; List all tags. Also check for dirty tag files, in case another 250 | ;; process modified them. 251 | ((string= line "tags") 252 | (load-tags) 253 | (show-all-tags)) 254 | 255 | ;; Remove all occurances of tag(s) 256 | ((string= command "killtag") 257 | (load-tags) 258 | (map nil #'kill-tag (parse-tag-list args))) 259 | 260 | ;; Clear the queue 261 | ((string= line "clear") 262 | (with-playqueue () 263 | (setf *playqueue* nil))) 264 | 265 | ;; Remove songs from the queue 266 | ((string= line "qdrop") 267 | (queue-remove-indices (list (1- (length *playqueue*))))) 268 | 269 | ((string= command "qdrop") 270 | (queue-remove-indices 271 | (expand-ranges (parse-ranges args 0 (1- (length *playqueue*)))))) 272 | 273 | ;; Tag all songs in queue 274 | ((string= command "qtag") 275 | (with-playqueue () 276 | (dolist (tag (parse-tag-list args)) 277 | (dolist (song *playqueue*) 278 | (tag-song song tag))) 279 | (format t "Tagged ~:D songs in queue: ~{~A~^, ~}~%" 280 | (length *playqueue*) (mapcar #'decode-as-filename (parse-tag-list args))))) 281 | 282 | ;; Queue to selection 283 | ((string= line "fromqueue") 284 | (set-selection (coerce *playqueue* 'vector))) 285 | 286 | ;; Selection to queue 287 | ((string= line "toqueue") 288 | (with-playqueue () 289 | (setf *playqueue* (coerce *selection* 'list)))) 290 | 291 | 292 | ;; Randomize queue 293 | ((string= line "shuffle") 294 | (with-playqueue () 295 | (setf *playqueue* (alexandria:shuffle *playqueue*)))) 296 | 297 | ;; Add/play in random order 298 | ((and (string= command "shuffle") args) 299 | (add-songs (alexandria:shuffle (copy-seq (selection-songs args))))) 300 | 301 | ;; Repeat the current song 302 | ((= 6 (or (mismatch "repeat" line) 6)) 303 | (let ((song (current-song-playing)) 304 | (num (or (parse-integer (subseq line 6) :junk-allowed t) 1))) 305 | (cond 306 | ((< num 0) (format t " A negative repeat count doesn't make sense.~%")) 307 | ((not song) (format t " No song is playing!~%")) 308 | (song 309 | (format t " Repeating ~D time~:P: ~A~%" num (song-local-path song)) 310 | (with-playqueue () 311 | (setf *playqueue* (nconc (make-list num :initial-element song) 312 | *playqueue*))))))) 313 | ;; Toggle loop mode 314 | ((string= line "loop") 315 | (setf *loop-mode* (not *loop-mode*)) 316 | (format t "Loop mode ~A~%" (if *loop-mode* "enabled" "disabled"))) 317 | 318 | ;; Print current time 319 | ((string= line "time") (print-time)) 320 | 321 | ;; Set alarm clock 322 | ((string= command "alarm") 323 | (do-set-alarm args)) 324 | 325 | ;; Help 326 | ((string= line "help") (print-help)) 327 | 328 | ;; Help: Commands 329 | ((and (string= command "help") 330 | (equalp args "commands")) (print-commands)) 331 | 332 | ;; Help: Examples 333 | ((and (string= command "help") 334 | (equalp args "examples")) (print-examples)) 335 | 336 | ;; Help: Alarms 337 | ((and (string= command "help") 338 | (equalp args "alarms")) (print-alarm-help)) 339 | 340 | ((string= command "help") 341 | (format t "Unknown help topic ~W~%" args)) 342 | 343 | ;; Scan new ID3 tags 344 | ((string= line "scanid3") 345 | (scan-file-metadata :verbose t)) 346 | 347 | ;; Clear and rescan ID3 tags 348 | ((string= line "rescanid3") 349 | (loop for song across *library* do (setf (song-id3-p song) nil)) 350 | (scan-file-metadata :verbose t)) 351 | 352 | ;; Attempt to start swank server, for development. 353 | ((string= line "swankme") 354 | ;; Work around an SBCL feature(?) in embedded cores: 355 | #+SBCL (cffi:foreign-funcall "setenv" :string "SBCL_HOME" :string "/usr/local/lib/sbcl/" :int 0 :int) 356 | (asdf:oos 'asdf:load-op :swank) 357 | (eval (read-from-string "(swank:create-server :port 0)"))) 358 | 359 | ;; Toggle file prescanning 360 | ((string= line "prescan") 361 | (setf (pref "prescan") (not (pref "prescan" t))) 362 | (if (pref "prescan") 363 | (format t "~&Prescanning enabled. This ensures track lengths and seeks are accurate.~%") 364 | (format t "~&Prescanning disabled. This eliminates the delay when initially starting 365 | playback, and is useful for slow disks or network file systems.~%"))) 366 | 367 | ;; ??? 368 | (t (format t "Unknown command ~W. Try 'help'.~%" line))))) 369 | 370 | (defun mainloop () 371 | (loop 372 | ;; Show the current query, if there aren't too many items: 373 | (when (and *selection-changed* (<= (length *selection*) *max-query-results*)) 374 | (show-current-query)) 375 | (setf *selection-changed* nil) 376 | ;;(update-status-bar) 377 | ;; Prompt 378 | (with-output () 379 | (format t "~A> " (if (querying-library-p) 380 | "library" 381 | (format nil "~:D matches" (length *selection*)))) 382 | (force-output)) 383 | ;; Input 384 | (let ((line (getline))) 385 | (flet ((cmd () 386 | (with-output () 387 | (update-terminal-size) 388 | (parse-and-execute (string-trim " " line))))) 389 | (if *debug-mode* 390 | (cmd) 391 | (handler-case (cmd) 392 | (error (c) 393 | (with-output () 394 | (format t "~&Oops! ~A~%" c))))))))) 395 | 396 | (defun run () 397 | (spooky-init) 398 | (mixalot:main-thread-init) 399 | ;; (Don't) Clear the screen first: 400 | #+ONSECONDTHOUGHT (format t "~C[2J~C[1;1H" #\Esc #\Esc) 401 | #+SBCL (setf *argv* (rest sb-ext:*posix-argv*)) 402 | #-SBCL (warn "*argv* not implemented for this CL implementation.") 403 | (init) 404 | (audio-init) 405 | (mainloop)) 406 | -------------------------------------------------------------------------------- /src/packages.lisp: -------------------------------------------------------------------------------- 1 | (defpackage :shuffletron 2 | (:use :common-lisp :mixalot) 3 | (:export #:run #:*shuffletron-version* 4 | #:emptyp 5 | #:walk #:rel #:dfn 6 | #:*profile* #:pref #:prefpath 7 | #:*library* #:*filtered-library* #:*library-base* 8 | #:song #:song-full-path #:song-local-path #:song-tags 9 | #:song-properties #:song-id3 #:song-id3-p 10 | #:song-start-time 11 | #:songs-matching-tags #:songs-matching-tag 12 | #:tag-songs #:tag-song #:untag-songs #:untag-song 13 | #:decode-as-filename #:encode-as-filename 14 | #:*selection* #:selection-history* 15 | #:querying-library-p #:set-selection 16 | #:reset-query #:refine-query #:query 17 | #:with-stream-control #:with-playqueue #:when-playing 18 | #:*mixer* #:*current-stream* #:*playqueue* 19 | #:song-of #:stopped 20 | #:*loop-mode* #:*wakeup-time* 21 | #:end-stream #:finish-stream 22 | #:play-song #:play-songs #:add-songs #:play-next-song #:skip-song 23 | #:play-command #:stop-command 24 | #:toggle-pause #:unpause 25 | #:current-song-playing 26 | #:playqueue-and-current 27 | #:queue-remove-songs #:queue-remove-indices 28 | #:parse-item-list #:parse-tag-list 29 | #:tag-current-song #:untag-current-song 30 | #:kill-tag #:tag-count-pairs 31 | #:parse-ranges #:expand-ranges #:extract-ranges 32 | #:sgr #:spacing 33 | #:time->string #:utime->string #:parse-relative-time 34 | #:parse-alarm-args 35 | #:parse-and-execute)) 36 | -------------------------------------------------------------------------------- /src/profiles.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;; Profile support (for multiple libraries in different 4 | ;;; locations). The "default" profile stores its settings directly 5 | ;;; under ~/.shuffletron/ for backward compatibility with existing 6 | ;;; settings. Alternate profiles store them under 7 | ;;; ~/.shuffletron/profiles//. 8 | 9 | (defvar *profile* "default") 10 | 11 | (defun subpath (list) (subseq list 0 (1- (length list)))) 12 | 13 | (defun prefpath (prefname &key (profile *profile*)) 14 | (let ((name (if (listp prefname) (car (last prefname)) prefname)) 15 | (subpath (if (listp prefname) (butlast prefname) nil)) 16 | (*profile* profile)) 17 | (merge-pathnames 18 | (make-pathname :directory `(:relative ".shuffletron" "profiles" ,*profile* ,@(mapcar #'string subpath)) 19 | :name (and name (string name))) 20 | (user-homedir-pathname)))) 21 | 22 | (defun pref (name &optional default) 23 | (handler-case (values (file (prefpath name)) t) 24 | (file-error (c) 25 | (when (probe-file (prefpath name)) 26 | (format t "Problem reading ~A:~%~A~%" (prefpath name) c)) 27 | (values default nil)) 28 | (reader-error (c) 29 | (format t "Error parsing contents of ~A:~%~A~%" (prefpath name) c) 30 | (values default nil)))) 31 | 32 | (defun (setf pref) (value name) 33 | (ensure-directories-exist (prefpath name)) 34 | (setf (file (prefpath name)) value)) 35 | 36 | (defun all-profiles () 37 | (mapcar (lambda (x) (car (last (pathname-directory x)))) 38 | (directory 39 | (merge-pathnames 40 | (make-pathname :directory '(:relative ".shuffletron" "profiles" :wild-inferiors) 41 | :name "library-base") 42 | (user-homedir-pathname))))) 43 | 44 | (defun get-profile-base (profile-name) 45 | (let ((*profile* profile-name)) 46 | (pref "library-base"))) 47 | 48 | -------------------------------------------------------------------------------- /src/query.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | (defvar *selection* nil) 4 | (defvar *selection-changed* nil) 5 | (defvar *selection-history* nil) 6 | (defvar *history-depth* 16) 7 | 8 | (defun querying-library-p () (= (length *selection*) (length *filtered-library*))) 9 | 10 | (defun set-selection (new-selection &key (record t)) 11 | (when record 12 | (push *selection* *selection-history*) 13 | (when (> (length *selection-history*) *history-depth*) 14 | (setf *selection-history* (subseq *selection-history* 0 *history-depth*)))) 15 | (setf *selection* new-selection 16 | *selection-changed* t) 17 | (values)) 18 | 19 | (defun reset-query () 20 | (set-selection (copy-seq *filtered-library*) :record nil) 21 | (loop for x across *library* do (setf (song-matchprops x) nil))) 22 | 23 | (defmacro any (&body forms) 24 | "Similar to the OR macro, but doesn't short-circuit." 25 | (let ((syms (loop repeat (length forms) collect (gensym "ANY")))) 26 | `((lambda ,syms (or ,@syms)) ,@forms))) 27 | 28 | (defun do-query (substring update-highlighting) 29 | (declare (optimize (speed 3))) 30 | ;; Query and update highlighting: 31 | (loop for song across *selection* 32 | with query = (coerce (string-downcase substring) 'simple-string) 33 | with new-selection = (make-array 0 :adjustable t :fill-pointer 0) 34 | do 35 | (labels 36 | ((field (keyword) 37 | (let* ((string (if (eql keyword :filename) 38 | (song-local-path song) 39 | (getf (song-id3 song) keyword))) 40 | (searchable (if (eql keyword :filename) 41 | (song-smashed song) 42 | string)) 43 | (found nil)) 44 | (when searchable 45 | ;; There's no guarantee that this really holds... 46 | (check-type searchable simple-string) 47 | ;; The case insensitive search is painfully slow, so shortcut 48 | ;; around it for filenames, which are already downcased. 49 | (if (eql keyword :filename) 50 | (setf found (search query searchable)) 51 | (setf found (search query searchable :key #'char-downcase)))) 52 | (when (and found update-highlighting) 53 | (setf (getf (song-matchprops song) keyword) 54 | (fill (the (simple-array bit 1) 55 | (or (getf (song-matchprops song) keyword) 56 | (make-array (length string) :element-type 'bit))) 57 | 1 :start found :end (+ found (length query))))) 58 | found))) 59 | (when (any (field :filename) 60 | (field :artist) 61 | (field :album) 62 | (field :title)) 63 | (vector-push-extend song new-selection))) 64 | finally (return new-selection))) 65 | 66 | (defun refine-query (substring) 67 | (set-selection (do-query substring t))) 68 | 69 | (defun query (substring) (do-query substring nil)) 70 | -------------------------------------------------------------------------------- /src/status-bar.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;;; Experimental status bar code: 4 | 5 | (defun save-cursor () (format t "~C[s" #\Esc)) 6 | (defun restore-cursor () (format t "~C[u" #\Esc)) 7 | 8 | (defun move-cursor (col row) 9 | (format t "~C[~D;~DH" #\Esc row col)) 10 | 11 | ;;; Debugging cruft. There's a strange issue where the program freezes 12 | ;;; in the call to finish-output for a number of seconds (10? 20?) if 13 | ;;; the alarm thread attempts to redraw the status bar while the other 14 | ;;; thread is doing output. I tried to use the output lock to protect 15 | ;;; access to the terminal, but perhaps I missed something (or that 16 | ;;; isn't the problem). 17 | 18 | (defvar *funcounter* 0) 19 | (defvar *waitcounter* 0) 20 | (defvar *donecounter* 0) 21 | 22 | (defun update-status-bar () 23 | ;; The status bar is now disabled. 24 | ;; To enable it, call DISPLAY-STATUS-BAR here. 25 | (values)) 26 | 27 | (defun display-status-bar () 28 | (with-output () 29 | (save-cursor) 30 | (update-terminal-size) 31 | (format t "~C[~D;~Dr" #\Esc 2 *term-rows*) ; Set scrolling window 32 | (format t "~C[6p" #\Esc) ; Hide cursor 33 | (move-cursor 1 1) 34 | (sgr '(44 97)) 35 | (format t "~C[0K" #\Esc) ; Clear line (sort of, not really) 36 | (let* ((count 1) 37 | (wakeup *wakeup-time*) 38 | (remaining (and wakeup (- wakeup (get-universal-time)))) 39 | (need-seperator wakeup)) 40 | 41 | (flet ((output (fmt &rest args) 42 | (let* ((string (apply #'format nil fmt args)) 43 | (nwrite (min (max 0 (- *term-cols* count)) 44 | (length string)))) 45 | (incf count (length string)) 46 | (write-string (subseq string 0 nwrite))))) 47 | (cond 48 | ((and wakeup (< remaining 3600)) 49 | (output "Alarm in ~D m" (round remaining 60))) 50 | (wakeup 51 | (output "Alarm in ~,1F hrs" (/ remaining 3600.0)))) 52 | 53 | (let* ((stream *current-stream*) 54 | (song (and stream (song-of stream)))) 55 | (when song 56 | (when need-seperator (output " | ")) 57 | (let* ((id3 (song-id3 song)) 58 | (artist (getf id3 :artist)) 59 | (album (getf id3 :album)) 60 | (title (getf id3 :title))) 61 | (if (streamer-paused-p stream *mixer*) 62 | (output "Paused: ") 63 | (output "Playing: ")) 64 | (cond 65 | ((and artist title) 66 | (output "\"~A\" by " title) 67 | (output "~A" artist) 68 | (when (and album (< (+ 4 (length album)) 69 | (- *term-cols* count))) 70 | (output " (~A)" album))) 71 | (t (output "~A" (song-local-path song))))))) 72 | 73 | (when (= 1 count) 74 | (output "Shuffletron ~A" *shuffletron-version*)) 75 | 76 | (loop while (< count *term-cols*) do (output " ")))) 77 | 78 | (sgr '(0)) 79 | (restore-cursor) 80 | (format t "~C[7p" #\Esc) ; Show cursor 81 | (incf *funcounter*) 82 | (finish-output) 83 | (incf *waitcounter*)) 84 | (incf *donecounter*)) 85 | 86 | ;;; How the hell does a little status bar take that much code to paint? 87 | -------------------------------------------------------------------------------- /src/tags.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;; Tags names have a particular encoding on disk and in memory which 4 | ;;; is distinct from what is seen by the user. This allows us to have 5 | ;;; tags with forward slashes without getting screwed by the filesystem, 6 | ;;; and a tag named by a single asterisk without getting screwed by CCL. 7 | 8 | (defun tag-unescaped-char-p (character) 9 | (or (<= 65 (char-code character) 90) 10 | (<= 97 (char-code character) 122))) 11 | 12 | (defun decode-as-filename (string) 13 | (with-input-from-string (in string) 14 | (with-output-to-string (out) 15 | (loop as next = (peek-char nil in nil) 16 | while next do 17 | (cond ((char= next #\%) 18 | (read-char in) 19 | (write-char (code-char 20 | (logior (ash (digit-char-p (read-char in) 16) 4) 21 | (digit-char-p (read-char in) 16))) 22 | out)) 23 | (t (write-char (read-char in) out))))))) 24 | 25 | (defun encode-as-filename (string) 26 | (with-input-from-string (in string) 27 | (with-output-to-string (out) 28 | (loop as next = (peek-char nil in nil) 29 | while next do 30 | (cond ((tag-unescaped-char-p next) (write-char (read-char in) out)) 31 | (t (read-char in) 32 | (write-char #\% out) 33 | (write-char (digit-char (ldb (byte 4 4) (char-code next)) 16) out) 34 | (write-char (digit-char (ldb (byte 4 0) (char-code next)) 16) out))))))) 35 | 36 | (defun list-tag-files () 37 | (directory (merge-pathnames (make-pathname :name :wild) 38 | (prefpath '("tag" nil))))) 39 | 40 | (defvar *tag-file-registry* (make-hash-table :test 'equal)) 41 | 42 | (defun invalidate-tags () 43 | (clrhash *tag-file-registry*) 44 | (loop for song across *library* do (setf (song-tags song) nil))) 45 | 46 | (defun tag-pathname-dirty-p (pathname) 47 | (not (eql (file-write-date pathname) 48 | (gethash (pathname-name pathname) *tag-file-registry*)))) 49 | 50 | (defun tag-file-dirty-p (tag) 51 | (tag-pathname-dirty-p (prefpath (list "tag" tag)))) 52 | 53 | (defun dirty-tag-pathnames () 54 | (remove-if-not #'tag-pathname-dirty-p (list-tag-files))) 55 | 56 | (defun dirty-tags () 57 | (mapcar #'pathname-name (dirty-tag-pathnames))) 58 | 59 | (defun note-tag-write-time (tag) 60 | (setf (gethash tag *tag-file-registry*) 61 | (file-write-date (prefpath (list "tag" tag))))) 62 | 63 | (defun load-tag-group (tag) 64 | ;; Delete all old occurrences of tag: 65 | (loop for song across *library* 66 | do (alexandria:deletef (song-tags song) tag :test #'string=)) 67 | ;; Apply new tag: 68 | (loop for name across (pref (list "tag" tag)) 69 | as song = (gethash name *local-path->song*) 70 | when song do (pushnew tag (song-tags song) :test #'string=)) 71 | (note-tag-write-time tag)) 72 | 73 | (defun load-tags () 74 | "Load all tags from disk." 75 | (map nil #'load-tag-group (dirty-tags))) 76 | 77 | (defun songs-matching-tags (query) 78 | (remove-if-not (lambda (tags) (intersection query tags :test #'string=)) 79 | *library* :key #'song-tags)) 80 | 81 | (defun songs-matching-tag (tag) (songs-matching-tags (list tag))) 82 | 83 | ;;; Yeah, there's a couple related race conditions with these file 84 | ;;; write times. If you really want to go out of your way to tickle 85 | ;;; them, you might lose some tags. Boo hoo. 86 | 87 | (defun save-tags-list (tag) 88 | (setf (pref (list "tag" tag)) 89 | (map 'vector #'song-local-path (songs-matching-tag tag))) 90 | (note-tag-write-time tag)) 91 | 92 | (defun tag-songs (songs tag) 93 | (load-tags) 94 | (map nil (lambda (song) 95 | (unless (member tag (song-tags song) :test #'string=) 96 | (push tag (song-tags song)))) 97 | songs) 98 | (save-tags-list tag) 99 | (when (string= tag "ignore") (compute-filtered-library))) 100 | 101 | (defun tag-song (song tag) (tag-songs (list song) tag)) 102 | 103 | (defun untag-songs (songs tag) 104 | (load-tags) 105 | (map nil (lambda (song) 106 | (when (member tag (song-tags song) :test #'string=) 107 | (setf (song-tags song) (delete tag (song-tags song) :test #'string=)))) 108 | songs) 109 | (save-tags-list tag) 110 | (when (string= tag "ignore") (compute-filtered-library))) 111 | 112 | (defun untag-song (song tag) (untag-songs (list song) tag)) 113 | 114 | ;;; Song start times 115 | 116 | (defun song-start-prefname (song) 117 | (list "start-time" (encode-as-filename (song-local-path song)))) 118 | 119 | (defun song-start-time (song) 120 | (pref (song-start-prefname song))) 121 | 122 | (defun (setf song-start-time) (time song) 123 | (setf (pref (song-start-prefname song)) time)) 124 | -------------------------------------------------------------------------------- /src/ui.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;; Temporal coupling ==> Recursive lock as workaround ==> Interesting. 4 | (defvar *output-lock* (bordeaux-threads:make-recursive-lock "Output Lock")) 5 | (defmacro with-output (() &body body) 6 | `(bordeaux-threads:with-recursive-lock-held (*output-lock*) 7 | ,@body)) 8 | 9 | (defvar *term-rows* 80) 10 | (defvar *term-cols* 25) 11 | 12 | (defun get-terminal-size () 13 | #-linux (values 80 25) ; =) 14 | #+linux 15 | (cffi:with-foreign-object (winsize :unsigned-short 4) 16 | (and (zerop (cffi:foreign-funcall "ioctl" 17 | :int 1 ; fd 18 | :int #x5413 ; TIOCGWINSZ (Linux!!) 19 | :pointer winsize 20 | :int)) 21 | (values (cffi:mem-aref winsize :unsigned-short 0) 22 | (cffi:mem-aref winsize :unsigned-short 1))))) 23 | 24 | (defun update-terminal-size () 25 | (multiple-value-bind (rows cols) (get-terminal-size) 26 | (setf *term-rows* (or rows *term-rows*) 27 | *term-cols* (or cols *term-cols*)))) 28 | 29 | (defparameter *max-query-results* 50 30 | "Maximum number of results to print without an explicit 'show' command.") 31 | 32 | (defun parse-ranges (string start max) 33 | "Parse comma delimited numeric ranges, returning a list of min/max 34 | pairs as cons cells." 35 | ;; This function isn't very good. Curiously, using parse-integer 36 | ;; actually made it harder, I suspect. 37 | (when (or (>= start (length string)) 38 | (char= #\- (aref string start))) 39 | (return-from parse-ranges nil)) 40 | (labels ((clamp (x) (max 0 (min x max))) 41 | (range (x y) (cons (clamp (min x y)) (clamp (max x y))))) 42 | (multiple-value-bind (min idx) 43 | (parse-integer string :junk-allowed t :start start) 44 | (cond 45 | ((null min) nil) 46 | ((= idx (length string)) (list (range min min))) 47 | ((or (char= #\, (aref string idx)) 48 | (char= #\ (aref string idx))) 49 | (list* (range min min) 50 | (parse-ranges string (1+ idx) max))) 51 | ((char= #\- (aref string idx)) 52 | (multiple-value-bind (parsed-max idx) 53 | (parse-integer string :junk-allowed t :start (1+ idx)) 54 | (list* (range min (or parsed-max max)) 55 | (parse-ranges string (1+ idx) max)))) 56 | (t (format t "???~%") nil))))) 57 | 58 | (defun expand-ranges (ranges) 59 | (loop for (min . max) in ranges 60 | nconcing (loop for i from min upto max collect i))) 61 | 62 | (defun extract-ranges (vector rangespec-string) 63 | (map 'vector (lambda (i) (aref vector i)) 64 | (expand-ranges (parse-ranges rangespec-string 0 (1- (length vector)))))) 65 | 66 | (defun getline () 67 | (finish-output *standard-output*) 68 | (or (read-line *standard-input* nil) (quit))) 69 | 70 | (defun parse-command-line-args () 71 | (let ((args *argv*)) 72 | (loop with arg1 = nil 73 | while args 74 | as arg = (pop args) do 75 | (flet ((bin (name &key (argname "parameter")) 76 | (when (equal arg name) 77 | (unless args 78 | (format t "Argument ~W expects a ~A." name argname) 79 | (quit)) 80 | (setf arg1 (pop args)) 81 | t))) 82 | (cond 83 | ((bin "--profile") 84 | (setf *profile* arg1) 85 | (format t "~&Using profile ~W~%" *profile*)) 86 | ((string= arg "--help") 87 | (print-usage-message) 88 | (quit)) 89 | ((string= arg "--version") 90 | (format t "Shuffletron ~A" *shuffletron-version*) 91 | (quit)) 92 | ((string= arg "--list") 93 | (let ((profiles (all-profiles))) 94 | (cond 95 | ((null profiles) 96 | (format t "~&There are no profiles.~%")) 97 | (t 98 | (format t "~& ~20A Path~%" "Name") 99 | (format t "~& ----------------------------------------------------------------------") 100 | (loop for name in profiles 101 | as path = (get-profile-base name) do 102 | (format t "~& ~20A ~A" name path))))) 103 | (quit)) 104 | (t (format t "~&Unrecognized argument ~W.~%" arg) 105 | (quit))))))) 106 | 107 | (defun sgr (modes) (format t "~C[~{~D~^;~}m" #\Esc modes)) 108 | 109 | (defun print-decorated (style-0 style-1 string markings style) 110 | (loop for char across string 111 | for new-style across markings 112 | with current-style = nil 113 | do 114 | (unless (eql new-style current-style) 115 | (sgr (list* (if (zerop new-style) style-0 style-1) 116 | style)) 117 | (setf current-style new-style)) 118 | (write-char char) 119 | finally (sgr '(0)))) 120 | 121 | (defun maybe-underlined (string markings-or-nil style) 122 | (cond 123 | ((null markings-or-nil) 124 | (when style (sgr style)) 125 | (write-string string) 126 | (when style (sgr '(0)))) 127 | (t (print-decorated 0 4 string markings-or-nil style)))) 128 | 129 | (defun spacing (n) 130 | (when (> n 0) (loop repeat n do (write-char #\.)))) 131 | 132 | (defparameter *color-prefs* '(:artist (31) :album (33) :title (37) :elided (90))) 133 | 134 | (defun show-song-matches (items &key (mode :query) (highlight-queue nil)) 135 | (loop with hash = (and highlight-queue 136 | (build-sequence-table (playqueue-and-current))) 137 | with last-artist = nil 138 | with last-album = nil 139 | for item across items 140 | as id3 = (song-id3 item) 141 | as mp = (song-matchprops item) 142 | as artist = (getf id3 :artist) 143 | as album = (getf id3 :album) 144 | as title = (getf id3 :title) 145 | as track = (getf id3 :track) 146 | for n upfrom 0 do 147 | 148 | (ecase mode 149 | (:query 150 | (loop for (keyword marker) in '((:filename #\f) (:artist #\a) 151 | (:album #\b) (:title #\t)) 152 | do (write-char (if (getf mp keyword) marker #\Space))) 153 | (when hash (when (gethash item hash) (sgr '(1 97)))) 154 | (format t "~5D " n) 155 | (when highlight-queue (sgr '(0)))) 156 | (:list 157 | (format t " ~7<(~D)~> " n))) 158 | 159 | (labels ((field (name key) 160 | (maybe-underlined name (getf mp key) (getf *color-prefs* key))) 161 | (sep () (format t ", ")) 162 | (post-number () 163 | #|(sgr '(90)) 164 | (format t " (~D)" n) 165 | (sgr '(0))|#) 166 | (track-and-title () 167 | (when track 168 | (format t "~2D: " track)) 169 | (field title :title) 170 | (post-number))) 171 | (cond 172 | ((and artist title) 173 | (cond 174 | ((equalp artist last-artist) 175 | (sgr (getf *color-prefs* :elided)) 176 | (write-char #\") 177 | (spacing (- (length artist) 1)) 178 | (cond 179 | ((and album (equalp album last-album)) 180 | (spacing (+ 2 (length album))) 181 | (format t "\" ") 182 | (sgr '(0)) 183 | (track-and-title)) 184 | (album 185 | (format t "\" ") 186 | (sgr '(0)) 187 | (field album :album) 188 | (sep) 189 | (track-and-title)) 190 | (t (format t "\" ") 191 | (sgr '(0)) 192 | (track-and-title)))) 193 | (t (field artist :artist) 194 | (sep) 195 | (when album 196 | (field album :album) 197 | (sep)) 198 | (track-and-title))) 199 | (setf last-artist artist 200 | last-album album)) 201 | (t (field (song-local-path item) :filename) 202 | ;; Occasionally we may have an artist but not the 203 | ;; title. Clear these, so we don't elide fields that 204 | ;; we didn't actually print. 205 | (setf last-artist nil 206 | last-album nil) 207 | (post-number)))) 208 | 209 | (terpri))) 210 | 211 | (defun vector-select-ranges (vector rangespec) 212 | (if (emptyp vector) 213 | (vector) 214 | (extract-ranges vector rangespec))) 215 | 216 | (defun selection-songs (rangespec) 217 | (vector-select-ranges *selection* rangespec)) 218 | 219 | (defun time->string (seconds) 220 | (setf seconds (round seconds)) 221 | (if (>= seconds 3600) 222 | (format nil "~D:~2,'0D:~2,'0D" (truncate seconds 3600) (mod (truncate seconds 60) 60) (mod seconds 60)) 223 | (format nil "~D:~2,'0D" (truncate seconds 60) (mod seconds 60)))) 224 | 225 | (defun print-id3-properties (stream props) 226 | (when (getf props :title) 227 | (format stream " Title: ~A" (getf props :title))) 228 | (let* ((line-length 76) 229 | (remaining 0)) 230 | (labels ((show (fmt &rest args) 231 | (let ((string (apply #'format nil fmt args))) 232 | (when (< remaining (length string)) 233 | (fresh-line) 234 | (write-string " " stream) 235 | (setf remaining line-length)) 236 | (write-string string stream) 237 | (write-string " " stream) 238 | (decf remaining (1+ (length string))))) 239 | (field (name indicator) 240 | (when (getf props indicator) 241 | (show "~A: ~A" name (getf props indicator))))) 242 | (field "Artist" :artist) 243 | (field "Album" :album) 244 | (field "Track" :track) 245 | (field "Genre" :genre) 246 | (field "Comment" :comment) 247 | (terpri)))) 248 | 249 | (defun show-current-song (&optional delimit) 250 | (let* ((current *current-stream*) 251 | (song (and current (song-of current))) 252 | (start-time (and song (song-start-time song)))) 253 | (when current 254 | (when delimit (terpri)) 255 | (let ((pos (streamer-position current *mixer*)) 256 | (len (streamer-length current *mixer*))) 257 | ;; It's possible these can be NIL if we're racing against the startup 258 | ;; of a stream with an error 259 | (when (and pos len) 260 | (format t "[~A/~A] ~A ~A~%" 261 | (time->string (round pos (mixer-rate *mixer*))) 262 | (time->string (round len (mixer-rate *mixer*))) 263 | (if (streamer-paused-p current *mixer*) 264 | "Paused:" 265 | "Playing:") 266 | (song-local-path song)))) 267 | (print-id3-properties *standard-output* (song-id3 song)) 268 | (when start-time 269 | (format t "Start time is set to ~A~%" (time->string start-time))) 270 | (show-song-tags song) 271 | (when delimit (terpri))))) 272 | 273 | (defun show-playqueue () 274 | (with-playqueue () 275 | (cond 276 | ((emptyp *playqueue*) 277 | (format t "The queue is empty.~%")) 278 | (t (show-song-matches (coerce *playqueue* 'vector) 279 | :mode :list :highlight-queue nil))) 280 | (when *loop-mode* (format t "Loop mode enabled.~%")) 281 | (show-current-song t))) 282 | 283 | ;;;; Tagging UI 284 | 285 | (defun item-list-delim (char) (or (char= char #\Space) (char= char #\,))) 286 | 287 | (defun parse-item-list (string start) 288 | (cond 289 | ((null start) nil) 290 | (t (let* ((istart (position-if-not #'item-list-delim string :start start)) 291 | (iend (and istart (position-if #'item-list-delim string :start istart))) 292 | (item (and istart (subseq string istart iend)))) 293 | (and item (cons item (parse-item-list string iend))))))) 294 | 295 | (defun parse-tag-list (tags-arg) 296 | (mapcar #'encode-as-filename (parse-item-list tags-arg 0))) 297 | 298 | (defun show-song-tags (song &key (no-tags-fmt "")) 299 | (load-tags) 300 | (cond 301 | ((not song) (format t "No song is playing.~%")) 302 | ((song-tags song) (format t "Tagged: ~{~A~^, ~}~%" 303 | (mapcar #'decode-as-filename (song-tags song)))) 304 | (t (format t no-tags-fmt)))) 305 | 306 | (defun show-current-song-tags () 307 | (let ((song (current-song-playing))) 308 | (when song (show-song-tags song :no-tags-fmt "No tags.~%")))) 309 | 310 | (defun tag-current-song (tags-arg) 311 | (let* ((song (current-song-playing)) 312 | (tags (parse-tag-list tags-arg))) 313 | (cond 314 | (song 315 | (dolist (tag tags) (tag-song song tag)) 316 | (show-current-song-tags)) 317 | (t (format t "No song is playing.~%"))))) 318 | 319 | (defun untag-current-song (tags-arg) 320 | (let* ((song (current-song-playing)) 321 | (tags (if tags-arg 322 | (parse-tag-list tags-arg) 323 | (song-tags song)))) 324 | (if song 325 | (dolist (tag tags) (untag-song song tag)) 326 | (format t "No song is playing.~%")))) 327 | 328 | (defun kill-tag (tag) 329 | (loop for song across *library* 330 | with num-killed = 0 331 | do 332 | (when (find tag (song-tags song) :test #'string=) 333 | (setf (song-tags song) (delete tag (song-tags song) :test #'string=)) 334 | (incf num-killed)) 335 | finally (format t "Removed ~:D occurrence~P of ~A~%" 336 | num-killed num-killed (decode-as-filename tag))) 337 | (save-tags-list tag)) 338 | 339 | (defun tag-count-pairs (songs) 340 | (let* ((all-tags (loop for song across songs 341 | appending (song-tags song))) 342 | (no-dups nil) 343 | (counts (make-hash-table :test 'equal))) 344 | (dolist (tag all-tags) 345 | (cond 346 | ((gethash tag counts) (incf (gethash tag counts))) 347 | (t (setf (gethash tag counts) 1 348 | no-dups (cons tag no-dups))))) 349 | (setf no-dups (sort no-dups #'string<=)) 350 | (loop for tag in no-dups collect (cons tag (gethash tag counts))))) 351 | 352 | (defun show-all-tags () 353 | (format t "All tags in ~A: ~{~A~^, ~}~%" 354 | (if (querying-library-p) "library" "query") 355 | (loop for (tag . count) in (tag-count-pairs *selection*) 356 | as printable = (decode-as-filename tag) 357 | if (= 1 count) collect printable 358 | else collect (format nil "~A(~A)" printable count)))) 359 | -------------------------------------------------------------------------------- /src/util.lisp: -------------------------------------------------------------------------------- 1 | (in-package :shuffletron) 2 | 3 | ;;;; File names 4 | 5 | (defun join-paths (a b) 6 | "Append a file name to a path, adding a directory separator if necessary." 7 | (declare (type string a b)) 8 | (cond 9 | ((zerop (length a)) b) 10 | ((and (char= #\/ (elt a (1- (length a)))) 11 | (zerop (length b))) 12 | a) 13 | (t (concatenate 'string a (if (char= #\/ (elt a (1- (length a)))) "" "/") b)))) 14 | 15 | (defun relative-to (path filename) 16 | (let ((index (mismatch (join-paths path "") filename))) 17 | (if (zerop index) 18 | (error "File ~A is not in path ~A" filename path) 19 | (subseq filename index)))) 20 | 21 | ;;;; POSIX directory walker 22 | 23 | (defmacro with-posix-interface (() &body body) 24 | `(let ((cffi:*default-foreign-encoding* :iso-8859-1)) 25 | ,@body)) 26 | 27 | (defun find-type-via-stat (path name) 28 | ;; Call stat, map back to d_type form since that's what we expect. 29 | (let ((mode (osicat-posix:stat-mode (osicat-posix:stat (join-paths path name))))) 30 | (cond 31 | ((osicat-posix:s-isdir mode) osicat-posix:dt-dir) 32 | ((osicat-posix:s-isreg mode) osicat-posix:dt-reg) 33 | (t osicat-posix:dt-unknown)))) 34 | 35 | (defun %split-list-directory (path) 36 | (with-posix-interface () 37 | (handler-case 38 | (let ((dir (osicat-posix:opendir path)) 39 | dirs files) 40 | (unwind-protect 41 | (loop 42 | (multiple-value-bind (name type) (osicat-posix:readdir dir) 43 | ;; Some OSes (and ancient glibc versions) don't support 44 | ;; d_type. We fall back to stat in that case. 45 | (when (and name (eql type osicat-posix:dt-unknown)) 46 | (setf type (find-type-via-stat path name))) 47 | (cond 48 | ((null name) (return-from %split-list-directory (values dirs files))) 49 | ((eql type osicat-posix:dt-dir) (push name dirs)) 50 | ((eql type osicat-posix:dt-reg) (push name files))))) 51 | (osicat-posix:closedir dir))) 52 | (osicat-posix:EACCES () 53 | (cerror "Skip it" "Skip scan of ~A" path))))) 54 | 55 | (defun split-list-directory (path) 56 | (multiple-value-bind (dirs files) (%split-list-directory path) 57 | (values 58 | (delete-if (lambda (str) (or (string= str ".") (string= str ".."))) dirs) 59 | files))) 60 | 61 | (defun abs-sorted-list-directory (path) 62 | (multiple-value-bind (dirs files) (split-list-directory path) 63 | (flet ((absolutize (list) 64 | (mapcar (lambda (filename) (join-paths path filename)) 65 | (sort list #'string<=)))) 66 | (values (absolutize dirs) (absolutize files))))) 67 | 68 | (defun walk (path fn) 69 | "Walk directory tree, ignoring symlinks." 70 | (multiple-value-bind (dirs files) (abs-sorted-list-directory path) 71 | (map nil fn files) 72 | (dolist (dir dirs) (walk dir fn))) 73 | (values)) 74 | 75 | ;;;; Little utilities 76 | 77 | (defun emptyp (seq) (or (null seq) (zerop (length seq)))) 78 | 79 | (defun build-sequence-table (seq &optional (key #'identity) (test #'equal)) 80 | (let ((table (make-hash-table :test test))) 81 | (map nil (lambda (elt) (setf (gethash (funcall key elt) table) elt)) seq) 82 | table)) 83 | 84 | ;;;; S-Expression File I/O Accessor 85 | 86 | (defun file (filename) 87 | (with-open-file (in filename :external-format :latin1) 88 | (with-standard-io-syntax () 89 | (let ((*read-eval* nil)) 90 | (read in))))) 91 | 92 | (defun write-sexp-file (filename object) 93 | (with-open-file (out filename 94 | :external-format :latin1 95 | :direction :output 96 | :if-exists :supersede 97 | :if-does-not-exist :create) 98 | (with-standard-io-syntax () 99 | (pprint object out)))) 100 | 101 | (defsetf file (filename) (object) 102 | `(write-sexp-file ,filename ,object)) 103 | 104 | ;;;; Parsing 105 | 106 | ;;; Awful anaphora in these parsing macros, they often assume IN 107 | ;;; is the name of the stream variable. 108 | 109 | (defmacro parsing ((&optional string) &body body) 110 | (if string 111 | `(with-input-from-string (in ,string) (catch 'fail ,@body)) 112 | `(catch 'fail ,@body))) 113 | 114 | ;;; Beware disjunctive definitions where branches are prefixes of 115 | ;;; later branches. The first match will be accepted, and there's no 116 | ;;; backtracking if that was the wrong one. 117 | (defmacro disjunction ((&optional string) &body branches) 118 | (if string 119 | `(or ,@(loop for branch in branches collect `(parsing (,string) ,branch))) 120 | (let ((start (gensym))) 121 | `(let ((,start (file-position in))) 122 | (or ,@(loop for branch in branches 123 | collect `(progn 124 | (assert (file-position in ,start)) 125 | (parsing (,string) ,branch)))))))) 126 | 127 | ;;; Parser result value: parse succeeds only when non-NIL. 128 | (defun val (x) (or x (throw 'fail nil))) 129 | 130 | ;;; Lexical elements: 131 | 132 | (defun num (in) 133 | (loop with accum = nil 134 | as next = (peek-char nil in nil) 135 | as digit = (and next (digit-char-p next 10)) 136 | while digit do 137 | (read-char in) 138 | (setf accum (+ digit (* (or accum 0) 10))) 139 | finally (return (val accum)))) 140 | 141 | (defun colon (in) (val (eql #\: (read-char in nil)))) 142 | (defun mod60 (in) (let ((n (num in))) (val (and (< n 60) n)))) 143 | (defun eof (in) (val (not (peek-char nil in nil)))) 144 | (defun whitespace (in) (val (peek-char t in nil))) 145 | (defun match (in match) 146 | (every (lambda (x) (val (char-equal x (val (read-char in nil))))) match)) 147 | 148 | --------------------------------------------------------------------------------