1 /***
2 * Ambient - A music player for the Android platform
3 Copyright (C) 2007 Martin Vysny
4
5 This program is free software: you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with this program. If not, see <http://www.gnu.org/licenses/>.
17 */
18
19 package sk.baka.ambient.lrc;
20
21 import java.io.IOException;
22 import java.io.InputStream;
23 import java.io.InputStreamReader;
24 import java.io.LineNumberReader;
25 import java.text.ParseException;
26 import java.util.Collections;
27 import java.util.Enumeration;
28 import java.util.List;
29 import java.util.Map;
30 import java.util.SortedMap;
31 import java.util.StringTokenizer;
32 import java.util.TreeMap;
33
34 import sk.baka.ambient.commons.MiscUtils;
35
36 /***
37 * Parses the LRC karaoke format (more on <a
38 * href="http://en.wikipedia.org/wiki/LRC_(file_format)">wiki</a>). Thread-safe.
39 *
40 * @author Martin Vysny
41 */
42 public final class LRCLyrics {
43 /***
44 * The karaoke lines. Maps time in milliseconds to the lyrics line.
45 */
46 private final SortedMap<Long, String> lines;
47
48 /***
49 * Creates new lyrics object.
50 *
51 * @param lines
52 * create object from these lines.
53 */
54 private LRCLyrics(final SortedMap<Long, String> lines) {
55 super();
56 this.lines = Collections.synchronizedSortedMap(lines);
57 }
58
59 /***
60 * Returns all lines in this lyrics instance.
61 *
62 * @return unmodifiable map of all lyrics.
63 */
64 public SortedMap<Long, String> getLines() {
65 return Collections.unmodifiableSortedMap(lines);
66 }
67
68 /***
69 * Returns line which should be displayed at given time.
70 *
71 * @param time
72 * the time.
73 * @return line to be displayed, never <code>null</code>, may be empty.
74 */
75 public String getLineToDisplay(final long time) {
76 final SortedMap<Long, String> earlierLines = lines.headMap(time + 1);
77 if (earlierLines.isEmpty())
78 return "";
79 return earlierLines.get(earlierLines.lastKey());
80 }
81
82 /***
83 * Returns next line time. Returned time is always greater than the time
84 * given.
85 *
86 * @param time
87 * current time.
88 * @return nearest next time of line to be displayed. May return -1 if there
89 * is no such line.
90 */
91 public long getNextLineTime(final long time) {
92 final SortedMap<Long, String> laterLines = lines.tailMap(time + 1);
93 if (laterLines.isEmpty())
94 return -1;
95 return laterLines.firstKey();
96 }
97
98 /***
99 * Parses given LRC file
100 *
101 * @param in
102 * the file to parse. The stream is always closed, even in cause
103 * of an exception
104 * @return a lyrics holder instance.
105 * @throws IOException
106 * if i/o error occurs.
107 * @throws ParseException
108 * if parse error occurs
109 */
110 public static LRCLyrics parse(final InputStream in) throws IOException,
111 ParseException {
112 final LineNumberReader reader = new LineNumberReader(
113 new InputStreamReader(in));
114 try {
115 final SortedMap<Long, String> lines = new TreeMap<Long, String>();
116 for (String line = reader.readLine(); line != null; line = reader
117 .readLine()) {
118 parseLine(line.trim(), lines);
119 }
120 return new LRCLyrics(lines);
121 } finally {
122 MiscUtils.closeQuietly(reader);
123 }
124 }
125
126 /***
127 * Parses a single line and adds it into given map.
128 *
129 * @param line
130 * the line to parse
131 * @param lines
132 * the map containing all lines.
133 * @throws ParseException
134 * if parse error occurs
135 */
136 private static void parseLine(String line, Map<Long, String> lines)
137 throws ParseException {
138 if (line.length() == 0)
139 return;
140 if (!line.startsWith("["))
141 throw new ParseException("'[' expected but '" + line + "' found", 0);
142 if (line.length() < 6)
143 throw new ParseException("'" + line + "' is not a valid LRC line",
144 0);
145 if (!Character.isDigit(line.charAt(1))) {
146
147 return;
148 }
149 int closingBracket = line.indexOf(']');
150 if (closingBracket < 0) {
151 throw new ParseException(
152 "Invalid format: expected [mm:ss.xx] but got '" + line
153 + "'", 0);
154 }
155
156 final long millis = parseTime(line.substring(1, closingBracket));
157
158 final String lyricsLine = line.substring(closingBracket + 1);
159 final StringBuilder lineBuilder = new StringBuilder();
160 boolean first = true;
161 boolean inEnhancedTag = false;
162 for (final Enumeration<Object> tokens = new StringTokenizer(lyricsLine,
163 "<> ", true); tokens.hasMoreElements();) {
164 final String token = tokens.nextElement().toString();
165 if ("<".equals(token)) {
166 inEnhancedTag = true;
167 continue;
168 } else if (">".equals(token)) {
169 inEnhancedTag = false;
170 continue;
171 }
172 if (inEnhancedTag) {
173 continue;
174 }
175 if (" ".equals(token)) {
176 continue;
177 }
178 if (first) {
179 first = false;
180 } else {
181 lineBuilder.append(' ');
182 }
183 lineBuilder.append(token.trim());
184 }
185
186 lines.put(millis, lineBuilder.toString());
187 }
188
189 private static long parseTime(String time) throws ParseException {
190 final List<Object> tokens = Collections.list(new StringTokenizer(time,
191 ":.", true));
192 if (((tokens.size() != 3) && (tokens.size() != 5))
193 || (!":".equals(tokens.get(1)))) {
194 throw new ParseException("Invalid format: expected mm:ss but got '"
195 + time + "'", 0);
196 }
197 final boolean hasMillis = (tokens.size() == 5);
198 if (hasMillis && (!".".equals(tokens.get(3)))) {
199 throw new ParseException(
200 "Invalid format: expected mm:ss.xx but got '" + time + "'",
201 0);
202 }
203 long millis = 0;
204 try {
205 final long minutes = Integer.parseInt(tokens.get(0).toString());
206 final long seconds = Integer.parseInt(tokens.get(2).toString());
207 if (hasMillis) {
208 final String sMillis = tokens.get(4).toString();
209 if (sMillis.length() < 2) {
210 throw new ParseException(
211 "Invalid format: expected mm:ss.xx but got '"
212 + time + "'", 0);
213 }
214 millis = Integer.parseInt(sMillis);
215 if (sMillis.length() < 3)
216 millis *= 10L;
217 }
218 millis += seconds * 1000 + minutes * 60 * 1000;
219 } catch (NumberFormatException ex) {
220 throw new ParseException("Invalid number format: "
221 + ex.getLocalizedMessage(), -1);
222 }
223 return millis;
224 }
225 }