├── .gitignore
├── LICENSE.md
├── README.md
├── go.mod
├── go.sum
├── internal
├── durafmt
│ └── durafmt.go
├── gtkutil
│ └── gtkutil.go
├── mpris
│ ├── mpris.go
│ ├── player.go
│ └── props.go
├── muse
│ ├── albumart
│ │ └── albumart.go
│ ├── metadata
│ │ ├── ffmpeg
│ │ │ └── ffmpeg.go
│ │ ├── ffprobe
│ │ │ └── ffprobe.go
│ │ └── seekbufio
│ │ │ └── seekbufio.go
│ ├── mpv.go
│ ├── muse.go
│ └── playlist
│ │ ├── audpl
│ │ └── audpl.go
│ │ ├── m3u
│ │ └── m3u.go
│ │ ├── playlist.go
│ │ ├── shuffle.go
│ │ └── track.go
├── state
│ ├── json.go
│ ├── playlist.go
│ ├── playlist_test.go
│ ├── prober
│ │ └── prober.go
│ ├── repeat.go
│ ├── state.go
│ └── track.go
└── ui
│ ├── actions
│ ├── actions.go
│ ├── menu.go
│ └── menubutton.go
│ ├── content
│ ├── bar
│ │ ├── bar.go
│ │ ├── controls
│ │ │ ├── buttons.go
│ │ │ ├── controls.go
│ │ │ └── seek.go
│ │ ├── playing.go
│ │ └── volume.go
│ ├── body
│ │ ├── body.go
│ │ ├── sidebar
│ │ │ ├── albumart.go
│ │ │ ├── playlists.go
│ │ │ └── sidebar.go
│ │ └── tracks
│ │ │ ├── sorter.go
│ │ │ ├── tracklist.go
│ │ │ └── tracks.go
│ └── content.go
│ ├── css
│ └── css.go
│ ├── header
│ ├── appcontrols.go
│ ├── header.go
│ ├── info.go
│ └── playlistcontrols.go
│ ├── preferences.go
│ └── ui.go
├── main.go
├── screenshot.png
└── shell.nix
/.gitignore:
--------------------------------------------------------------------------------
1 | aqours
2 | .direnv
3 | .envrc
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | ### GNU GENERAL PUBLIC LICENSE
2 |
3 | Version 3, 29 June 2007
4 |
5 | Copyright (C) 2007 Free Software Foundation, Inc.
6 |
7 |
8 | Everyone is permitted to copy and distribute verbatim copies of this
9 | license document, but changing it is not allowed.
10 |
11 | ### Preamble
12 |
13 | The GNU General Public License is a free, copyleft license for
14 | software and other kinds of works.
15 |
16 | The licenses for most software and other practical works are designed
17 | to take away your freedom to share and change the works. By contrast,
18 | the GNU General Public License is intended to guarantee your freedom
19 | to share and change all versions of a program--to make sure it remains
20 | free software for all its users. We, the Free Software Foundation, use
21 | the GNU General Public License for most of our software; it applies
22 | also to any other work released this way by its authors. You can apply
23 | it to your programs, too.
24 |
25 | When we speak of free software, we are referring to freedom, not
26 | price. Our General Public Licenses are designed to make sure that you
27 | have the freedom to distribute copies of free software (and charge for
28 | them if you wish), that you receive source code or can get it if you
29 | want it, that you can change the software or use pieces of it in new
30 | free programs, and that you know you can do these things.
31 |
32 | To protect your rights, we need to prevent others from denying you
33 | these rights or asking you to surrender the rights. Therefore, you
34 | have certain responsibilities if you distribute copies of the
35 | software, or if you modify it: responsibilities to respect the freedom
36 | of others.
37 |
38 | For example, if you distribute copies of such a program, whether
39 | gratis or for a fee, you must pass on to the recipients the same
40 | freedoms that you received. You must make sure that they, too, receive
41 | or can get the source code. And you must show them these terms so they
42 | know their rights.
43 |
44 | Developers that use the GNU GPL protect your rights with two steps:
45 | (1) assert copyright on the software, and (2) offer you this License
46 | giving you legal permission to copy, distribute and/or modify it.
47 |
48 | For the developers' and authors' protection, the GPL clearly explains
49 | that there is no warranty for this free software. For both users' and
50 | authors' sake, the GPL requires that modified versions be marked as
51 | changed, so that their problems will not be attributed erroneously to
52 | authors of previous versions.
53 |
54 | Some devices are designed to deny users access to install or run
55 | modified versions of the software inside them, although the
56 | manufacturer can do so. This is fundamentally incompatible with the
57 | aim of protecting users' freedom to change the software. The
58 | systematic pattern of such abuse occurs in the area of products for
59 | individuals to use, which is precisely where it is most unacceptable.
60 | Therefore, we have designed this version of the GPL to prohibit the
61 | practice for those products. If such problems arise substantially in
62 | other domains, we stand ready to extend this provision to those
63 | domains in future versions of the GPL, as needed to protect the
64 | freedom of users.
65 |
66 | Finally, every program is threatened constantly by software patents.
67 | States should not allow patents to restrict development and use of
68 | software on general-purpose computers, but in those that do, we wish
69 | to avoid the special danger that patents applied to a free program
70 | could make it effectively proprietary. To prevent this, the GPL
71 | assures that patents cannot be used to render the program non-free.
72 |
73 | The precise terms and conditions for copying, distribution and
74 | modification follow.
75 |
76 | ### TERMS AND CONDITIONS
77 |
78 | #### 0. Definitions.
79 |
80 | "This License" refers to version 3 of the GNU General Public License.
81 |
82 | "Copyright" also means copyright-like laws that apply to other kinds
83 | of works, such as semiconductor masks.
84 |
85 | "The Program" refers to any copyrightable work licensed under this
86 | License. Each licensee is addressed as "you". "Licensees" and
87 | "recipients" may be individuals or organizations.
88 |
89 | To "modify" a work means to copy from or adapt all or part of the work
90 | in a fashion requiring copyright permission, other than the making of
91 | an exact copy. The resulting work is called a "modified version" of
92 | the earlier work or a work "based on" the earlier work.
93 |
94 | A "covered work" means either the unmodified Program or a work based
95 | on the Program.
96 |
97 | To "propagate" a work means to do anything with it that, without
98 | permission, would make you directly or secondarily liable for
99 | infringement under applicable copyright law, except executing it on a
100 | computer or modifying a private copy. Propagation includes copying,
101 | distribution (with or without modification), making available to the
102 | public, and in some countries other activities as well.
103 |
104 | To "convey" a work means any kind of propagation that enables other
105 | parties to make or receive copies. Mere interaction with a user
106 | through a computer network, with no transfer of a copy, is not
107 | conveying.
108 |
109 | An interactive user interface displays "Appropriate Legal Notices" to
110 | the extent that it includes a convenient and prominently visible
111 | feature that (1) displays an appropriate copyright notice, and (2)
112 | tells the user that there is no warranty for the work (except to the
113 | extent that warranties are provided), that licensees may convey the
114 | work under this License, and how to view a copy of this License. If
115 | the interface presents a list of user commands or options, such as a
116 | menu, a prominent item in the list meets this criterion.
117 |
118 | #### 1. Source Code.
119 |
120 | The "source code" for a work means the preferred form of the work for
121 | making modifications to it. "Object code" means any non-source form of
122 | a work.
123 |
124 | A "Standard Interface" means an interface that either is an official
125 | standard defined by a recognized standards body, or, in the case of
126 | interfaces specified for a particular programming language, one that
127 | is widely used among developers working in that language.
128 |
129 | The "System Libraries" of an executable work include anything, other
130 | than the work as a whole, that (a) is included in the normal form of
131 | packaging a Major Component, but which is not part of that Major
132 | Component, and (b) serves only to enable use of the work with that
133 | Major Component, or to implement a Standard Interface for which an
134 | implementation is available to the public in source code form. A
135 | "Major Component", in this context, means a major essential component
136 | (kernel, window system, and so on) of the specific operating system
137 | (if any) on which the executable work runs, or a compiler used to
138 | produce the work, or an object code interpreter used to run it.
139 |
140 | The "Corresponding Source" for a work in object code form means all
141 | the source code needed to generate, install, and (for an executable
142 | work) run the object code and to modify the work, including scripts to
143 | control those activities. However, it does not include the work's
144 | System Libraries, or general-purpose tools or generally available free
145 | programs which are used unmodified in performing those activities but
146 | which are not part of the work. For example, Corresponding Source
147 | includes interface definition files associated with source files for
148 | the work, and the source code for shared libraries and dynamically
149 | linked subprograms that the work is specifically designed to require,
150 | such as by intimate data communication or control flow between those
151 | subprograms and other parts of the work.
152 |
153 | The Corresponding Source need not include anything that users can
154 | regenerate automatically from other parts of the Corresponding Source.
155 |
156 | The Corresponding Source for a work in source code form is that same
157 | work.
158 |
159 | #### 2. Basic Permissions.
160 |
161 | All rights granted under this License are granted for the term of
162 | copyright on the Program, and are irrevocable provided the stated
163 | conditions are met. This License explicitly affirms your unlimited
164 | permission to run the unmodified Program. The output from running a
165 | covered work is covered by this License only if the output, given its
166 | content, constitutes a covered work. This License acknowledges your
167 | rights of fair use or other equivalent, as provided by copyright law.
168 |
169 | You may make, run and propagate covered works that you do not convey,
170 | without conditions so long as your license otherwise remains in force.
171 | You may convey covered works to others for the sole purpose of having
172 | them make modifications exclusively for you, or provide you with
173 | facilities for running those works, provided that you comply with the
174 | terms of this License in conveying all material for which you do not
175 | control copyright. Those thus making or running the covered works for
176 | you must do so exclusively on your behalf, under your direction and
177 | control, on terms that prohibit them from making any copies of your
178 | copyrighted material outside their relationship with you.
179 |
180 | Conveying under any other circumstances is permitted solely under the
181 | conditions stated below. Sublicensing is not allowed; section 10 makes
182 | it unnecessary.
183 |
184 | #### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
185 |
186 | No covered work shall be deemed part of an effective technological
187 | measure under any applicable law fulfilling obligations under article
188 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
189 | similar laws prohibiting or restricting circumvention of such
190 | measures.
191 |
192 | When you convey a covered work, you waive any legal power to forbid
193 | circumvention of technological measures to the extent such
194 | circumvention is effected by exercising rights under this License with
195 | respect to the covered work, and you disclaim any intention to limit
196 | operation or modification of the work as a means of enforcing, against
197 | the work's users, your or third parties' legal rights to forbid
198 | circumvention of technological measures.
199 |
200 | #### 4. Conveying Verbatim Copies.
201 |
202 | You may convey verbatim copies of the Program's source code as you
203 | receive it, in any medium, provided that you conspicuously and
204 | appropriately publish on each copy an appropriate copyright notice;
205 | keep intact all notices stating that this License and any
206 | non-permissive terms added in accord with section 7 apply to the code;
207 | keep intact all notices of the absence of any warranty; and give all
208 | recipients a copy of this License along with the Program.
209 |
210 | You may charge any price or no price for each copy that you convey,
211 | and you may offer support or warranty protection for a fee.
212 |
213 | #### 5. Conveying Modified Source Versions.
214 |
215 | You may convey a work based on the Program, or the modifications to
216 | produce it from the Program, in the form of source code under the
217 | terms of section 4, provided that you also meet all of these
218 | conditions:
219 |
220 | - a) The work must carry prominent notices stating that you modified
221 | it, and giving a relevant date.
222 | - b) The work must carry prominent notices stating that it is
223 | released under this License and any conditions added under
224 | section 7. This requirement modifies the requirement in section 4
225 | to "keep intact all notices".
226 | - c) You must license the entire work, as a whole, under this
227 | License to anyone who comes into possession of a copy. This
228 | License will therefore apply, along with any applicable section 7
229 | additional terms, to the whole of the work, and all its parts,
230 | regardless of how they are packaged. This License gives no
231 | permission to license the work in any other way, but it does not
232 | invalidate such permission if you have separately received it.
233 | - d) If the work has interactive user interfaces, each must display
234 | Appropriate Legal Notices; however, if the Program has interactive
235 | interfaces that do not display Appropriate Legal Notices, your
236 | work need not make them do so.
237 |
238 | A compilation of a covered work with other separate and independent
239 | works, which are not by their nature extensions of the covered work,
240 | and which are not combined with it such as to form a larger program,
241 | in or on a volume of a storage or distribution medium, is called an
242 | "aggregate" if the compilation and its resulting copyright are not
243 | used to limit the access or legal rights of the compilation's users
244 | beyond what the individual works permit. Inclusion of a covered work
245 | in an aggregate does not cause this License to apply to the other
246 | parts of the aggregate.
247 |
248 | #### 6. Conveying Non-Source Forms.
249 |
250 | You may convey a covered work in object code form under the terms of
251 | sections 4 and 5, provided that you also convey the machine-readable
252 | Corresponding Source under the terms of this License, in one of these
253 | ways:
254 |
255 | - a) Convey the object code in, or embodied in, a physical product
256 | (including a physical distribution medium), accompanied by the
257 | Corresponding Source fixed on a durable physical medium
258 | customarily used for software interchange.
259 | - b) Convey the object code in, or embodied in, a physical product
260 | (including a physical distribution medium), accompanied by a
261 | written offer, valid for at least three years and valid for as
262 | long as you offer spare parts or customer support for that product
263 | model, to give anyone who possesses the object code either (1) a
264 | copy of the Corresponding Source for all the software in the
265 | product that is covered by this License, on a durable physical
266 | medium customarily used for software interchange, for a price no
267 | more than your reasonable cost of physically performing this
268 | conveying of source, or (2) access to copy the Corresponding
269 | Source from a network server at no charge.
270 | - c) Convey individual copies of the object code with a copy of the
271 | written offer to provide the Corresponding Source. This
272 | alternative is allowed only occasionally and noncommercially, and
273 | only if you received the object code with such an offer, in accord
274 | with subsection 6b.
275 | - d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 | - e) Convey the object code using peer-to-peer transmission,
288 | provided you inform other peers where the object code and
289 | Corresponding Source of the work are being offered to the general
290 | public at no charge under subsection 6d.
291 |
292 | A separable portion of the object code, whose source code is excluded
293 | from the Corresponding Source as a System Library, need not be
294 | included in conveying the object code work.
295 |
296 | A "User Product" is either (1) a "consumer product", which means any
297 | tangible personal property which is normally used for personal,
298 | family, or household purposes, or (2) anything designed or sold for
299 | incorporation into a dwelling. In determining whether a product is a
300 | consumer product, doubtful cases shall be resolved in favor of
301 | coverage. For a particular product received by a particular user,
302 | "normally used" refers to a typical or common use of that class of
303 | product, regardless of the status of the particular user or of the way
304 | in which the particular user actually uses, or expects or is expected
305 | to use, the product. A product is a consumer product regardless of
306 | whether the product has substantial commercial, industrial or
307 | non-consumer uses, unless such uses represent the only significant
308 | mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to
312 | install and execute modified versions of a covered work in that User
313 | Product from a modified version of its Corresponding Source. The
314 | information must suffice to ensure that the continued functioning of
315 | the modified object code is in no case prevented or interfered with
316 | solely because modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or
331 | updates for a work that has been modified or installed by the
332 | recipient, or for the User Product in which it has been modified or
333 | installed. Access to a network may be denied when the modification
334 | itself materially and adversely affects the operation of the network
335 | or violates the rules and protocols for communication across the
336 | network.
337 |
338 | Corresponding Source conveyed, and Installation Information provided,
339 | in accord with this section must be in a format that is publicly
340 | documented (and with an implementation available to the public in
341 | source code form), and must require no special password or key for
342 | unpacking, reading or copying.
343 |
344 | #### 7. Additional Terms.
345 |
346 | "Additional permissions" are terms that supplement the terms of this
347 | License by making exceptions from one or more of its conditions.
348 | Additional permissions that are applicable to the entire Program shall
349 | be treated as though they were included in this License, to the extent
350 | that they are valid under applicable law. If additional permissions
351 | apply only to part of the Program, that part may be used separately
352 | under those permissions, but the entire Program remains governed by
353 | this License without regard to the additional permissions.
354 |
355 | When you convey a copy of a covered work, you may at your option
356 | remove any additional permissions from that copy, or from any part of
357 | it. (Additional permissions may be written to require their own
358 | removal in certain cases when you modify the work.) You may place
359 | additional permissions on material, added by you to a covered work,
360 | for which you have or can give appropriate copyright permission.
361 |
362 | Notwithstanding any other provision of this License, for material you
363 | add to a covered work, you may (if authorized by the copyright holders
364 | of that material) supplement the terms of this License with terms:
365 |
366 | - a) Disclaiming warranty or limiting liability differently from the
367 | terms of sections 15 and 16 of this License; or
368 | - b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 | - c) Prohibiting misrepresentation of the origin of that material,
372 | or requiring that modified versions of such material be marked in
373 | reasonable ways as different from the original version; or
374 | - d) Limiting the use for publicity purposes of names of licensors
375 | or authors of the material; or
376 | - e) Declining to grant rights under trademark law for use of some
377 | trade names, trademarks, or service marks; or
378 | - f) Requiring indemnification of licensors and authors of that
379 | material by anyone who conveys the material (or modified versions
380 | of it) with contractual assumptions of liability to the recipient,
381 | for any liability that these contractual assumptions directly
382 | impose on those licensors and authors.
383 |
384 | All other non-permissive additional terms are considered "further
385 | restrictions" within the meaning of section 10. If the Program as you
386 | received it, or any part of it, contains a notice stating that it is
387 | governed by this License along with a term that is a further
388 | restriction, you may remove that term. If a license document contains
389 | a further restriction but permits relicensing or conveying under this
390 | License, you may add to a covered work material governed by the terms
391 | of that license document, provided that the further restriction does
392 | not survive such relicensing or conveying.
393 |
394 | If you add terms to a covered work in accord with this section, you
395 | must place, in the relevant source files, a statement of the
396 | additional terms that apply to those files, or a notice indicating
397 | where to find the applicable terms.
398 |
399 | Additional terms, permissive or non-permissive, may be stated in the
400 | form of a separately written license, or stated as exceptions; the
401 | above requirements apply either way.
402 |
403 | #### 8. Termination.
404 |
405 | You may not propagate or modify a covered work except as expressly
406 | provided under this License. Any attempt otherwise to propagate or
407 | modify it is void, and will automatically terminate your rights under
408 | this License (including any patent licenses granted under the third
409 | paragraph of section 11).
410 |
411 | However, if you cease all violation of this License, then your license
412 | from a particular copyright holder is reinstated (a) provisionally,
413 | unless and until the copyright holder explicitly and finally
414 | terminates your license, and (b) permanently, if the copyright holder
415 | fails to notify you of the violation by some reasonable means prior to
416 | 60 days after the cessation.
417 |
418 | Moreover, your license from a particular copyright holder is
419 | reinstated permanently if the copyright holder notifies you of the
420 | violation by some reasonable means, this is the first time you have
421 | received notice of violation of this License (for any work) from that
422 | copyright holder, and you cure the violation prior to 30 days after
423 | your receipt of the notice.
424 |
425 | Termination of your rights under this section does not terminate the
426 | licenses of parties who have received copies or rights from you under
427 | this License. If your rights have been terminated and not permanently
428 | reinstated, you do not qualify to receive new licenses for the same
429 | material under section 10.
430 |
431 | #### 9. Acceptance Not Required for Having Copies.
432 |
433 | You are not required to accept this License in order to receive or run
434 | a copy of the Program. Ancillary propagation of a covered work
435 | occurring solely as a consequence of using peer-to-peer transmission
436 | to receive a copy likewise does not require acceptance. However,
437 | nothing other than this License grants you permission to propagate or
438 | modify any covered work. These actions infringe copyright if you do
439 | not accept this License. Therefore, by modifying or propagating a
440 | covered work, you indicate your acceptance of this License to do so.
441 |
442 | #### 10. Automatic Licensing of Downstream Recipients.
443 |
444 | Each time you convey a covered work, the recipient automatically
445 | receives a license from the original licensors, to run, modify and
446 | propagate that work, subject to this License. You are not responsible
447 | for enforcing compliance by third parties with this License.
448 |
449 | An "entity transaction" is a transaction transferring control of an
450 | organization, or substantially all assets of one, or subdividing an
451 | organization, or merging organizations. If propagation of a covered
452 | work results from an entity transaction, each party to that
453 | transaction who receives a copy of the work also receives whatever
454 | licenses to the work the party's predecessor in interest had or could
455 | give under the previous paragraph, plus a right to possession of the
456 | Corresponding Source of the work from the predecessor in interest, if
457 | the predecessor has it or can get it with reasonable efforts.
458 |
459 | You may not impose any further restrictions on the exercise of the
460 | rights granted or affirmed under this License. For example, you may
461 | not impose a license fee, royalty, or other charge for exercise of
462 | rights granted under this License, and you may not initiate litigation
463 | (including a cross-claim or counterclaim in a lawsuit) alleging that
464 | any patent claim is infringed by making, using, selling, offering for
465 | sale, or importing the Program or any portion of it.
466 |
467 | #### 11. Patents.
468 |
469 | A "contributor" is a copyright holder who authorizes use under this
470 | License of the Program or a work on which the Program is based. The
471 | work thus licensed is called the contributor's "contributor version".
472 |
473 | A contributor's "essential patent claims" are all patent claims owned
474 | or controlled by the contributor, whether already acquired or
475 | hereafter acquired, that would be infringed by some manner, permitted
476 | by this License, of making, using, or selling its contributor version,
477 | but do not include claims that would be infringed only as a
478 | consequence of further modification of the contributor version. For
479 | purposes of this definition, "control" includes the right to grant
480 | patent sublicenses in a manner consistent with the requirements of
481 | this License.
482 |
483 | Each contributor grants you a non-exclusive, worldwide, royalty-free
484 | patent license under the contributor's essential patent claims, to
485 | make, use, sell, offer for sale, import and otherwise run, modify and
486 | propagate the contents of its contributor version.
487 |
488 | In the following three paragraphs, a "patent license" is any express
489 | agreement or commitment, however denominated, not to enforce a patent
490 | (such as an express permission to practice a patent or covenant not to
491 | sue for patent infringement). To "grant" such a patent license to a
492 | party means to make such an agreement or commitment not to enforce a
493 | patent against the party.
494 |
495 | If you convey a covered work, knowingly relying on a patent license,
496 | and the Corresponding Source of the work is not available for anyone
497 | to copy, free of charge and under the terms of this License, through a
498 | publicly available network server or other readily accessible means,
499 | then you must either (1) cause the Corresponding Source to be so
500 | available, or (2) arrange to deprive yourself of the benefit of the
501 | patent license for this particular work, or (3) arrange, in a manner
502 | consistent with the requirements of this License, to extend the patent
503 | license to downstream recipients. "Knowingly relying" means you have
504 | actual knowledge that, but for the patent license, your conveying the
505 | covered work in a country, or your recipient's use of the covered work
506 | in a country, would infringe one or more identifiable patents in that
507 | country that you have reason to believe are valid.
508 |
509 | If, pursuant to or in connection with a single transaction or
510 | arrangement, you convey, or propagate by procuring conveyance of, a
511 | covered work, and grant a patent license to some of the parties
512 | receiving the covered work authorizing them to use, propagate, modify
513 | or convey a specific copy of the covered work, then the patent license
514 | you grant is automatically extended to all recipients of the covered
515 | work and works based on it.
516 |
517 | A patent license is "discriminatory" if it does not include within the
518 | scope of its coverage, prohibits the exercise of, or is conditioned on
519 | the non-exercise of one or more of the rights that are specifically
520 | granted under this License. You may not convey a covered work if you
521 | are a party to an arrangement with a third party that is in the
522 | business of distributing software, under which you make payment to the
523 | third party based on the extent of your activity of conveying the
524 | work, and under which the third party grants, to any of the parties
525 | who would receive the covered work from you, a discriminatory patent
526 | license (a) in connection with copies of the covered work conveyed by
527 | you (or copies made from those copies), or (b) primarily for and in
528 | connection with specific products or compilations that contain the
529 | covered work, unless you entered into that arrangement, or that patent
530 | license was granted, prior to 28 March 2007.
531 |
532 | Nothing in this License shall be construed as excluding or limiting
533 | any implied license or other defenses to infringement that may
534 | otherwise be available to you under applicable patent law.
535 |
536 | #### 12. No Surrender of Others' Freedom.
537 |
538 | If conditions are imposed on you (whether by court order, agreement or
539 | otherwise) that contradict the conditions of this License, they do not
540 | excuse you from the conditions of this License. If you cannot convey a
541 | covered work so as to satisfy simultaneously your obligations under
542 | this License and any other pertinent obligations, then as a
543 | consequence you may not convey it at all. For example, if you agree to
544 | terms that obligate you to collect a royalty for further conveying
545 | from those to whom you convey the Program, the only way you could
546 | satisfy both those terms and this License would be to refrain entirely
547 | from conveying the Program.
548 |
549 | #### 13. Use with the GNU Affero General Public License.
550 |
551 | Notwithstanding any other provision of this License, you have
552 | permission to link or combine any covered work with a work licensed
553 | under version 3 of the GNU Affero General Public License into a single
554 | combined work, and to convey the resulting work. The terms of this
555 | License will continue to apply to the part which is the covered work,
556 | but the special requirements of the GNU Affero General Public License,
557 | section 13, concerning interaction through a network will apply to the
558 | combination as such.
559 |
560 | #### 14. Revised Versions of this License.
561 |
562 | The Free Software Foundation may publish revised and/or new versions
563 | of the GNU General Public License from time to time. Such new versions
564 | will be similar in spirit to the present version, but may differ in
565 | detail to address new problems or concerns.
566 |
567 | Each version is given a distinguishing version number. If the Program
568 | specifies that a certain numbered version of the GNU General Public
569 | License "or any later version" applies to it, you have the option of
570 | following the terms and conditions either of that numbered version or
571 | of any later version published by the Free Software Foundation. If the
572 | Program does not specify a version number of the GNU General Public
573 | License, you may choose any version ever published by the Free
574 | Software Foundation.
575 |
576 | If the Program specifies that a proxy can decide which future versions
577 | of the GNU General Public License can be used, that proxy's public
578 | statement of acceptance of a version permanently authorizes you to
579 | choose that version for the Program.
580 |
581 | Later license versions may give you additional or different
582 | permissions. However, no additional obligations are imposed on any
583 | author or copyright holder as a result of your choosing to follow a
584 | later version.
585 |
586 | #### 15. Disclaimer of Warranty.
587 |
588 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
589 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
590 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
591 | WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
592 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
593 | A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
594 | PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
595 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
596 | CORRECTION.
597 |
598 | #### 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
602 | CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
603 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
604 | ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
605 | NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
606 | LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
607 | TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
608 | PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
609 |
610 | #### 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | ### How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these
626 | terms.
627 |
628 | To do so, attach the following notices to the program. It is safest to
629 | attach them to the start of each source file to most effectively state
630 | the exclusion of warranty; and each file should have at least the
631 | "copyright" line and a pointer to where the full notice is found.
632 |
633 |
634 | Copyright (C)
635 |
636 | This program is free software: you can redistribute it and/or modify
637 | it under the terms of the GNU General Public License as published by
638 | the Free Software Foundation, either version 3 of the License, or
639 | (at your option) any later version.
640 |
641 | This program is distributed in the hope that it will be useful,
642 | but WITHOUT ANY WARRANTY; without even the implied warranty of
643 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
644 | GNU General Public License for more details.
645 |
646 | You should have received a copy of the GNU General Public License
647 | along with this program. If not, see .
648 |
649 | Also add information on how to contact you by electronic and paper
650 | mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands \`show w' and \`show c' should show the
661 | appropriate parts of the General Public License. Of course, your
662 | program's commands might be different; for a GUI interface, you would
663 | use an "about box".
664 |
665 | You should also get your employer (if you work as a programmer) or
666 | school, if any, to sign a "copyright disclaimer" for the program, if
667 | necessary. For more information on this, and how to apply and follow
668 | the GNU GPL, see .
669 |
670 | The GNU General Public License does not permit incorporating your
671 | program into proprietary programs. If your program is a subroutine
672 | library, you may consider it more useful to permit linking proprietary
673 | applications with the library. If this is what you want to do, use the
674 | GNU Lesser General Public License instead of this License. But first,
675 | please read .
676 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # aqours
2 |
3 | 
4 |
5 | A playlist-oriented music player. This music player is a work-in-progress, so it
6 | will be missing many needed features, such as adding and removing songs, as well
7 | as containing many bugs.
8 |
9 | ## Dependencies
10 |
11 | - gtk3
12 | - mpv
13 | - ffmpeg
14 | - Building with tag `catnip` (visualizer):
15 | - parec or portaudio or ffmpeg
16 | - fftw
17 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/diamondburned/aqours
2 |
3 | go 1.17
4 |
5 | replace github.com/DexterLB/mpvipc => github.com/diamondburned/mpvipc v0.0.0-20201209233959-abc9af4dc0af
6 |
7 | replace github.com/noriah/catnip => github.com/diamondburned/tavis v0.0.0-20210213063943-442356061e51
8 |
9 | require (
10 | github.com/DexterLB/mpvipc v0.0.0-20190216161438-2a226fa01bbd
11 | github.com/dhowden/tag v0.0.0-20200828214007-46e57f75dbfc
12 | github.com/diamondburned/audpl v0.0.0-20201107052523-20d1b6c126e7
13 | github.com/diamondburned/gotk4/pkg v0.0.0-20220224183509-a424ccf7497a
14 | github.com/go-test/deep v1.0.7
15 | github.com/godbus/dbus/v5 v5.0.3
16 | github.com/lithammer/fuzzysearch v1.1.1
17 | github.com/pkg/errors v0.9.1
18 | github.com/ushis/m3u v0.0.0-20150127162843-94396b784733
19 | )
20 |
21 | require (
22 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063 // indirect
23 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
24 | golang.org/x/text v0.3.2 // indirect
25 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
26 | )
27 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/dhowden/tag v0.0.0-20200828214007-46e57f75dbfc h1:8ndBJ8cTZwp5Qtl5fnG5bM/ekMnm1GFdUSMTGiN09K0=
2 | github.com/dhowden/tag v0.0.0-20200828214007-46e57f75dbfc/go.mod h1:SniNVYuaD1jmdEEvi+7ywb1QFR7agjeTdGKyFb0p7Rw=
3 | github.com/diamondburned/audpl v0.0.0-20201107052523-20d1b6c126e7 h1:Dgx4rdfcxY+DhJXowfIUY17Tt3G9ndN9yxnRLbSFiU8=
4 | github.com/diamondburned/audpl v0.0.0-20201107052523-20d1b6c126e7/go.mod h1:KT42fQJB9VDjTgtvToaw3znWShn7nS3aJ6n0QmCx1Bg=
5 | github.com/diamondburned/gotk4/pkg v0.0.0-20220224183509-a424ccf7497a h1:ds5PDUaU41TdRQxzH8iFXURaQudMMixehWPCsYsTW90=
6 | github.com/diamondburned/gotk4/pkg v0.0.0-20220224183509-a424ccf7497a/go.mod h1:dJ2gfR0gvBsGg4IteP8aMBq/U5Q9boDw0DP7kAjXTwM=
7 | github.com/diamondburned/mpvipc v0.0.0-20201209233959-abc9af4dc0af h1:ieA6nTXvTfZDMIMXbGtcwaqqvVz72XPXp0GY7PA0M6k=
8 | github.com/diamondburned/mpvipc v0.0.0-20201209233959-abc9af4dc0af/go.mod h1:MOb+Drd+EFz0VPj7rIvOWoXSujs55jAL7MGCUQIrM7M=
9 | github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
10 | github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
11 | github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME=
12 | github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
13 | github.com/lithammer/fuzzysearch v1.1.1 h1:8F9OAV2xPuYblToVohjanztdnPjbtA0MLgMvDKQ0Z08=
14 | github.com/lithammer/fuzzysearch v1.1.1/go.mod h1:H2bng+w5gsR7NlfIJM8ElGZI0sX6C/9uzGqicVXGU6c=
15 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
16 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
17 | github.com/ushis/m3u v0.0.0-20150127162843-94396b784733 h1:m4zGEkIeft/gfUs469WS/gB6NT3RtkG8zQrOvCOzovE=
18 | github.com/ushis/m3u v0.0.0-20150127162843-94396b784733/go.mod h1:/w56gU05vgM74JSy2/xFy6tUQ9vJBMiciHNvyIEU1UY=
19 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063 h1:1tk03FUNpulq2cuWpXZWj649rwJpk0d20rxWiopKRmc=
20 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
21 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
22 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
23 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
24 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
25 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
26 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
27 | gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
28 |
--------------------------------------------------------------------------------
/internal/durafmt/durafmt.go:
--------------------------------------------------------------------------------
1 | package durafmt
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 | )
8 |
9 | var durationChunks = []time.Duration{time.Hour, time.Minute, time.Second}
10 |
11 | // Format formats the given duration into HH:MM:SS form.
12 | func Format(d time.Duration) string {
13 | var dwords = make([]string, 0, 3)
14 | var n int
15 |
16 | for i, section := range durationChunks {
17 | n, d = divide(d, section)
18 | // Skip hour if there's none.
19 | if i == 0 && n < 1 {
20 | continue
21 | }
22 |
23 | dwords = append(dwords, fmt.Sprintf("%02d", n))
24 | }
25 |
26 | return strings.Join(dwords, ":")
27 | }
28 |
29 | func divide(d, div time.Duration) (n int, newd time.Duration) {
30 | n = int(d / div)
31 | return n, d - time.Duration(n)*div
32 | }
33 |
--------------------------------------------------------------------------------
/internal/gtkutil/gtkutil.go:
--------------------------------------------------------------------------------
1 | package gtkutil
2 |
3 | import (
4 | "log"
5 | "strings"
6 |
7 | "github.com/diamondburned/gotk4/pkg/gdk/v4"
8 | "github.com/diamondburned/gotk4/pkg/gio/v2"
9 | "github.com/diamondburned/gotk4/pkg/glib/v2"
10 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
11 | )
12 |
13 | // TODO: move these out of internal/ui/actions
14 | // TODO: move internal/ui/actions out of internal/ui
15 |
16 | // BindActionMap binds the given map of actions (of key prefixed appropriately)
17 | // to the given widget.
18 | func BindActionMap(wd gtk.Widgetter, m map[string]func()) {
19 | actions := make(map[string]*gio.SimpleActionGroup)
20 | w := gtk.BaseWidget(wd)
21 |
22 | for k, v := range m {
23 | parts := strings.SplitN(k, ".", 2)
24 | if len(parts) != 2 {
25 | log.Panicf("invalid action key %q", k)
26 | }
27 |
28 | group, ok := actions[parts[0]]
29 | if !ok {
30 | group = gio.NewSimpleActionGroup()
31 | w.InsertActionGroup(parts[0], group)
32 | actions[parts[0]] = group
33 | }
34 |
35 | group.AddAction(ActionFunc(parts[1], v))
36 | }
37 | }
38 |
39 | // ActionFunc creates a CallbackActionFunc from a function.
40 | func ActionFunc(name string, f func()) *gio.SimpleAction {
41 | c := gio.NewSimpleAction(name, nil)
42 | c.ConnectActivate(func(*glib.Variant) { f() })
43 | return c
44 | }
45 |
46 | // MenuPair creates a gtk.Menu out of the given menu pair. The returned Menu
47 | // instance satisfies gio.MenuModeller. The first value of a pair should be the
48 | // name.
49 | func MenuPair(pairs [][2]string) *gio.Menu {
50 | menu := gio.NewMenu()
51 | for _, pair := range pairs {
52 | menu.Append(pair[0], pair[1])
53 | }
54 | return menu
55 | }
56 |
57 | // PopoverWidth is the default popover width.
58 | const PopoverWidth = 150
59 |
60 | // NewPopoverMenu creates a new Popover menu.
61 | func NewPopoverMenu(w gtk.Widgetter, pos gtk.PositionType, menu gio.MenuModeller) *gtk.PopoverMenu {
62 | popover := gtk.NewPopoverMenuFromModel(menu)
63 | popover.SetParent(w)
64 | popover.SetPosition(pos)
65 | popover.SetSizeRequest(PopoverWidth, -1)
66 | popover.SetMnemonicsVisible(true)
67 | popover.ConnectClosed(func() {
68 | glib.TimeoutSecondsAdd(5, popover.Unparent)
69 | })
70 | return popover
71 | }
72 |
73 | // NewPopoverMenuAt is a convenient function for NewPopoverMenu and
74 | // SetPointingTo.
75 | func NewPopoverMenuAt(w gtk.Widgetter, pos gtk.PositionType, x, y float64, menu gio.MenuModeller) *gtk.PopoverMenu {
76 | rect := gdk.NewRectangle(int(x), int(y), 0, 0)
77 | p := NewPopoverMenu(w, pos, menu)
78 | p.SetPointingTo(&rect)
79 | return p
80 | }
81 |
82 | // BindPopoverMenu binds the menu popover at the given position for the given
83 | // widget.
84 | func BindPopoverMenu(wd gtk.Widgetter, pos gtk.PositionType, menu gio.MenuModeller) {
85 | BindRightClick(wd, func(x, y float64) {
86 | p := NewPopoverMenuAt(wd, pos, x, y, menu)
87 | p.Popup()
88 | })
89 | }
90 |
91 | func BindRightClick(wd gtk.Widgetter, f func(x, y float64)) {
92 | rclick := gtk.NewGestureClick()
93 | rclick.SetExclusive(true)
94 | rclick.SetButton(gdk.BUTTON_SECONDARY)
95 | rclick.ConnectPressed(func(n int, x, y float64) { f(x, y) })
96 |
97 | w := gtk.BaseWidget(wd)
98 | w.AddController(rclick)
99 | }
100 |
101 | // ActiveWindow returns the active window.
102 | func ActiveWindow() *gtk.Window {
103 | app := gio.ApplicationGetDefault().Cast().(*gtk.Application)
104 | if app != nil {
105 | return app.ActiveWindow()
106 | }
107 |
108 | windowList := gtk.WindowGetToplevels()
109 |
110 | for i := uint(0); true; i++ {
111 | window := windowList.Item(i)
112 | if window == nil {
113 | break
114 | }
115 |
116 | win := window.Cast().(*gtk.Window)
117 | if !win.IsActive() {
118 | continue
119 | }
120 |
121 | return win
122 | }
123 |
124 | return nil
125 | }
126 |
--------------------------------------------------------------------------------
/internal/mpris/mpris.go:
--------------------------------------------------------------------------------
1 | package mpris
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/muse"
5 | "github.com/diamondburned/aqours/internal/state"
6 | "github.com/diamondburned/aqours/internal/ui"
7 | "github.com/godbus/dbus/v5"
8 | "github.com/godbus/dbus/v5/introspect"
9 | "github.com/godbus/dbus/v5/prop"
10 | "github.com/pkg/errors"
11 | )
12 |
13 | const (
14 | aqoursPath = "/com/github/diamondburned/aqours"
15 | tracksPath = aqoursPath + "/Tracks"
16 |
17 | mprisPath = "/org/mpris/MediaPlayer2"
18 |
19 | introspectID = "org.freedesktop.DBus.Introspectable"
20 | mprisID = "org.mpris.MediaPlayer2"
21 | playerID = mprisID + ".Player"
22 | aqoursID = mprisID + ".aqours"
23 | )
24 |
25 | // Conn is a single MPRIS DBus connection.
26 | type Conn struct {
27 | conn *dbus.Conn
28 | player *player
29 | }
30 |
31 | // New creates a new MPRIS connection. It is not ready to be used until
32 | // PassthroughEvents is called.
33 | func New() (*Conn, error) {
34 | c, err := newConn()
35 | if err == nil {
36 | return c, nil
37 | }
38 |
39 | c.Close()
40 | return nil, err
41 | }
42 |
43 | func newConn() (*Conn, error) {
44 | s, err := dbus.SessionBus()
45 | if err != nil {
46 | return nil, errors.Wrap(err, "failed to connect to session bus")
47 | }
48 |
49 | props := map[string]map[string]*prop.Prop{
50 | playerID: playerProps,
51 | }
52 |
53 | p, err := prop.Export(s, mprisPath, props)
54 | if err != nil {
55 | return nil, errors.Wrap(err, "failed to create DBus properties")
56 | }
57 |
58 | conn := Conn{
59 | conn: s,
60 | player: newPlayer(p),
61 | }
62 |
63 | if err := s.Export(conn.player, mprisPath, playerID); err != nil {
64 | return &conn, errors.Wrap(err, "failed to export the MPRIS Player")
65 | }
66 |
67 | if err := s.Export(introspectionXML, mprisPath, introspectID); err != nil {
68 | return &conn, errors.Wrap(err, "failed to export introspection.xml")
69 | }
70 |
71 | reply, err := s.RequestName(aqoursID, dbus.NameFlagDoNotQueue)
72 | if err != nil {
73 | return &conn, errors.Wrap(err, "failed to request name")
74 | }
75 |
76 | if reply != dbus.RequestNameReplyPrimaryOwner {
77 | return &conn, errors.New("requested name is not primary, name already taken")
78 | }
79 |
80 | return &conn, nil
81 | }
82 |
83 | // Close closes the current DBus connection and destroys background workers. If
84 | // c is nil, then Close returns nil.
85 | func (c *Conn) Close() error {
86 | if c == nil {
87 | return nil
88 | }
89 |
90 | c.player.Destroy()
91 | return c.conn.Close()
92 | }
93 |
94 | // Update signals to MPRIS to update the properties from state.
95 | func (c *Conn) Update(s *state.State) {
96 | c.player.sendPlaying(s)
97 | }
98 |
99 | // PassthroughEvents passes-through events from the returned EventHandler into
100 | // MainWindow. Events that are intercepted will update the MPRIS state after
101 | // they're updated in the UI using w's methods.
102 | func (c *Conn) PassthroughEvents(w *ui.MainWindow) muse.EventHandler {
103 | c.player.MainWindow = w
104 | return c.player
105 | }
106 |
107 | const introspectionXML introspect.Introspectable = `
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | `
153 |
--------------------------------------------------------------------------------
/internal/mpris/player.go:
--------------------------------------------------------------------------------
1 | package mpris
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "math"
7 | "time"
8 |
9 | "github.com/diamondburned/aqours/internal/muse"
10 | "github.com/diamondburned/aqours/internal/state"
11 | "github.com/diamondburned/aqours/internal/ui"
12 | "github.com/diamondburned/gotk4/pkg/glib/v2"
13 | "github.com/godbus/dbus/v5"
14 | "github.com/godbus/dbus/v5/prop"
15 | )
16 |
17 | type microsecond = int
18 |
19 | func secondsToMicroseconds(secs float64) microsecond {
20 | const us = float64(time.Second / time.Microsecond)
21 | return int(math.Round(secs * us))
22 | }
23 |
24 | func microsecondsToSeconds(usec microsecond) float64 {
25 | const us = float64(time.Second / time.Microsecond)
26 | return float64(usec) / us
27 | }
28 |
29 | func trackID(trackIx int) dbus.ObjectPath {
30 | if trackIx < 0 {
31 | return dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack")
32 | }
33 | const trackIDfmt = tracksPath + "/%d"
34 | return dbus.ObjectPath(fmt.Sprintf(trackIDfmt, trackIx))
35 | }
36 |
37 | type player struct {
38 | *ui.MainWindow
39 | propQ chan propChange
40 | stop chan struct{}
41 |
42 | // state
43 | trackID dbus.ObjectPath
44 | // position microsecond
45 | }
46 |
47 | var _ muse.EventHandler = (*player)(nil)
48 |
49 | type propChange struct {
50 | n string
51 | v interface{}
52 | }
53 |
54 | func newPlayer(prop *prop.Properties) *player {
55 | propQ := make(chan propChange, 10)
56 | stop := make(chan struct{})
57 |
58 | go func() {
59 | for {
60 | select {
61 | case <-stop:
62 | return
63 | case send := <-propQ:
64 | if err := prop.Set(playerID, send.n, dbus.MakeVariant(send.v)); err != nil {
65 | log.Println("MRPIS set prop failed:", err)
66 | }
67 | }
68 | }
69 | }()
70 |
71 | return &player{
72 | MainWindow: nil,
73 | propQ: propQ,
74 | stop: stop,
75 | }
76 | }
77 |
78 | // Destroy stops background workers.
79 | func (p *player) Destroy() {
80 | close(p.stop)
81 | }
82 |
83 | // sendProp queues the prop to be sent through DBus. It pops off the first item
84 | // of the queue if it's full.
85 | func (p *player) sendProp(n string, v interface{}) {
86 | prop := propChange{n, v}
87 |
88 | for {
89 | select {
90 | case <-p.stop:
91 | return
92 | case p.propQ <- prop:
93 | return
94 | default:
95 | log.Println("Warning: prop send buffer overflow.")
96 |
97 | // Try and pop the earliest prop out.
98 | select {
99 | case <-p.propQ:
100 | default:
101 | }
102 | }
103 | }
104 | }
105 |
106 | // Muse event handler methods.
107 |
108 | var noTrackMetadata = map[string]interface{}{
109 | "mpris:trackid": trackID(-1),
110 | }
111 |
112 | // I hate implementing this, and I hate dbus. I hate its design. Why the fuck
113 | // would it create a feedback loop when it's waiting for a fucking reply? Why is
114 | // it designed this weirdly? Why can't it just be a stateless asynchronous event
115 | // receiver and state getter? Why does it need to fucking echo shit back? Why
116 | // does it send a few events at the start? Why does my volume all of a sudden
117 | // get kicked to 0.9 before going back to 1? Why is it constantly flipping
118 | // shuffle mode?
119 |
120 | func (p *player) SetRepeat(mode state.RepeatMode) {}
121 |
122 | func (p *player) SetShuffle(shuffle bool) {}
123 |
124 | func (p *player) SetVolume(volume float64) {}
125 |
126 | func (p *player) SetMute(mute bool) {}
127 |
128 | // Volume, Bitrate and OnSongFinish omitted (inherited).
129 |
130 | func (p *player) OnPauseUpdate(pause bool) {
131 | p.MainWindow.OnPauseUpdate(pause)
132 |
133 | if pause {
134 | p.sendProp("PlaybackStatus", "Paused")
135 | } else {
136 | p.sendProp("PlaybackStatus", "Playing")
137 | }
138 | }
139 |
140 | func (p *player) sendPlaying(state *state.State) {
141 | i, track := state.NowPlaying()
142 | p.trackID = trackID(i)
143 |
144 | // If we don't have anything playing...
145 | if track == nil {
146 | p.sendProp("Metadata", noTrackMetadata)
147 | p.sendProp("PlaybackStatus", "Paused")
148 | } else {
149 | metadata := track.Metadata()
150 | p.sendProp("PlaybackStatus", "Playing")
151 | p.sendProp("Metadata", map[string]interface{}{
152 | "mpris:trackid": p.trackID,
153 | "mpris:length": metadata.Length.Microseconds(),
154 | "xesam:title": metadata.Title,
155 | "xesam:album": metadata.Album,
156 | "xesam:artist": metadata.Artist,
157 | "xesam:trackNumber": metadata.Number,
158 | })
159 | }
160 | }
161 |
162 | // DBus methods.
163 |
164 | func (p *player) Next() *dbus.Error {
165 | glib.IdleAdd(func() { p.Bar.Controls.Buttons.Next.Activate() })
166 | return nil
167 | }
168 |
169 | func (p *player) Previous() *dbus.Error {
170 | glib.IdleAdd(func() { p.Bar.Controls.Buttons.Prev.Activate() })
171 | return nil
172 | }
173 |
174 | func (p *player) Pause() *dbus.Error {
175 | glib.IdleAdd(func() { p.Bar.Controls.Buttons.Play.SetPlaying(false) })
176 | return nil
177 | }
178 |
179 | func (p *player) Play() *dbus.Error {
180 | glib.IdleAdd(func() { p.Bar.Controls.Buttons.Play.SetPlaying(true) })
181 | return nil
182 | }
183 |
184 | func (s *player) Stop() *dbus.Error {
185 | return errUnimplemented
186 | }
187 |
188 | func (p *player) PlayPause() *dbus.Error {
189 | glib.IdleAdd(func() { p.Bar.Controls.Buttons.Play.Activate() })
190 | return nil
191 | }
192 |
193 | func (p *player) Seek(us microsecond) *dbus.Error {
194 | s := p.PlaySession()
195 |
196 | pos, _ := s.PlayState.PlayTime()
197 | pos += microsecondsToSeconds(us)
198 |
199 | glib.IdleAdd(func() { p.MainWindow.Seek(pos) })
200 | return nil
201 | }
202 |
203 | func (p *player) SetPosition(id dbus.ObjectPath, us microsecond) *dbus.Error {
204 | glib.IdleAdd(func() {
205 | // Seek if our trackID is not stale.
206 | if p.trackID == id {
207 | p.MainWindow.Seek(microsecondsToSeconds(us))
208 | return
209 | }
210 | })
211 |
212 | return nil
213 | }
214 |
--------------------------------------------------------------------------------
/internal/mpris/props.go:
--------------------------------------------------------------------------------
1 | package mpris
2 |
3 | import (
4 | "github.com/godbus/dbus/v5"
5 | "github.com/godbus/dbus/v5/prop"
6 | "github.com/pkg/errors"
7 | )
8 |
9 | var playerProps = map[string]*prop.Prop{
10 | "PlaybackStatus": newWritableProp("Paused", nil),
11 | "LoopStatus": newWritableProp("None", unimplementedChangeFn),
12 | "Rate": newWritableProp(1.0, unimplementedChangeFn),
13 | "Shuffle": newWritableProp(false, unimplementedChangeFn),
14 | "Metadata": newWritableProp(noTrackMetadata, nil),
15 | "Volume": newWritableProp(1.0, unimplementedChangeFn),
16 | "Position": newWritableUnemittedProp(int64(0), nil),
17 | "MinimumRate": newWritableProp(1.0, nil),
18 | "MaximumRate": newWritableProp(1.0, nil),
19 | "CanGoNext": newWritableProp(true, nil),
20 | "CanGoPrevious": newWritableProp(true, nil),
21 | "CanPlay": newWritableProp(true, nil),
22 | "CanPause": newWritableProp(true, nil),
23 | "CanSeek": newWritableProp(false, nil),
24 | "CanControl": newWritableProp(false, nil),
25 | }
26 |
27 | var errUnimplemented = dbus.MakeFailedError(errors.New("unimplemented"))
28 |
29 | func unimplementedChangeFn(*prop.Change) *dbus.Error {
30 | return errUnimplemented
31 | }
32 |
33 | func newWritableProp(v interface{}, fn func(*prop.Change) *dbus.Error) *prop.Prop {
34 | return &prop.Prop{
35 | Value: v,
36 | Writable: true,
37 | Emit: prop.EmitTrue,
38 | Callback: fn,
39 | }
40 | }
41 |
42 | func newWritableUnemittedProp(v interface{}, fn func(*prop.Change) *dbus.Error) *prop.Prop {
43 | return &prop.Prop{
44 | Value: v,
45 | Writable: true,
46 | Emit: prop.EmitFalse,
47 | Callback: fn,
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/internal/muse/albumart/albumart.go:
--------------------------------------------------------------------------------
1 | package albumart
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "io"
7 | "io/ioutil"
8 | "os"
9 | "path/filepath"
10 | "strings"
11 | "sync"
12 |
13 | "github.com/dhowden/tag"
14 | )
15 |
16 | // Stolen from: mpv/blob/master/player/external_files.c#L45, which was
17 | // stolen from: vlc/blob/master/modules/meta_engine/folder.c#L40.
18 | // Sorted by priority.
19 | var coverFiles = []string{
20 | "AlbumArt.jpg",
21 | "Album.jpg",
22 | "cover.jpg",
23 | "cover.png",
24 | "front.jpg",
25 | "front.png",
26 | "Cover.jpg",
27 |
28 | "AlbumArtSmall.jpg",
29 | "Folder.jpg",
30 | "Folder.png",
31 | "folder.jpg",
32 | ".folder.png",
33 | "thumb.jpg",
34 | "Thumb.jpg",
35 |
36 | "front.bmp",
37 | "front.gif",
38 | "cover.gif",
39 | }
40 |
41 | type File struct {
42 | io.ReadCloser
43 | Extension string // jpeg, ...
44 | }
45 |
46 | // AlbumArt queries for an album art. It returns an invalid File if there is no
47 | // album art. The function may read the album art into memory.
48 | //
49 | // The given context will directly control the returned file. If the context is
50 | // cancelled, then the file is also closed.
51 | func AlbumArt(ctx context.Context, path string) *File {
52 | // Prioritize searching for external album arts over reading the album art
53 | // into memory.
54 | dir := filepath.Dir(path)
55 |
56 | openCtx, cancelOpen := context.WithCancel(ctx)
57 | defer cancelOpen()
58 |
59 | waitGroup := sync.WaitGroup{}
60 | waitGroup.Add(len(coverFiles))
61 |
62 | go func() {
63 | waitGroup.Wait()
64 | cancelOpen()
65 | }()
66 |
67 | results := make(chan File)
68 |
69 | for _, coverFile := range coverFiles {
70 | go func(coverFile string) {
71 | defer waitGroup.Done()
72 |
73 | f, err := os.Open(filepath.Join(dir, coverFile))
74 | if err != nil {
75 | return
76 | }
77 |
78 | file := File{
79 | ReadCloser: f,
80 | Extension: normalizeExt(filepath.Ext(coverFile)),
81 | }
82 |
83 | select {
84 | case results <- file:
85 | // Stop prematurely.
86 | cancelOpen()
87 | // Close the file when the context is done.
88 | closeWhenDone(ctx, f)
89 | // done
90 | case <-openCtx.Done():
91 | f.Close()
92 | }
93 | }(coverFile)
94 | }
95 |
96 | select {
97 | case file := <-results:
98 | return &file
99 | case <-openCtx.Done():
100 | // continue
101 | }
102 |
103 | f, err := os.Open(path)
104 | if err != nil {
105 | return nil
106 | }
107 | defer f.Close()
108 |
109 | closeWhenDone(ctx, f)
110 |
111 | m, err := tag.ReadFrom(f)
112 | if err == nil {
113 | if pic := m.Picture(); pic != nil {
114 | return &File{
115 | ReadCloser: ioutil.NopCloser(bytes.NewReader(pic.Data)),
116 | Extension: normalizeExt(pic.Ext),
117 | }
118 | }
119 | }
120 |
121 | return nil
122 | }
123 |
124 | func closeWhenDone(ctx context.Context, f *os.File) {
125 | go func() { <-ctx.Done(); f.Close() }()
126 | }
127 |
128 | func normalizeExt(ext string) string {
129 | ext = strings.TrimPrefix(ext, ".")
130 | ext = strings.ToLower(ext)
131 |
132 | if ext == "jpg" {
133 | ext = "jpeg"
134 | }
135 |
136 | return ext
137 | }
138 |
--------------------------------------------------------------------------------
/internal/muse/metadata/ffmpeg/ffmpeg.go:
--------------------------------------------------------------------------------
1 | package ffmpeg
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "os"
8 | "os/exec"
9 | "time"
10 | )
11 |
12 | const bufSz = 1000 * 1000 // 1MB
13 |
14 | var globalCtx, globalStop = context.WithCancel(context.Background())
15 |
16 | func StopAll() {
17 | globalStop()
18 | }
19 |
20 | func AlbumArt(w io.Writer, path string, size int) error {
21 | ctx, cancel := context.WithTimeout(globalCtx, 1*time.Minute)
22 | defer cancel()
23 |
24 | vf := fmt.Sprintf("scale=-1:'min(%d,ih)'", size)
25 |
26 | cmd := exec.CommandContext(ctx,
27 | "ffmpeg",
28 | "-hide_banner", "-threads", "1", "-loglevel", "error", "-y",
29 | "-i", path,
30 | "-an",
31 | "-c:v", "mjpeg", "-sws_flags", "lanczos", "-q:v", "5", "-vf", vf,
32 | "-f", "mjpeg", "-",
33 | )
34 | cmd.Stderr = os.Stderr
35 | cmd.Stdout = w
36 |
37 | if err := cmd.Run(); err != nil {
38 | return err
39 | }
40 |
41 | return nil
42 | }
43 |
--------------------------------------------------------------------------------
/internal/muse/metadata/ffprobe/ffprobe.go:
--------------------------------------------------------------------------------
1 | package ffprobe
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "os"
7 | "os/exec"
8 | "strconv"
9 | "strings"
10 | "time"
11 |
12 | "github.com/pkg/errors"
13 | )
14 |
15 | func Probe(path string) (*ProbeResult, error) {
16 | ctx, cancel := context.WithTimeout(context.TODO(), 15*time.Second)
17 | defer cancel()
18 |
19 | cmd := exec.CommandContext(ctx,
20 | "ffprobe",
21 | "-loglevel", "fatal",
22 | "-print_format", "json",
23 | "-read_intervals", "%+1us",
24 | "-show_format",
25 | "-show_streams", "-select_streams", "a:0",
26 | path,
27 | )
28 | cmd.Stderr = os.Stderr
29 |
30 | o, err := cmd.StdoutPipe()
31 | if err != nil {
32 | return nil, errors.Wrap(err, "failed to make stdout pipe")
33 | }
34 | defer o.Close()
35 |
36 | if err := cmd.Start(); err != nil {
37 | return nil, errors.Wrap(err, "failed to start ffprobe")
38 | }
39 | defer cmd.Wait()
40 |
41 | var result ProbeResult
42 |
43 | if err := json.NewDecoder(o).Decode(&result); err != nil {
44 | return nil, errors.Wrap(err, "failed to parse ffprobe JSON")
45 | }
46 |
47 | return &result, nil
48 | }
49 |
50 | type ProbeResult struct {
51 | Format Format `json:"format"`
52 | Streams []Stream `json:"streams"`
53 | }
54 |
55 | var escaper = strings.NewReplacer("\n", `↵`)
56 |
57 | // TagValue searches the given key name in all possible tags in the format and
58 | // all streams.
59 | func (res ProbeResult) TagValue(name string) string {
60 | if v, ok := res.Format.Tags[name]; ok {
61 | return escaper.Replace(v)
62 | }
63 |
64 | for _, stream := range res.Streams {
65 | if v, ok := stream.Tags[name]; ok {
66 | return escaper.Replace(v)
67 | }
68 | }
69 |
70 | return ""
71 | }
72 |
73 | func (res ProbeResult) TagValueInt(name string, orInt int) int {
74 | v := res.TagValue(name)
75 | // Split the slash for certain values like 0/3 (track number), etc.
76 | i, err := strconv.Atoi(strings.SplitN(v, "/", 2)[0])
77 | if err != nil {
78 | return orInt
79 | }
80 | return i
81 | }
82 |
83 | type Format struct {
84 | Duration float64 `json:"duration,string"`
85 | BitRate int `json:"bit_rate,string"`
86 | Tags Tags `json:"tags"`
87 | }
88 |
89 | type Stream struct {
90 | CodecName string `json:"codec_name"`
91 | SampleRate int `json:"sample_rate,string"`
92 | Channels int `json:"channels"`
93 | ChannelLayout string `json:"channel_layout"`
94 | Tags Tags `json:"tags"`
95 | }
96 |
97 | type Tags map[string]string
98 |
99 | func (tags *Tags) UnmarshalJSON(v []byte) error {
100 | var rawTags = map[string]string{}
101 |
102 | if err := json.Unmarshal(v, &rawTags); err != nil {
103 | return err
104 | }
105 |
106 | for k, v := range rawTags {
107 | rawTags[strings.ToLower(k)] = v
108 | }
109 |
110 | *tags = rawTags
111 | return nil
112 | }
113 |
--------------------------------------------------------------------------------
/internal/muse/metadata/seekbufio/seekbufio.go:
--------------------------------------------------------------------------------
1 | package seekbufio
2 |
3 | // import (
4 | // "bytes"
5 | // "io"
6 |
7 | // "github.com/pkg/errors"
8 | // )
9 |
10 | // // Reader buffers the first n bytes to allow quicker seeking.
11 | // type Reader struct {
12 | // prefix *bytes.Reader
13 | // seeker io.ReadSeeker
14 | // cursor int64
15 | // }
16 |
17 | // var _ io.ReadSeeker = (*Reader)(nil)
18 |
19 | // func NewReaderSize(r io.ReadSeeker, prefixLen int64) (*Reader, error) {
20 | // prefix := bytes.Buffer{}
21 | // prefix.Grow(int(prefixLen))
22 |
23 | // if _, err := io.CopyN(&prefix, r, prefixLen); err != nil {
24 | // return nil, errors.Wrap(err, "failed to read prefix")
25 | // }
26 |
27 | // if _, err := r.Seek(0, io.SeekStart); err != nil {
28 | // return nil, errors.Wrap(err, "failed to seek back")
29 | // }
30 |
31 | // return &Reader{
32 | // prefix: bytes.NewReader(prefix.Bytes()),
33 | // seeker: r,
34 | // }, nil
35 | // }
36 |
37 | // func (r *Reader) Read(b []byte) (n int, err error) {
38 | // n, err = r.prefix.Read(b)
39 | // r.cursor += int64(n)
40 |
41 | // if n == len(b) && err == nil {
42 | // return n, err
43 | // }
44 |
45 | // r.seeker.Seek(r.cursor, io.SeekStart)
46 |
47 | // return r.seeker.Read(b[n:])
48 | // }
49 |
50 | // func (r *Reader) Seek(offset int64, whence int) (int64, error) {
51 | // n, err := r.prefix.Seek(offset, whence)
52 | // if err != nil {
53 | // return 0, err
54 | // }
55 |
56 | // r.cursor = n
57 |
58 | // if int(r.cursor) > r.prefix.Len() {
59 | // n, err = r.seeker.Seek(n, io.SeekStart)
60 | // }
61 |
62 | // return n, err
63 | // }
64 |
--------------------------------------------------------------------------------
/internal/muse/mpv.go:
--------------------------------------------------------------------------------
1 | package muse
2 |
3 | import (
4 | "context"
5 | "log"
6 | "math"
7 | "os"
8 | "os/exec"
9 | "path/filepath"
10 | "runtime"
11 | "strings"
12 | "sync/atomic"
13 | "time"
14 |
15 | "github.com/DexterLB/mpvipc"
16 | "github.com/diamondburned/gotk4/pkg/glib/v2"
17 | "github.com/pkg/errors"
18 | )
19 |
20 | type mpvEvent uint
21 |
22 | const (
23 | allEvent mpvEvent = iota
24 | pauseEvent
25 | bitrateEvent
26 | timePositionEvent
27 | timeRemainingEvent
28 | audioDeviceEvent
29 | )
30 |
31 | var events = []string{
32 | "idle",
33 | "end-file",
34 | }
35 |
36 | var propertyMap = map[mpvEvent]string{
37 | pauseEvent: "pause",
38 | bitrateEvent: "audio-bitrate",
39 | timePositionEvent: "time-pos",
40 | timeRemainingEvent: "time-remaining",
41 | audioDeviceEvent: "audio-device",
42 | }
43 |
44 | // EventHandler methods are all called in the glib main thread.
45 | type EventHandler interface {
46 | OnSongFinish()
47 | OnPauseUpdate(pause bool)
48 | }
49 |
50 | var tmpdir = filepath.Join(os.TempDir(), "aqours")
51 |
52 | func newMpv() (*Session, error) {
53 | sockPath := filepath.Join(tmpdir, "mpv", "mpv.sock")
54 |
55 | if err := os.MkdirAll(filepath.Dir(sockPath), os.ModePerm); err != nil {
56 | return nil, errors.Wrap(err, "failed to make socket directory")
57 | }
58 |
59 | // Trust Gtk in doing the right thing.
60 | if err := os.RemoveAll(sockPath); err != nil {
61 | return nil, errors.Wrap(err, "failed to clean up socket")
62 | }
63 |
64 | args := []string{
65 | "--idle",
66 | "--quiet",
67 | "--pause",
68 | "--no-input-terminal",
69 | "--loop-playlist=no",
70 | "--gapless-audio=weak",
71 | "--audio-client-name=aqours + mpv",
72 | "--replaygain=album",
73 | "--replaygain-clip=yes",
74 | "--ad=lavc:*",
75 | "--input-ipc-server=" + sockPath,
76 | "--volume=100",
77 | "--volume-max=100",
78 | "--no-video",
79 | }
80 |
81 | // Try and support MPV_MPRIS.
82 | if scripts := os.Getenv("MPV_SCRIPTS"); scripts != "" {
83 | for _, script := range strings.Split(scripts, ":") {
84 | args = append(args, "--script="+script)
85 | }
86 | }
87 |
88 | cmd := exec.Command("mpv", args...)
89 | cmd.Env = os.Environ()
90 | cmd.Stderr = os.Stderr
91 |
92 | conn := mpvipc.NewConnection(sockPath)
93 |
94 | if err := cmd.Start(); err != nil {
95 | return nil, errors.Wrap(err, "failed to start mpv")
96 | }
97 |
98 | // Give us a 5-second period timeout.
99 | ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
100 | defer cancel()
101 |
102 | // Spin until we can connect.
103 | var err error
104 | RetryOpen:
105 | for {
106 | err = conn.Open()
107 | if err == nil {
108 | cancel()
109 | break RetryOpen
110 | }
111 | select {
112 | case <-ctx.Done():
113 | break RetryOpen
114 | default:
115 | runtime.Gosched()
116 | continue RetryOpen
117 | }
118 | }
119 |
120 | if err != nil {
121 | return nil, errors.Wrap(err, "failed to open connection")
122 | }
123 |
124 | for _, event := range events {
125 | _, err := conn.Call("enable_event", event)
126 | if err != nil {
127 | return nil, errors.Wrapf(err, "failed to enable event %q", event)
128 | }
129 | }
130 |
131 | for id, property := range propertyMap {
132 | _, err := conn.Call("observe_property", id, property)
133 | if err != nil {
134 | return nil, errors.Wrapf(err, "failed to observe property %q", property)
135 | }
136 | }
137 |
138 | return &Session{
139 | Playback: conn,
140 | PlayState: &PlayState{},
141 | Command: cmd,
142 | socketPath: sockPath,
143 | OnAsyncError: func(err error) {
144 | if err != nil {
145 | log.Println("mpv async error:", err)
146 | }
147 | },
148 | }, nil
149 | }
150 |
151 | func (s *Session) SetHandler(h EventHandler) {
152 | s.handler = h
153 | }
154 |
155 | // Start starts all the event listeners in background goroutines. As such, it is
156 | // non-blocking.
157 | func (s *Session) Start() {
158 | // Copy the handler so the caller cannot change it.
159 | var handler = s.handler
160 |
161 | s.Playback.ListenForEvents(func(event *mpvipc.Event) {
162 | if event.Error != "" {
163 | log.Println("Error in event:", event.Error)
164 | }
165 |
166 | if event.Data == nil {
167 | goto handleAllEvents
168 | }
169 |
170 | switch mpvEvent(event.ID) {
171 | case allEvent:
172 | goto handleAllEvents
173 |
174 | case pauseEvent:
175 | b := event.Data.(bool)
176 | glib.IdleAdd(func() { handler.OnPauseUpdate(b) })
177 |
178 | case bitrateEvent:
179 | s.PlayState.updateBitrate(event.Data.(float64))
180 |
181 | case timePositionEvent:
182 | s.PlayState.updatePos(event.Data.(float64))
183 |
184 | case timeRemainingEvent:
185 | s.PlayState.updateRem(event.Data.(float64))
186 |
187 | case audioDeviceEvent:
188 | log.Println("Audio device changed to", event.Data)
189 | }
190 |
191 | return
192 |
193 | handleAllEvents:
194 | switch event.Name {
195 | case "idle":
196 | // log.Println("Player is idle.")
197 | // glib.IdleAdd(func() { handler.OnSongFinish() })
198 |
199 | case "start-file":
200 | // For some reason, the end-file event behaves a bit erratically, so
201 | // we use start-file.
202 | s.PlayState.updatePos(0)
203 | s.PlayState.updateRem(0)
204 | s.PlayState.updateBitrate(0)
205 |
206 | glib.IdleAdd(func() {
207 | // Edge-case when we force playing; because we invoked this
208 | // action, we don't trigger the callback.
209 | if s.forced {
210 | s.forced = false
211 | return
212 | }
213 |
214 | s.stopped = true
215 | handler.OnSongFinish()
216 | })
217 | }
218 | })
219 | }
220 |
221 | // Stop stops the mpv session. It does nothing if it's called more than once. A
222 | // stopped session cannot be reused.
223 | func (s *Session) Stop() {
224 | s.Playback.Close()
225 |
226 | if err := s.Command.Process.Signal(os.Interrupt); err != nil {
227 | log.Println("Attempted to send SIGINT failed, error occured:", err)
228 | log.Println("Killing anyway.")
229 |
230 | if err = s.Command.Process.Kill(); err != nil {
231 | log.Println("Failed to kill mpv:", err)
232 | }
233 | } else {
234 | // Wait for mpv to finish up.
235 | s.Command.Wait()
236 | }
237 |
238 | if err := os.Remove(s.socketPath); err != nil {
239 | log.Println("Failed to clean up socket:", err)
240 | }
241 | }
242 |
243 | // PlayState wraps the current playback state.
244 | type PlayState struct {
245 | btr uint64
246 | pos uint64
247 | rem uint64
248 | }
249 |
250 | func (tc *PlayState) updatePos(pos float64) {
251 | atomic.StoreUint64(&tc.pos, math.Float64bits(pos))
252 | }
253 |
254 | func (tc *PlayState) updateRem(rem float64) {
255 | atomic.StoreUint64(&tc.rem, math.Float64bits(rem))
256 | }
257 |
258 | func (tc *PlayState) updateBitrate(btr float64) {
259 | atomic.StoreUint64(&tc.btr, math.Float64bits(btr))
260 | }
261 |
262 | // Bitrate reads the bitrate atomically.
263 | func (tc *PlayState) Bitrate() float64 {
264 | return math.Float64frombits(atomic.LoadUint64(&tc.btr))
265 | }
266 |
267 | // PlayTime reads the playback timestamps atomically.
268 | func (tc *PlayState) PlayTime() (pos, rem float64) {
269 | pos = math.Float64frombits(atomic.LoadUint64(&tc.pos))
270 | rem = math.Float64frombits(atomic.LoadUint64(&tc.rem))
271 | return
272 | }
273 |
--------------------------------------------------------------------------------
/internal/muse/muse.go:
--------------------------------------------------------------------------------
1 | package muse
2 |
3 | import (
4 | "log"
5 | "os/exec"
6 |
7 | "github.com/DexterLB/mpvipc"
8 | "github.com/pkg/errors"
9 |
10 | _ "github.com/diamondburned/aqours/internal/muse/playlist/audpl"
11 | _ "github.com/diamondburned/aqours/internal/muse/playlist/m3u"
12 | )
13 |
14 | var ErrNoPlaylistLoaded = errors.New("no playlist loaded")
15 |
16 | type Session struct {
17 | Playback *mpvipc.Connection
18 | PlayState *PlayState
19 | Command *exec.Cmd
20 |
21 | handler EventHandler
22 | socketPath string
23 |
24 | // OnAsyncError is called on both nil and non-nil.
25 | OnAsyncError func(error)
26 |
27 | nextSong string
28 | stopped bool
29 | forced bool
30 | }
31 |
32 | func NewSession() (*Session, error) {
33 | return newMpv()
34 | }
35 |
36 | // PlayTrack asynchronously loads and plays a file. An error is not returned
37 | // because mpv doesn't seem to return one regardless.
38 | func (s *Session) PlayTrack(path, next string) {
39 | // We only need to play if the path to be loaded matches the path that's
40 | // already next in the playlist. Unless we're not stopped when the song is
41 | // changed, which possibly means that it's a user-requested action.
42 | if !s.stopped || s.nextSong != path {
43 | s.forced = true
44 | log.Println("Force loading path.")
45 |
46 | if err := s.loadFile(path, false); err != nil {
47 | log.Println("async loadfile failed:", err)
48 | return
49 | }
50 |
51 | if err := s.SetPlay(true); err != nil {
52 | log.Println("play failed:", err)
53 | }
54 | }
55 |
56 | s.stopped = false
57 |
58 | // Preload the next file.
59 | s.nextSong = next
60 | if next != "" {
61 | if err := s.loadFile(next, true); err != nil {
62 | log.Println("async loadfile next track failed:", err)
63 | return
64 | }
65 | }
66 | }
67 |
68 | func (s *Session) loadFile(file string, toAppend bool) (err error) {
69 | errFn := func(v interface{}, err error) { s.OnAsyncError(err) }
70 |
71 | if toAppend {
72 | err = s.Playback.CallAsync(errFn, "async", "loadfile", file, "append")
73 | } else {
74 | err = s.Playback.CallAsync(errFn, "async", "loadfile", file)
75 | }
76 |
77 | return
78 | }
79 |
80 | func (s *Session) Seek(pos float64) error {
81 | return s.Playback.SetAsync("time-pos", pos, s.OnAsyncError)
82 | }
83 |
84 | func (s *Session) SetPlay(playing bool) error {
85 | s.stopped = false
86 | return s.Playback.SetAsync("pause", !playing, s.OnAsyncError)
87 | }
88 |
89 | func (s *Session) SetVolume(perc float64) error {
90 | return s.Playback.SetAsync("volume", perc, s.OnAsyncError)
91 | }
92 |
93 | func (s *Session) SetMute(muted bool) error {
94 | return s.Playback.SetAsync("mute", muted, s.OnAsyncError)
95 | }
96 |
--------------------------------------------------------------------------------
/internal/muse/playlist/audpl/audpl.go:
--------------------------------------------------------------------------------
1 | package audpl
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "log"
7 | "os"
8 | "strconv"
9 | "strings"
10 | "time"
11 |
12 | "github.com/diamondburned/aqours/internal/muse/playlist"
13 | "github.com/diamondburned/audpl"
14 | "github.com/pkg/errors"
15 | )
16 |
17 | func init() {
18 | playlist.Register(".audpl", Parse, Write)
19 | }
20 |
21 | func Parse(path string) (*playlist.Playlist, error) {
22 | f, err := os.Open(path)
23 | if err != nil {
24 | return nil, err
25 | }
26 | defer f.Close()
27 |
28 | f.SetDeadline(time.Now().Add(15 * time.Second))
29 |
30 | p, err := audpl.Parse(f)
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | var playlistCopy = playlist.Playlist{
36 | Name: p.Name,
37 | Path: path,
38 | Tracks: make([]playlist.Track, 0, len(p.Tracks)),
39 | }
40 |
41 | for _, track := range p.Tracks {
42 | if !strings.HasPrefix(track.URI, "file://") {
43 | log.Println("[audpl]: rogue path not in local fs:", track.URI)
44 | continue
45 | }
46 |
47 | path := strings.TrimPrefix(track.URI, "file://")
48 |
49 | trackNum, _ := strconv.Atoi(track.TrackNumber)
50 | lengthMs, _ := strconv.Atoi(track.Length)
51 | bitrateKbit, _ := strconv.Atoi(track.Bitrate)
52 |
53 | playlistCopy.Tracks = append(playlistCopy.Tracks, playlist.Track{
54 | Title: track.Title,
55 | Artist: track.Artist,
56 | Album: track.Album,
57 | Number: trackNum,
58 | Length: time.Duration(lengthMs) * time.Millisecond,
59 | Bitrate: bitrateKbit * 1000,
60 | Filepath: path,
61 | })
62 | }
63 |
64 | return &playlistCopy, nil
65 | }
66 |
67 | func Write(p *playlist.Playlist, done func(error)) error {
68 | plist := audpl.Playlist{
69 | Name: p.Name,
70 | Tracks: make([]audpl.Track, len(p.Tracks)),
71 | }
72 |
73 | for i, track := range p.Tracks {
74 | plist.Tracks[i] = audpl.Track{
75 | Title: track.Title,
76 | Artist: track.Artist,
77 | Album: track.Album,
78 | TrackNumber: strconv.Itoa(track.Number),
79 | Length: strconv.Itoa(int(track.Length / time.Millisecond)),
80 | Bitrate: strconv.Itoa(int(track.Bitrate / 1000)),
81 | URI: fmt.Sprintf("file://%s", track.Filepath),
82 | }
83 | }
84 |
85 | go func() {
86 | f, err := os.Create(p.Path)
87 | if err != nil {
88 | done(errors.Wrap(err, "failed to create playlist file"))
89 | return
90 | }
91 | defer f.Close()
92 |
93 | buf := bufio.NewWriter(f)
94 |
95 | if err := plist.SaveTo(f); err != nil {
96 | done(errors.Wrap(err, "failed to write playlist"))
97 | return
98 | }
99 |
100 | if err := buf.Flush(); err != nil {
101 | done(errors.Wrap(err, "failed to flush"))
102 | return
103 | }
104 |
105 | done(nil)
106 | }()
107 |
108 | return nil
109 | }
110 |
--------------------------------------------------------------------------------
/internal/muse/playlist/m3u/m3u.go:
--------------------------------------------------------------------------------
1 | package m3u
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "net/url"
7 | "os"
8 | "path/filepath"
9 | "strings"
10 | "time"
11 |
12 | "github.com/diamondburned/aqours/internal/muse/playlist"
13 | "github.com/pkg/errors"
14 | "github.com/ushis/m3u"
15 | )
16 |
17 | func init() {
18 | playlist.Register(".m3u", Parse, Write)
19 | }
20 |
21 | func Parse(path string) (*playlist.Playlist, error) {
22 | f, err := os.Open(path)
23 | if err != nil {
24 | return nil, err
25 | }
26 | defer f.Close()
27 |
28 | f.SetDeadline(time.Now().Add(15 * time.Second))
29 |
30 | p, err := m3u.Parse(f)
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | var pl = playlist.Playlist{
36 | Name: basename(path),
37 | Path: path,
38 | Tracks: make([]playlist.Track, len(p)),
39 | }
40 |
41 | for i, track := range p {
42 | var title = track.Title
43 | if title == "" {
44 | title = filepath.Base(track.Path)
45 | }
46 | if title == "" {
47 | continue
48 | }
49 |
50 | pl.Tracks[i] = playlist.Track{
51 | Title: title,
52 | Length: time.Duration(track.Time) * time.Second,
53 | Filepath: track.Path,
54 | }
55 | }
56 |
57 | return &pl, nil
58 | }
59 |
60 | func basename(path string) string {
61 | name := filepath.Base(path)
62 | ext := filepath.Ext(name)
63 | name = name[:len(name)-len(ext)]
64 |
65 | u, err := url.PathUnescape(name)
66 | if err != nil {
67 | return name
68 | }
69 | return u
70 | }
71 |
72 | type ErrNamePathMismatch struct {
73 | name string
74 | base string
75 | }
76 |
77 | var _ playlist.FixableError = (*ErrNamePathMismatch)(nil)
78 |
79 | func (err ErrNamePathMismatch) Error() string {
80 | return fmt.Sprintf("Name %q mismatches path filename %q", err.name, err.base)
81 | }
82 |
83 | func (err ErrNamePathMismatch) Fix(pl *playlist.Playlist) {
84 | oldPath := pl.Path
85 | // Clean up the old playlist file.
86 | go func() { os.Rename(oldPath, makeDotfile(oldPath)) }()
87 |
88 | pl.Path = pathFromName(pl.Path, pl.Name)
89 | }
90 |
91 | func makeDotfile(path string) string {
92 | dir, name := filepath.Split(path)
93 | return filepath.Join(dir, fmt.Sprintf(".%s.bak", name))
94 | }
95 |
96 | var slashesc = strings.NewReplacer("/", "∕", `\`, "⧵").Replace
97 |
98 | func pathFromName(path, name string) string {
99 | dirnm := filepath.Dir(path)
100 | fname := fmt.Sprintf("%s.m3u", slashesc(name))
101 | return filepath.Join(dirnm, fname)
102 | }
103 |
104 | func Write(p *playlist.Playlist, done func(error)) error {
105 | // Verify.
106 | if p.Path != pathFromName(p.Path, p.Name) {
107 | return ErrNamePathMismatch{
108 | name: p.Name,
109 | base: basename(p.Path),
110 | }
111 | }
112 |
113 | var plist = make(m3u.Playlist, len(p.Tracks))
114 |
115 | for i, track := range p.Tracks {
116 | plist[i] = m3u.Track{
117 | Title: track.Title,
118 | Path: track.Filepath,
119 | Time: int64(track.Length.Seconds()),
120 | }
121 | }
122 |
123 | go func() {
124 | f, err := os.Create(p.Path)
125 | if err != nil {
126 | done(errors.Wrap(err, "failed to create playlist file"))
127 | return
128 | }
129 | defer f.Close()
130 |
131 | buf := bufio.NewWriter(f)
132 |
133 | if _, err := plist.WriteTo(f); err != nil {
134 | done(errors.Wrap(err, "failed to write playlist"))
135 | return
136 | }
137 |
138 | if err := buf.Flush(); err != nil {
139 | done(errors.Wrap(err, "failed to flush"))
140 | return
141 | }
142 |
143 | done(nil)
144 | }()
145 |
146 | return nil
147 | }
148 |
--------------------------------------------------------------------------------
/internal/muse/playlist/playlist.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "path/filepath"
7 | "sort"
8 | )
9 |
10 | type (
11 | PlaylistReader func(path string) (*Playlist, error)
12 | PlaylistWriter func(pl *Playlist, done func(error)) error
13 | )
14 |
15 | var (
16 | playlistReaders = map[string]PlaylistReader{}
17 | playlistWriters = map[string]PlaylistWriter{}
18 | )
19 |
20 | func SupportedExtensions() []string {
21 | var exts = make([]string, 0, len(playlistReaders))
22 | for ext := range playlistReaders {
23 | exts = append(exts, ext)
24 | }
25 | sort.Strings(exts)
26 | return exts
27 | }
28 |
29 | // FixableError is returned from PlaylistWriter if the error can be fixed
30 | // automatically.
31 | type FixableError interface {
32 | error
33 | Fix(playlist *Playlist)
34 | }
35 |
36 | func Register(fileExt string, r PlaylistReader, w PlaylistWriter) {
37 | playlistReaders[fileExt] = r
38 | playlistWriters[fileExt] = w
39 | }
40 |
41 | func ParseFile(path string) (*Playlist, error) {
42 | fn, ok := playlistReaders[filepath.Ext(path)]
43 | if !ok {
44 | return nil, fmt.Errorf("unknown format for path %q", path)
45 | }
46 |
47 | return fn(path)
48 | }
49 |
50 | type Playlist struct {
51 | Name string
52 | Path string
53 | Tracks []Track
54 | }
55 |
56 | // Save saves the playlist. The function must not be called in another
57 | // goroutine. The done callback may be called in a goroutine.
58 | func (pl *Playlist) Save(done func(error)) {
59 | fn, ok := playlistWriters[filepath.Ext(pl.Path)]
60 | if !ok {
61 | done(fmt.Errorf("unknown format for path %q", pl.Path))
62 | return
63 | }
64 |
65 | pl.save(fn, done)
66 | }
67 |
68 | func (pl *Playlist) save(wfn PlaylistWriter, done func(error)) {
69 | if err := wfn(pl, done); err != nil {
70 | // Try and fix the playlist if possible.
71 | var fixable FixableError
72 | if errors.As(err, &fixable) {
73 | fixable.Fix(pl)
74 | pl.save(wfn, done) // resave
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/internal/muse/playlist/shuffle.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | import (
4 | "encoding/binary"
5 | "math/rand"
6 | "time"
7 |
8 | cryptorand "crypto/rand"
9 | )
10 |
11 | func init() {
12 | rand.Seed(trueRandSeed())
13 | }
14 |
15 | // Meme.
16 | func trueRandSeed() (seed int64) {
17 | err := binary.Read(cryptorand.Reader, binary.LittleEndian, &seed)
18 | if err == nil {
19 | return
20 | }
21 | return time.Now().UnixNano()
22 | }
23 |
24 | // TODO: stateful shuffler
25 |
26 | // ShuffleQueue shuffles the given list of track indices.
27 | func ShuffleQueue(queue []int) {
28 | rand.Shuffle(len(queue), func(i, j int) {
29 | queue[i], queue[j] = queue[j], queue[i]
30 | })
31 | }
32 |
33 | // ResetQueue resets the queue to the usual incremental order.
34 | func ResetQueue(queue []int) {
35 | for i := 0; i < len(queue); i++ {
36 | queue[i] = i
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/internal/muse/playlist/track.go:
--------------------------------------------------------------------------------
1 | package playlist
2 |
3 | import (
4 | "path/filepath"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/diamondburned/aqours/internal/muse/metadata/ffprobe"
9 | )
10 |
11 | type Track struct {
12 | Title string
13 | Artist string
14 | Album string
15 | Genre string
16 | Date string
17 |
18 | Filepath string `json:",omitempty"`
19 |
20 | Number int
21 | Length time.Duration
22 | Bitrate int
23 |
24 | // Unprobeable is true if the Track cannot be probed.
25 | Unprobeable bool `json:"unprobeable,omitempty"`
26 | }
27 |
28 | // IsProbed returns true if the track is probed.
29 | func (t Track) IsProbed() bool {
30 | // Consider probed if we can't probe before.
31 | if t.Unprobeable {
32 | return true
33 | }
34 | return true &&
35 | (t.Bitrate > 0 && t.Length > 0) &&
36 | (t.Title != "" && t.Artist != "" && t.Album != "")
37 | }
38 |
39 | func (t *Track) Probe() error {
40 | if t.IsProbed() {
41 | return nil
42 | }
43 |
44 | return t.ForceProbe()
45 | }
46 |
47 | func (t *Track) ForceProbe() error {
48 | p, err := ffprobe.Probe(t.Filepath)
49 | if err != nil {
50 | // We can still reset the title and try to guess it. We might want to do
51 | // this if the playlist file has invalid titles.
52 | t.Title = TitleFromPath(t.Filepath)
53 | t.Unprobeable = true
54 | return err
55 | }
56 |
57 | title := p.TagValue("title")
58 |
59 | // Try and keep the old metadata the same, as playlist loaders might somehow
60 | // derive it.
61 | if title == "" {
62 | t.Unprobeable = true
63 | return nil
64 | }
65 |
66 | t.Title = title
67 | t.Artist = p.TagValue("artist")
68 | t.Album = p.TagValue("album")
69 | t.Number = p.TagValueInt("track", t.Number)
70 | t.Bitrate = p.Format.BitRate
71 | t.Length = time.Duration(p.Format.Duration * float64(time.Second))
72 | t.Date = p.TagValue("date")
73 |
74 | return nil
75 | }
76 |
77 | // TitleFromPath grabs the file basename from the given path, which could be
78 | // used as a title placeholder.
79 | func TitleFromPath(path string) string {
80 | return trimExt(filepath.Base(path))
81 | }
82 |
83 | func trimExt(name string) string {
84 | ext := filepath.Ext(name)
85 | return name[:len(name)-len(ext)]
86 | }
87 |
88 | func stringOr(str, or string) string {
89 | if str != "" {
90 | return str
91 | }
92 | return or
93 | }
94 |
95 | func intOr(str string, or int) int {
96 | if n, err := strconv.Atoi(str); err == nil {
97 | return n
98 | }
99 | return or
100 | }
101 |
--------------------------------------------------------------------------------
/internal/state/json.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | "os"
7 | "sync"
8 | "time"
9 |
10 | "github.com/diamondburned/aqours/internal/muse/playlist"
11 | )
12 |
13 | type jsonPlaylist struct {
14 | Name PlaylistName
15 | Path string
16 | }
17 |
18 | type jsonState struct {
19 | Playlists []jsonPlaylist `json:"playlist_names"`
20 | Metadata metadataMap `json:"metadata"`
21 |
22 | PlayingPlaylist string `json:"playing_playlist,omitempty"` // playlist name
23 | PlayingSongIndex int `json:"playing_song_index,omitempty"` // song index
24 |
25 | Shuffling bool `json:"shuffling"`
26 | Repeating RepeatMode `json:"repeating"`
27 | Volume float64 `json:"volume"`
28 | Muted bool `json:"muted"`
29 | }
30 |
31 | // MarshalJSON marshals State to JSON.
32 | func (s *State) MarshalJSON() ([]byte, error) {
33 | return json.Marshal(makeJSONState(s))
34 | }
35 |
36 | func fileJSONState(file string) (jsonState, error) {
37 | var jsonState jsonState
38 |
39 | f, err := os.Open(file)
40 | if err != nil {
41 | return jsonState, err
42 | }
43 | f.SetDeadline(time.Now().Add(10 * time.Second))
44 | defer f.Close()
45 |
46 | if err := json.NewDecoder(f).Decode(&jsonState); err != nil {
47 | return jsonState, err
48 | }
49 |
50 | return jsonState, nil
51 | }
52 |
53 | func makeJSONState(s *State) jsonState {
54 | var playlists = make([]jsonPlaylist, len(s.playlistNames))
55 | for i, name := range s.playlistNames {
56 | playlists[i] = jsonPlaylist{
57 | Name: name,
58 | Path: s.playlists[name].Path,
59 | }
60 | }
61 |
62 | playingPlaylist := s.PlayingPlaylistName()
63 | playingSongIndex := 0
64 |
65 | if len(s.playing.Queue) > 0 {
66 | playingSongIndex = s.playing.Queue[s.playing.QueuePos]
67 | }
68 |
69 | return jsonState{
70 | Playlists: playlists,
71 | Metadata: s.metadata,
72 | Shuffling: s.shuffling,
73 | Repeating: s.repeating,
74 | PlayingPlaylist: playingPlaylist,
75 | PlayingSongIndex: playingSongIndex,
76 | Volume: s.volume,
77 | Muted: s.muted,
78 | }
79 | }
80 |
81 | func (s *State) UnmarshalJSON(b []byte) error {
82 | var state jsonState
83 | if err := json.Unmarshal(b, &state); err != nil {
84 | return err
85 | }
86 |
87 | *s = *makeStateFromJSON(state, s.intern)
88 | return nil
89 | }
90 |
91 | func makeStateFromJSON(jsonState jsonState, intern *stateIntern) *State {
92 | state := &State{
93 | intern: intern,
94 | metadata: jsonState.Metadata,
95 | playlists: make(map[PlaylistName]*Playlist, len(jsonState.Playlists)),
96 | playlistNames: make([]PlaylistName, 0, len(jsonState.Playlists)),
97 | shuffling: jsonState.Shuffling,
98 | repeating: jsonState.Repeating,
99 | volume: jsonState.Volume,
100 | muted: jsonState.Muted,
101 | }
102 |
103 | // Load playlists concurrently.
104 | playlists := make([]*playlist.Playlist, len(jsonState.Playlists))
105 | waitGroup := sync.WaitGroup{}
106 | waitGroup.Add(len(jsonState.Playlists))
107 |
108 | for i, pl := range jsonState.Playlists {
109 | go func(i int, pl jsonPlaylist) {
110 | p, err := playlist.ParseFile(pl.Path)
111 | if err != nil {
112 | log.Printf("Ignoring erroneous playlist at %q, reason: %v\n", pl.Path, err)
113 | } else {
114 | p.Name = pl.Name
115 | playlists[i] = p
116 | }
117 |
118 | waitGroup.Done()
119 | }(i, pl)
120 | }
121 |
122 | waitGroup.Wait()
123 |
124 | for _, pl := range playlists {
125 | if pl == nil {
126 | continue
127 | }
128 |
129 | playlist := convertPlaylist(state, pl)
130 |
131 | state.playlistNames = append(state.playlistNames, playlist.Name)
132 | state.playlists[playlist.Name] = playlist
133 | }
134 |
135 | // Drop metadata with no references.
136 | for k, metadata := range state.metadata {
137 | if metadata.reference < 1 {
138 | delete(state.metadata, k)
139 | }
140 | }
141 |
142 | // Attempt to restore the currently playing states.
143 |
144 | if jsonState.PlayingPlaylist == "" {
145 | return state
146 | }
147 |
148 | pl, ok := state.playlists[jsonState.PlayingPlaylist]
149 | if !ok {
150 | return state
151 | }
152 |
153 | state.SetPlayingPlaylist(pl)
154 |
155 | // Attempt to find the right position by value from the queue position.
156 | for i, ix := range state.playing.Queue {
157 | if ix == jsonState.PlayingSongIndex {
158 | state.playing.QueuePos = i
159 | break
160 | }
161 | }
162 |
163 | return state
164 | }
165 |
--------------------------------------------------------------------------------
/internal/state/playlist.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "sort"
5 | "sync/atomic"
6 |
7 | "github.com/diamondburned/aqours/internal/muse/playlist"
8 | )
9 |
10 | type PlaylistName = string
11 |
12 | type Playlist struct {
13 | Name PlaylistName
14 | Path string
15 | Tracks []*Track
16 |
17 | state *State
18 | unsaved uint32 // atomic
19 | }
20 |
21 | func convertPlaylist(state *State, orig *playlist.Playlist) *Playlist {
22 | playlist := &Playlist{
23 | Name: orig.Name,
24 | Path: orig.Path,
25 | Tracks: make([]*Track, len(orig.Tracks)),
26 | state: state,
27 | unsaved: 0, // fresh state
28 | }
29 |
30 | for i, track := range orig.Tracks {
31 | playlist.Tracks[i] = &Track{
32 | Filepath: track.Filepath,
33 | playlist: playlist,
34 | }
35 |
36 | // Reincrement reference.
37 | md, ok := state.metadata[track.Filepath]
38 | if !ok {
39 | md = newMetadata(track)
40 | state.metadata[track.Filepath] = md
41 | state.intern.unsaved = true
42 | }
43 |
44 | md.reference++
45 | }
46 |
47 | return playlist
48 | }
49 |
50 | // SetUnsaved marks the playlist as unsaved.
51 | func (pl *Playlist) SetUnsaved() {
52 | atomic.StoreUint32(&pl.unsaved, 1)
53 | }
54 |
55 | // IsUnsaved returns true if the playlist is unsaved. It is thread-safe.
56 | func (pl *Playlist) IsUnsaved() bool {
57 | return atomic.LoadUint32(&pl.unsaved) == 1
58 | }
59 |
60 | // Add adds the given path to a track to the current playlist. It marks the
61 | // playlist as unsaved. If before is false, then the track is appended after the
62 | // index. If before is true, then the track is appended before the index. The
63 | // returned integers are the positions of the inserted tracks. If len(paths) is
64 | // 0, then ix is returned for both.
65 | func (pl *Playlist) Add(ix int, before bool, paths ...string) (start, end int) {
66 | if len(paths) == 0 {
67 | return ix, ix
68 | }
69 |
70 | pl.SetUnsaved()
71 |
72 | if !before {
73 | ix++
74 | }
75 |
76 | // https://github.com/golang/go/wiki/SliceTricks
77 | pl.Tracks = append(pl.Tracks, make([]*Track, len(paths))...)
78 | copy(pl.Tracks[ix+len(paths):], pl.Tracks[ix:])
79 |
80 | for i, path := range paths {
81 | pl.Tracks[ix+i] = &Track{
82 | Filepath: path,
83 | playlist: pl,
84 | }
85 |
86 | pl.state.metadata.ref(path)
87 | }
88 |
89 | return ix, ix + len(paths)
90 | }
91 |
92 | // Remove removes the tracks with the given indices. The function guarantees
93 | // that the delete will never touch tracks that didn't have the given indices
94 | // before removal; it does this by sorting the internal array of ixs.
95 | func (pl *Playlist) Remove(ixs ...int) {
96 | if len(ixs) == 0 {
97 | return
98 | }
99 |
100 | pl.SetUnsaved()
101 |
102 | // Sort indices from largest to smallest so we could pop the last track off
103 | // first to preserve order.
104 | sort.Sort(sort.Reverse(sort.IntSlice(ixs)))
105 |
106 | for _, ix := range ixs {
107 | track := pl.Tracks[ix]
108 | pl.state.metadata.unref(pl.state, track.Filepath)
109 |
110 | // https://github.com/golang/go/wiki/SliceTricks
111 | copy(pl.Tracks[ix:], pl.Tracks[ix+1:]) // shift backwards
112 | pl.Tracks[len(pl.Tracks)-1] = nil // nil last
113 | pl.Tracks = pl.Tracks[:len(pl.Tracks)-1] // omit last
114 | }
115 | }
116 |
117 | // Save saves the playlist. The function must not be called in another
118 | // goroutine. The done callback may be called in a goroutine.
119 | //
120 | // TODO: refactor this to Save() and WaitUntilSaved().
121 | func (pl *Playlist) Save(done func(error)) {
122 | if !pl.IsUnsaved() {
123 | done(nil)
124 | return
125 | }
126 |
127 | playlistCopy := playlist.Playlist{
128 | Name: pl.Name,
129 | Path: pl.Path,
130 | Tracks: make([]playlist.Track, len(pl.Tracks)),
131 | }
132 |
133 | for i, track := range pl.Tracks {
134 | playlistCopy.Tracks[i] = track.Metadata()
135 | }
136 |
137 | go playlistCopy.Save(func(err error) {
138 | if err == nil {
139 | atomic.StoreUint32(&pl.unsaved, 0)
140 | }
141 | done(err)
142 | })
143 | }
144 |
--------------------------------------------------------------------------------
/internal/state/playlist_test.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/go-test/deep"
9 | )
10 |
11 | type playlistTest struct {
12 | name string
13 | apply func(t *testing.T, pl *Playlist)
14 | expect []*Track
15 | }
16 |
17 | func TestAdd(t *testing.T) {
18 | testRunPlaylistTests(t, []playlistTest{
19 | {
20 | name: "empty addition",
21 | apply: func(t *testing.T, pl *Playlist) {
22 | addAndAssert(t, pl, 0, true, "0")
23 | },
24 | expect: emptyTracks("0"),
25 | },
26 | {
27 | name: "before",
28 | apply: func(t *testing.T, pl *Playlist) {
29 | pl.Tracks = emptyTracks("1", "2")
30 | addAndAssert(t, pl, 0, true, "0")
31 | addAndAssert(t, pl, 2, true, "1.5")
32 | },
33 | expect: emptyTracks("0", "1", "1.5", "2"),
34 | },
35 | {
36 | name: "before variadic",
37 | apply: func(t *testing.T, pl *Playlist) {
38 | pl.Tracks = emptyTracks("1", "2")
39 | addAndAssert(t, pl, 0, true, "0", "0.5")
40 | addAndAssert(t, pl, 3, true, "1.5", "1.75")
41 | },
42 | expect: emptyTracks("0", "0.5", "1", "1.5", "1.75", "2"),
43 | },
44 | {
45 | name: "after",
46 | apply: func(t *testing.T, pl *Playlist) {
47 | pl.Tracks = emptyTracks("0", "3")
48 | addAndAssert(t, pl, 0, false, "1")
49 | addAndAssert(t, pl, 1, false, "2")
50 | addAndAssert(t, pl, 3, false, "5")
51 | },
52 | expect: emptyTracks("0", "1", "2", "3", "5"),
53 | },
54 | {
55 | name: "after variadic",
56 | apply: func(t *testing.T, pl *Playlist) {
57 | pl.Tracks = emptyTracks("0", "3")
58 | addAndAssert(t, pl, 0, false, "1", "1.5")
59 | addAndAssert(t, pl, 2, false, "2", "2.5")
60 | addAndAssert(t, pl, 5, false, "4", "5")
61 | },
62 | expect: emptyTracks("0", "1", "1.5", "2", "2.5", "3", "4", "5"),
63 | },
64 | })
65 | }
66 |
67 | func addAndAssert(t *testing.T, pl *Playlist, ix int, before bool, paths ...string) {
68 | t.Helper()
69 |
70 | i, j := pl.Add(ix, before, paths...)
71 | assertTracks(t, pl.Tracks[i:j], emptyTracks(paths...))
72 | }
73 |
74 | func TestRemove(t *testing.T) {
75 | testRunPlaylistTests(t, []playlistTest{
76 | {
77 | name: "remove 0",
78 | apply: func(t *testing.T, pl *Playlist) {
79 | pl.Tracks = emptyTracks("0")
80 | pl.Remove(0)
81 | },
82 | expect: emptyTracks(),
83 | },
84 | {
85 | name: "between",
86 | apply: func(t *testing.T, pl *Playlist) {
87 | pl.Tracks = emptyTracks("1", "2", "3")
88 | pl.Remove(1)
89 | },
90 | expect: emptyTracks("1", "3"),
91 | },
92 | {
93 | name: "multiple between + last",
94 | apply: func(t *testing.T, pl *Playlist) {
95 | pl.Tracks = emptyTracks("1", "2", "3", "4", "5", "6", "7", "8")
96 | pl.Remove(2, 3, 4, 5, 6, 7)
97 | },
98 | expect: emptyTracks("1", "2"),
99 | },
100 | {
101 | name: "multiple first + last",
102 | apply: func(t *testing.T, pl *Playlist) {
103 | pl.Tracks = emptyTracks("1", "2", "3", "4")
104 | pl.Remove(0, 3)
105 | },
106 | expect: emptyTracks("2", "3"),
107 | },
108 | {
109 | name: "multiple first + between",
110 | apply: func(t *testing.T, pl *Playlist) {
111 | pl.Tracks = emptyTracks("1", "2", "3", "4")
112 | pl.Remove(0, 1, 2)
113 | },
114 | expect: emptyTracks("4"),
115 | },
116 | {
117 | name: "all",
118 | apply: func(t *testing.T, pl *Playlist) {
119 | pl.Tracks = emptyTracks("0", "1", "2", "3")
120 | pl.Remove(0, 1, 2, 3)
121 | },
122 | expect: emptyTracks(),
123 | },
124 | })
125 | }
126 |
127 | func testRunPlaylistTests(t *testing.T, tests []playlistTest) {
128 | t.Helper()
129 |
130 | for _, test := range tests {
131 | t.Run(test.name, func(t *testing.T) {
132 | pl := Playlist{
133 | state: &State{metadata: make(metadataMap)},
134 | }
135 | test.apply(t, &pl)
136 |
137 | if !pl.IsUnsaved() {
138 | t.Error("playlist is unsaved after applying")
139 | }
140 |
141 | assertTracks(t, pl.Tracks, test.expect)
142 | })
143 | }
144 | }
145 |
146 | func assertTracks(t *testing.T, tracksGot, tracksExpected []*Track) {
147 | t.Helper()
148 |
149 | if ineqs := deep.Equal(tracksGot, tracksExpected); ineqs != nil {
150 | t.Errorf("got: %s", fmtTracks(tracksGot))
151 | t.Errorf("expected: %s", fmtTracks(tracksExpected))
152 | }
153 | }
154 |
155 | func fmtTracks(tracks []*Track) string {
156 | var builder strings.Builder
157 | for _, track := range tracks {
158 | fmt.Fprintf(&builder, "%q ", track.Filepath)
159 | }
160 | return builder.String()
161 | }
162 |
163 | func emptyTracks(paths ...string) []*Track {
164 | var tracks = make([]*Track, len(paths))
165 | for i, path := range paths {
166 | tracks[i] = &Track{
167 | Filepath: path,
168 | }
169 | }
170 | return tracks
171 | }
172 |
--------------------------------------------------------------------------------
/internal/state/prober/prober.go:
--------------------------------------------------------------------------------
1 | package prober
2 |
3 | import (
4 | "log"
5 | "sync"
6 |
7 | "github.com/diamondburned/aqours/internal/muse/playlist"
8 | "github.com/diamondburned/aqours/internal/state"
9 | "github.com/diamondburned/gotk4/pkg/glib/v2"
10 | )
11 |
12 | // Job is an internal type that allows a track to be copied in a thread-safe way
13 | // for probing.
14 | type Job struct {
15 | done func() // called in glib
16 | ptr *state.Track
17 | cpy playlist.Track
18 | // Force, if true, forces a reprobe.
19 | Force bool
20 | }
21 |
22 | // NewJob creates a new job. done will be called in the glib main thread.
23 | func NewJob(track *state.Track, done func()) Job {
24 | return Job{
25 | ptr: track,
26 | done: done,
27 | cpy: track.Metadata(),
28 | }
29 | }
30 |
31 | // This is quite arbitrary, but it should be fast enough on a local disk and
32 | // doesn't clog much on a remote mount.
33 | var maxJobs = 4
34 |
35 | // Variables needed for dynamically scaling workers.
36 | var (
37 | runningMut sync.Mutex
38 | probeQueue chan Job
39 | probingGroup sync.WaitGroup
40 | )
41 |
42 | func ensureRunning() {
43 | // log.Println("worker group++")
44 | probingGroup.Add(1)
45 |
46 | runningMut.Lock()
47 | defer runningMut.Unlock()
48 |
49 | if probeQueue != nil {
50 | return
51 | }
52 |
53 | probeQueue = make(chan Job)
54 | startRunning(probeQueue)
55 | }
56 |
57 | func stopRunning() {
58 | // log.Println("worker group--")
59 | probingGroup.Done()
60 | }
61 |
62 | func startRunning(queue <-chan Job) {
63 | // log.Println("starting prober workers")
64 |
65 | go func() {
66 | probingGroup.Wait()
67 |
68 | runningMut.Lock()
69 | defer runningMut.Unlock()
70 |
71 | // Kill all current workers.
72 | if queue == probeQueue {
73 | close(probeQueue)
74 | probeQueue = nil
75 | }
76 | }()
77 |
78 | // Go is probably efficient enough to make this a minor issue.
79 | for i := 0; i < maxJobs; i++ {
80 | go func() {
81 | // log.Println("worker started")
82 | // defer log.Println("worker stopped")
83 |
84 | for job := range queue {
85 | job := job // copy for IdleAdd
86 |
87 | var err error
88 | if job.Force {
89 | // Probe and update the copy.
90 | err = job.cpy.ForceProbe()
91 | } else {
92 | err = job.cpy.Probe()
93 | }
94 |
95 | if err != nil {
96 | log.Printf("error probing %q: %v", job.cpy.Filepath, err)
97 | }
98 |
99 | glib.IdleAdd(func() {
100 | // Update the original track with the copy.
101 | job.ptr.UpdateMetadata(job.cpy)
102 | job.done()
103 | })
104 | }
105 | }()
106 | }
107 | }
108 |
109 | // Queue queues multiple probeJobs. It is thread-safe and non-blocking.
110 | func Queue(jobs ...Job) {
111 | if len(jobs) == 0 {
112 | return
113 | }
114 |
115 | go func() {
116 | ensureRunning()
117 | defer stopRunning()
118 |
119 | for _, job := range jobs {
120 | probeQueue <- job
121 | }
122 | }()
123 | }
124 |
--------------------------------------------------------------------------------
/internal/state/repeat.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | type RepeatMode uint8
4 |
5 | const (
6 | RepeatNone RepeatMode = iota
7 | RepeatAll
8 | RepeatSingle
9 | repeatLen
10 | )
11 |
12 | func enableRepeat(playlist bool) RepeatMode {
13 | if playlist {
14 | return RepeatAll
15 | }
16 | return RepeatSingle
17 | }
18 |
19 | // Cycle returns the next mode to be activated when the repeat button is
20 | // constantly pressed.
21 | func (m RepeatMode) Cycle() RepeatMode {
22 | return (m + 1) % repeatLen
23 | }
24 |
--------------------------------------------------------------------------------
/internal/state/state.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "sync"
10 |
11 | "github.com/diamondburned/aqours/internal/muse/playlist"
12 | "github.com/diamondburned/gotk4/pkg/glib/v2"
13 | "github.com/pkg/errors"
14 | )
15 |
16 | var stateDir, stateFile string
17 |
18 | func init() {
19 | stateDir = getConfigDir()
20 | stateFile = filepath.Join(stateDir, "state.json")
21 | }
22 |
23 | func getConfigDir() string {
24 | d := filepath.Join(glib.GetUserDataDir(), "aqours")
25 |
26 | if err := os.Mkdir(d, os.ModePerm); err != nil && !os.IsExist(err) {
27 | log.Println("failed to make data directory:", err)
28 | return ""
29 | }
30 |
31 | return d
32 | }
33 |
34 | func assert(b bool, e string) {
35 | if b {
36 | log.Panicln("BUG: assertion failed:", e)
37 | }
38 | }
39 |
40 | type stateIntern struct {
41 | // onUpdate is called when a playing track is updated.
42 | onUpdate func(s *State)
43 | saving sync.WaitGroup
44 | unsaved bool
45 | }
46 |
47 | func newStateIntern() *stateIntern {
48 | return &stateIntern{
49 | onUpdate: func(s *State) { s.intern.unsaved = true },
50 | }
51 | }
52 |
53 | // TODO: State is due for another factor. It should be a fully public structure
54 | // with private save states. The caller should manually call state.Updated().
55 |
56 | type State struct {
57 | intern *stateIntern
58 |
59 | metadata metadataMap
60 | playlistNames []PlaylistName
61 | playlists map[PlaylistName]*Playlist
62 |
63 | playing struct {
64 | Playlist *Playlist
65 | Queue []int // list of indices to playlists[playing.Playlist]
66 | QueuePos int // relative to Queue
67 | }
68 |
69 | volume float64
70 | muted bool
71 | shuffling bool
72 | repeating RepeatMode
73 | }
74 |
75 | // NewState creates an empty state.
76 | func NewState() *State {
77 | return &State{
78 | metadata: make(metadataMap),
79 | playlists: make(map[PlaylistName]*Playlist),
80 | volume: 100,
81 | intern: newStateIntern(),
82 | }
83 | }
84 |
85 | // ReadFromFile reads the state from the user's state.
86 | func ReadFromFile() (*State, error) {
87 | s, err := fileJSONState(stateFile)
88 | if err != nil {
89 | return nil, errors.Wrap(err, "failed to read state file")
90 | }
91 |
92 | return makeStateFromJSON(s, newStateIntern()), nil
93 | }
94 |
95 | // MarkChanged marks the state as changed (unsaved).
96 | func (s *State) MarkChanged() {
97 | s.intern.unsaved = true
98 | s.onUpdate()
99 | }
100 |
101 | // OnTrackUpdate adds into the call stack a callback that is triggered when the
102 | // state is changed.
103 | func (s *State) OnUpdate(fn func(*State)) {
104 | old := s.intern.onUpdate
105 | s.intern.onUpdate = func(s *State) {
106 | old(s)
107 | fn(s)
108 | }
109 | }
110 |
111 | func (s *State) onUpdate() {
112 | s.intern.onUpdate(s)
113 | }
114 |
115 | // RefreshQueue refreshes completely the current play queue.
116 | func (s *State) RefreshQueue() {
117 | s.SetPlayingPlaylist(s.playing.Playlist)
118 | }
119 |
120 | // assertCoherentState asserts everything in the JSON state with the helper
121 | // pointers.
122 | func (s *State) assertCoherentState() {
123 | // Nothing left. TODO: refactor this out.
124 | }
125 |
126 | // Playlist returns a playlist, or nil if none. It also returns a boolean to
127 | // indicate.
128 | func (s *State) Playlist(name string) (*Playlist, bool) {
129 | s.assertCoherentState()
130 |
131 | pl, ok := s.playlists[name]
132 | return pl, ok
133 | }
134 |
135 | // PlaylistFromPath returns a playlist, or nil if none.
136 | func (s *State) PlaylistFromPath(path string) (*Playlist, bool) {
137 | s.assertCoherentState()
138 |
139 | if s.playing.Playlist != nil && s.playing.Playlist.Path == path {
140 | return s.playing.Playlist, true
141 | }
142 |
143 | for _, playlist := range s.playlists {
144 | if playlist.Path == path {
145 | return playlist, true
146 | }
147 | }
148 |
149 | return nil, false
150 | }
151 |
152 | func (s *State) PlaylistNames() []PlaylistName {
153 | return s.playlistNames
154 | }
155 |
156 | func (s *State) RenamePlaylist(p *Playlist, oldName string) {
157 | pl, ok := s.playlists[oldName]
158 | if !ok {
159 | return
160 | }
161 |
162 | delete(s.playlists, oldName)
163 | s.playlists[p.Name] = pl
164 |
165 | for i, name := range s.playlistNames {
166 | if name == oldName {
167 | s.playlistNames[i] = p.Name
168 | break
169 | }
170 | }
171 |
172 | pl.Name = p.Name
173 |
174 | s.onUpdate()
175 | }
176 |
177 | // AddPlaylist adds a playlist. If a playlist with the same name is added, then
178 | // the function does nothing.
179 | func (s *State) AddPlaylist(p *playlist.Playlist) *Playlist {
180 | if _, ok := s.playlists[p.Name]; ok {
181 | log.Println("Playlist collision while adding:", p.Name)
182 | return nil
183 | }
184 |
185 | playlist := convertPlaylist(s, p)
186 | playlist.unsaved = 1
187 |
188 | s.playlists[p.Name] = playlist
189 | s.playlistNames = append(s.playlistNames, p.Name)
190 |
191 | s.onUpdate()
192 |
193 | return playlist
194 | }
195 |
196 | // DeletePlaylist deletes the playlist with the given name.
197 | func (s *State) DeletePlaylist(name string) {
198 | // TODO: optimize?
199 | for i, playlistName := range s.playlistNames {
200 | if playlistName == name {
201 | s.playlistNames = append(s.playlistNames[:i], s.playlistNames[i+1:]...)
202 | delete(s.playlists, name)
203 |
204 | if name == s.playing.Playlist.Name {
205 | s.SetPlayingPlaylist(nil)
206 | }
207 |
208 | s.onUpdate()
209 |
210 | return
211 | }
212 | }
213 | }
214 |
215 | // SetPlayingPlaylist sets the playing playlist.
216 | func (s *State) SetPlayingPlaylist(pl *Playlist) {
217 | s.assertCoherentState()
218 |
219 | defer s.onUpdate()
220 |
221 | s.playing.Playlist = pl
222 | s.playing.QueuePos = 0 // reset QueuePos as well
223 |
224 | if pl == nil {
225 | s.playing.Queue = nil
226 | return
227 | }
228 |
229 | s.ReloadPlayQueue()
230 |
231 | if s.shuffling {
232 | // Reshuffle.
233 | playlist.ShuffleQueue(s.playing.Queue)
234 | }
235 | }
236 |
237 | // PlayingPlaylist returns the playing playlist, or nil if none. It panics if
238 | // the internal states are inconsistent, which should never happen unless the
239 | // playlist pointer was illegally changed. If the path were to be changed, then
240 | // SetCurrentPlaylist should be called again.
241 | func (s *State) PlayingPlaylist() *Playlist {
242 | // coherency check skipped for performance.
243 | return s.playing.Playlist
244 | }
245 |
246 | // PlayingPlaylistName returns the playing playlist name, or an empty string if
247 | // none.
248 | func (s *State) PlayingPlaylistName() string {
249 | s.assertCoherentState()
250 |
251 | if s.playing.Playlist == nil {
252 | return ""
253 | }
254 |
255 | return s.playing.Playlist.Name
256 | }
257 |
258 | // NowPlaying returns the currently playing track. If playingPl is nil, then
259 | // this method returns (-1, nil).
260 | func (s *State) NowPlaying() (int, *Track) {
261 | s.assertCoherentState()
262 |
263 | if s.playing.Playlist == nil || s.playing.QueuePos < 0 {
264 | return -1, nil
265 | }
266 |
267 | ix := s.playing.Queue[s.playing.QueuePos]
268 | return ix, s.playing.Playlist.Tracks[ix]
269 | }
270 |
271 | // IsShuffling returns true if the list is being shuffled.
272 | func (s *State) IsShuffling() bool {
273 | return s.shuffling
274 | }
275 |
276 | // SetShuffling sets the shuffling mode.
277 | func (s *State) SetShuffling(shuffling bool) {
278 | // Do nothing if we're setting the same thing. Helps a bit w/ state
279 | // inconsistency.
280 | if s.shuffling == shuffling {
281 | return
282 | }
283 |
284 | defer s.onUpdate()
285 |
286 | s.shuffling = shuffling
287 |
288 | if s.playing.Playlist == nil {
289 | return
290 | }
291 |
292 | s.assertCoherentState()
293 |
294 | if shuffling {
295 | playlist.ShuffleQueue(s.playing.Queue)
296 | return
297 | }
298 |
299 | // Attempt to renew the QueuePos before changing the queue. As Queue holds a
300 | // list of actual track indices, we could use the queue position as the key
301 | // to get the actual position, then set that to the queue position.
302 | s.playing.QueuePos = s.playing.Queue[s.playing.QueuePos]
303 |
304 | playlist.ResetQueue(s.playing.Queue)
305 | }
306 |
307 | // RepeatMode returns the current repeat mode.
308 | func (s *State) RepeatMode() RepeatMode {
309 | return s.repeating
310 | }
311 |
312 | // SetRepeatMode sets the current repeat mode.
313 | func (s *State) SetRepeatMode(mode RepeatMode) {
314 | if s.repeating == mode {
315 | return
316 | }
317 |
318 | s.repeating = mode
319 |
320 | s.onUpdate()
321 | }
322 |
323 | // Volume returns the current volume.
324 | func (s *State) Volume() float64 {
325 | return s.volume
326 | }
327 |
328 | // SetVolume sets the volume.
329 | func (s *State) SetVolume(vol float64) {
330 | if s.volume == vol {
331 | return
332 | }
333 | s.volume = vol
334 | s.onUpdate()
335 | }
336 |
337 | // IsMuted returns the current muted state.
338 | func (s *State) IsMuted() bool {
339 | return s.muted
340 | }
341 |
342 | // SetMute sets the mute state.
343 | func (s *State) SetMute(muted bool) {
344 | if s.muted == muted {
345 | return
346 | }
347 | s.muted = muted
348 | s.onUpdate()
349 | }
350 |
351 | // ReloadPlayQueue reloads the internal play queue for the currently playing
352 | // playlist. Call this when the playlist's track slice is changed.
353 | func (s *State) ReloadPlayQueue() {
354 | s.assertCoherentState()
355 |
356 | if s.playing.Playlist == nil {
357 | return
358 | }
359 |
360 | if newlen := len(s.playing.Playlist.Tracks); newlen != len(s.playing.Queue) {
361 | s.playing.Queue = make([]int, newlen)
362 | }
363 |
364 | playlist.ResetQueue(s.playing.Queue)
365 |
366 | // Restore shuffling.
367 | if s.shuffling {
368 | playlist.ShuffleQueue(s.playing.Queue)
369 | }
370 |
371 | s.onUpdate()
372 | }
373 |
374 | // Play plays the track indexed relative to the actual playlist. This does not
375 | // index relative to the actual play queue, which may be shuffled. It also does
376 | // not update QueuePos if we're shuffling.
377 | func (s *State) Play(index int) *Track {
378 | // Only update QueuePos if we're not shuffling.
379 | if !s.shuffling {
380 | s.playing.QueuePos = index
381 | defer s.onUpdate()
382 | }
383 |
384 | return s.trackFromPlaylist(index)
385 | }
386 |
387 | func (s *State) trackFromQueue(index int) *Track {
388 | // Bound check.
389 | assert(index < 0, "index is negative")
390 | assert(index >= len(s.playing.Queue), "given index is out of bounds in Play")
391 |
392 | return s.trackFromPlaylist(s.playing.Queue[index])
393 | }
394 |
395 | func (s *State) trackFromPlaylist(index int) *Track {
396 | // Ensure that we have an active playing playlist.
397 | assert(s.playing.Playlist == nil, "playing.Playlist is nil while Play is called")
398 | // Assert the state after modifying the index.
399 | s.assertCoherentState()
400 |
401 | // Bound check.
402 | assert(index < 0, "index is negative")
403 | assert(index >= len(s.playing.Playlist.Tracks), "given index is out of bounds in Play")
404 |
405 | return s.playing.Playlist.Tracks[index]
406 | }
407 |
408 | // Previous returns the previous track, similarly to Next. Nil is returned if
409 | // there is no previous track. If shuffling mode is on, then Prev will not
410 | // return the previous track.
411 | func (s *State) Previous() (int, *Track) {
412 | return s.move(false, true)
413 | }
414 |
415 | // Next returns the next track from the currently playing playlist. Nil is
416 | // returned if there is no next track.
417 | func (s *State) Next() (int, *Track) {
418 | return s.move(true, true)
419 | }
420 |
421 | // AutoNext returns the next track, unless we're in RepeatSingle mode, then it
422 | // returns the same track. Use this to cycle across the playlist.
423 | func (s *State) AutoNext() (int, *Track) {
424 | return s.move(true, false)
425 | }
426 |
427 | // Peek returns the next track without changing the state. It basically emulates
428 | // AutoNext.
429 | func (s *State) Peek() (int, *Track) {
430 | return s.peek(true, false)
431 | }
432 |
433 | // move is an abstracted function used by Prev, Next and AutoNext.
434 | func (s *State) move(forward, force bool) (int, *Track) {
435 | next, track := s.peek(forward, force)
436 | // Only update the progress if we have something else to play.
437 | if next > -1 {
438 | s.playing.QueuePos = next
439 | s.onUpdate()
440 | }
441 | return next, track
442 | }
443 |
444 | func (s *State) peek(forward, force bool) (int, *Track) {
445 | s.assertCoherentState()
446 |
447 | if !force && s.repeating == RepeatSingle {
448 | return s.NowPlaying()
449 | }
450 |
451 | next, oob := spinIndex(forward, s.playing.QueuePos, len(s.playing.Queue))
452 |
453 | if oob && s.repeating == RepeatNone {
454 | return -1, nil
455 | }
456 |
457 | return next, s.trackFromQueue(next)
458 | }
459 |
460 | // spinIndex spins the index. It returns the newly spun index and whether it was
461 | // spun back.
462 | func spinIndex(fwd bool, i, max int) (int, bool) {
463 | if fwd {
464 | i++
465 |
466 | if i >= max {
467 | return 0, true
468 | }
469 | } else {
470 | i--
471 |
472 | if i < 0 {
473 | return max - 1, true
474 | }
475 | }
476 |
477 | return i, false
478 | }
479 |
480 | // SaveState saves the state. It is non-blocking, but the JSON is marshaled in
481 | // the same thread as the caller.
482 | func (s *State) SaveState() {
483 | if stateDir == "" {
484 | return
485 | }
486 |
487 | if !s.intern.unsaved {
488 | return
489 | }
490 |
491 | b, err := json.Marshal(makeJSONState(s))
492 | if err != nil {
493 | log.Println("failed to JSON marshal state:", err)
494 | return
495 | }
496 |
497 | s.intern.unsaved = false
498 | s.intern.saving.Add(1)
499 |
500 | go func() {
501 | if err := ioutil.WriteFile(stateFile, b, os.ModePerm); err != nil {
502 | log.Println("failed to save JSON state:", err)
503 | }
504 | s.intern.saving.Done()
505 | }()
506 | }
507 |
508 | // SaveAll saves the state and all its playlists. It's asynchronous.
509 | func (s *State) SaveAll() {
510 | b, err := json.Marshal(s)
511 | if err != nil {
512 | log.Println("Failed to JSON marshal state:", err)
513 | return
514 | }
515 |
516 | s.intern.unsaved = false
517 |
518 | s.intern.saving.Add(1)
519 | s.intern.saving.Add(len(s.playlists))
520 |
521 | go func() {
522 | if err := ioutil.WriteFile(stateFile, b, os.ModePerm); err != nil {
523 | log.Println("Failed to save JSON state:", err)
524 | }
525 | s.intern.saving.Done()
526 | }()
527 |
528 | for _, pl := range s.playlists {
529 | pl.Save(func(err error) {
530 | if err != nil {
531 | log.Println("Failed to save playlist:", err)
532 | }
533 | s.intern.saving.Done()
534 | })
535 | }
536 | }
537 |
538 | // WaitUntilSaved waits until all the saving routines are done. This is useful
539 | // for implementing a loading bar.
540 | func (s *State) WaitUntilSaved() {
541 | s.intern.saving.Wait()
542 | }
543 |
--------------------------------------------------------------------------------
/internal/state/track.go:
--------------------------------------------------------------------------------
1 | package state
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/diamondburned/aqours/internal/muse/playlist"
7 | )
8 |
9 | type metadataMap map[string]*metadata
10 |
11 | func (mm metadataMap) ref(path string) {
12 | md, ok := mm[path]
13 | if !ok {
14 | return
15 | }
16 | md.reference++
17 | }
18 |
19 | func (mm metadataMap) unref(s *State, path string) {
20 | md, ok := mm[path]
21 | if !ok {
22 | return
23 | }
24 | md.reference--
25 | if md.reference == 0 {
26 | delete(mm, path)
27 | s.intern.unsaved = true
28 | }
29 | }
30 |
31 | // metadata is a metadata state value that is shared across tracks.
32 | type metadata struct {
33 | // DON'T COPY!!
34 | _ [0]sync.Mutex
35 |
36 | playlist.Track
37 | reference int32
38 | }
39 |
40 | func newMetadata(t playlist.Track) *metadata {
41 | t.Filepath = ""
42 |
43 | return &metadata{
44 | Track: t,
45 | }
46 | }
47 |
48 | // Track is a track value that keeps track of a Metadata pointer and a filepath.
49 | type Track struct {
50 | // DON'T COPY!!
51 | _ [0]sync.Mutex
52 |
53 | Filepath string
54 | playlist *Playlist
55 | }
56 |
57 | // UpdateMetadata updates the track's metadata in the global metadata store. If
58 | // the metadata does not yet exist, it will create a new one and automatically
59 | // reference it. Else, no references are taken.
60 | func (t *Track) UpdateMetadata(i playlist.Track) {
61 | md, ok := t.playlist.state.metadata[t.Filepath]
62 | if !ok {
63 | md = newMetadata(i)
64 | md.reference = 1
65 | t.playlist.state.metadata[t.Filepath] = md
66 | }
67 |
68 | i.Filepath = ""
69 | md.Track = i
70 |
71 | // Mark as unsaved.
72 | t.playlist.state.MarkChanged()
73 | }
74 |
75 | // Metadata returns a copy of the current track's metadata with the filepath
76 | // filled in. If the metadata is not found, then a placeholder one is returned.
77 | func (t *Track) Metadata() (track playlist.Track) {
78 | if md, ok := t.playlist.state.metadata[t.Filepath]; ok {
79 | track = md.Track
80 | } else {
81 | track.Title = playlist.TitleFromPath(t.Filepath)
82 | }
83 |
84 | track.Filepath = t.Filepath
85 |
86 | return
87 | }
88 |
--------------------------------------------------------------------------------
/internal/ui/actions/actions.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "log"
5 | "strings"
6 |
7 | "github.com/diamondburned/gotk4/pkg/gio/v2"
8 | "github.com/diamondburned/gotk4/pkg/glib/v2"
9 | )
10 |
11 | // Stateful is a stateful action group, which would allow additional methods
12 | // that would otherwise be impossible to do with a simple Action Map.
13 | type Stateful struct {
14 | *gio.SimpleActionGroup
15 | labels []string // labels
16 | }
17 |
18 | func NewStateful() *Stateful {
19 | group := gio.NewSimpleActionGroup()
20 | return &Stateful{
21 | SimpleActionGroup: group,
22 | }
23 | }
24 |
25 | func (s *Stateful) Reset() {
26 | for _, label := range s.labels {
27 | s.RemoveAction(ActionName(label))
28 | }
29 | s.labels = nil
30 | }
31 |
32 | // Len returns the number of menu entries.
33 | func (s *Stateful) Len() int {
34 | return len(s.labels)
35 | }
36 |
37 | func (s *Stateful) AddAction(label string, call func()) {
38 | sa := gio.NewSimpleAction(ActionName(label), nil)
39 | sa.ConnectActivate(func(*glib.Variant) { call() })
40 |
41 | s.labels = append(s.labels, label)
42 | s.SimpleActionGroup.AddAction(sa)
43 | }
44 |
45 | func (s *Stateful) LookupAction(label string) gio.Actioner {
46 | for _, l := range s.labels {
47 | if l == label {
48 | return s.SimpleActionGroup.LookupAction(ActionName(label))
49 | }
50 | }
51 | return nil
52 | }
53 |
54 | func (s *Stateful) RemoveAction(label string) {
55 | for i, l := range s.labels {
56 | if l == label {
57 | s.labels = append(s.labels[:i], s.labels[:i+1]...)
58 | s.SimpleActionGroup.RemoveAction(ActionName(label))
59 | return
60 | }
61 | }
62 | }
63 |
64 | // ActionName converts the label name into the action name.
65 | func ActionName(label string) (actionName string) {
66 | actionName = strings.Replace(label, " ", "-", -1)
67 |
68 | if !gio.ActionNameIsValid(actionName) {
69 | log.Panicf("Label makes for invalid action name %q\n", actionName)
70 | }
71 |
72 | return
73 | }
74 |
--------------------------------------------------------------------------------
/internal/ui/actions/menu.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/diamondburned/gotk4/pkg/gdk/v4"
7 | "github.com/diamondburned/gotk4/pkg/gio/v2"
8 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 | )
10 |
11 | type Menu struct {
12 | *Stateful
13 | menu *gio.Menu
14 | prefix string
15 | }
16 |
17 | func NewMenu(prefix string) *Menu {
18 | return &Menu{
19 | Stateful: NewStateful(), // actiongroup and menu not linked
20 | menu: gio.NewMenu(),
21 | prefix: prefix,
22 | }
23 | }
24 |
25 | func (m *Menu) Prefix() string {
26 | return m.prefix
27 | }
28 |
29 | func (m *Menu) MenuModel() (string, *gio.MenuModel) {
30 | return m.prefix, &m.menu.MenuModel
31 | }
32 |
33 | func (m *Menu) InsertActionGroup(widget gtk.Widgetter) {
34 | w := gtk.BaseWidget(widget)
35 | w.InsertActionGroup(m.prefix, m)
36 | }
37 |
38 | func (m *Menu) ButtonRightClick(w gtk.Widgetter) {
39 | click := gtk.NewGestureClick()
40 | click.SetButton(gdk.BUTTON_SECONDARY)
41 | click.ConnectPressed(func(n int, x, y float64) {
42 | m.Popup(w)
43 | })
44 | }
45 |
46 | // Popup pops up the menu popover. It does not pop up anything if there are no
47 | // menu items.
48 | func (m *Menu) Popup(relative gtk.Widgetter) {
49 | p := m.popover(relative)
50 | if p == nil || m.Len() == 0 {
51 | return
52 | }
53 |
54 | p.Popup()
55 | }
56 |
57 | func (m *Menu) popover(relative gtk.Widgetter) *gtk.PopoverMenu {
58 | _, model := m.MenuModel()
59 |
60 | p := gtk.NewPopoverMenuFromModel(model)
61 | p.SetParent(relative)
62 | p.SetPosition(gtk.PosRight)
63 |
64 | return p
65 | }
66 |
67 | func (m *Menu) Reset() {
68 | m.menu.RemoveAll()
69 | m.Stateful.Reset()
70 | }
71 |
72 | func (m *Menu) AddAction(label string, call func()) {
73 | m.Stateful.AddAction(label, call)
74 | m.menu.Append(label, fmt.Sprintf("%s.%s", m.prefix, ActionName(label)))
75 | }
76 |
77 | func (m *Menu) RemoveAction(label string) {
78 | var labels = m.Stateful.labels
79 |
80 | for i, l := range labels {
81 | if l == label {
82 | labels = append(labels[:i], labels[:i+1]...)
83 | m.menu.Remove(i)
84 |
85 | m.Stateful.labels = labels
86 | m.Stateful.SimpleActionGroup.RemoveAction(ActionName(label))
87 |
88 | return
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/internal/ui/actions/menubutton.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "github.com/diamondburned/gotk4/pkg/gio/v2"
5 | "github.com/diamondburned/gotk4/pkg/glib/v2"
6 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
7 | )
8 |
9 | type MenuButton struct {
10 | *gtk.MenuButton
11 |
12 | lastsig glib.SignalHandle
13 | lastmod *gio.MenuModel
14 | }
15 |
16 | func NewMenuButton() *MenuButton {
17 | b := gtk.NewMenuButton()
18 | b.SetVAlign(gtk.AlignCenter)
19 | b.SetSensitive(false)
20 |
21 | return &MenuButton{
22 | MenuButton: b,
23 | }
24 | }
25 |
26 | // Bind binds the given menu. The menu's prefix MUST be a constant for this
27 | // instance of the MenuButton.
28 | func (m *MenuButton) Bind(menu *Menu) {
29 | prefix, model := menu.MenuModel()
30 |
31 | // Insert the action group into the menu. This will only override the old
32 | // action group, as the prefix is a constant for this instance.
33 | m.MenuButton.InsertActionGroup(prefix, menu)
34 | // Only after we have inserted the action group can we set the model that
35 | // menu has. This tells Gtk to look for the menu actions inside the inserted
36 | // group.
37 | m.MenuButton.SetMenuModel(model)
38 |
39 | // Unbind the last handler if we have one.
40 | if m.lastmod != nil {
41 | m.lastmod.HandlerDisconnect(m.lastsig)
42 | }
43 |
44 | // Set the current model as the last one for future calls.
45 | if m.lastmod = model; m.lastmod != nil {
46 | // If we have a model, then only activate the button when we have any
47 | // menu items.
48 | m.SetSensitive(model.NItems() > 0)
49 | // Subscribe the button to menu update events.
50 | m.lastsig = model.ConnectItemsChanged(func(_, _, _ int) {
51 | m.SetSensitive(model.NItems() > 0)
52 | })
53 | } else {
54 | // Else, don't allow the button to be clicked at all.
55 | m.SetSensitive(false)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/bar.go:
--------------------------------------------------------------------------------
1 | // Package bar contains the control bar.
2 | package bar
3 |
4 | import (
5 | "log"
6 |
7 | "github.com/diamondburned/aqours/internal/ui/content/bar/controls"
8 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 | )
10 |
11 | type ParentController interface {
12 | controls.ParentController
13 | ScrollToPlaying()
14 | SetVolume(perc float64)
15 | SetMute(muted bool)
16 | }
17 |
18 | type Container struct {
19 | gtk.Grid
20 | ParentController
21 |
22 | NowPlaying *NowPlaying
23 | Controls *controls.Container
24 | Volume *Volume
25 | }
26 |
27 | func NewContainer(parent ParentController) *Container {
28 | c := Container{ParentController: parent}
29 | c.NowPlaying = NewNowPlaying(parent)
30 |
31 | c.Controls = controls.NewContainer(parent)
32 | c.Controls.SetHExpand(true)
33 | c.Controls.SetHAlign(gtk.AlignFill)
34 |
35 | c.Volume = NewVolume(&c)
36 |
37 | grid := gtk.NewGrid()
38 | grid.SetRowHomogeneous(true)
39 | grid.SetColumnHomogeneous(true)
40 | grid.SetColumnSpacing(5)
41 | grid.SetHExpand(true)
42 |
43 | grid.Attach(c.NowPlaying, 0, 0, 2, 1) // 1st column; 2 columns
44 | grid.Attach(c.Controls, 3, 0, 3, 1) // 2nd-3rd; 3 columns
45 | grid.Attach(c.Volume, 6, 0, 2, 1) // 4th column; 2 columns
46 |
47 | c.Grid = *grid
48 | return &c
49 | }
50 |
51 | // SetPaused sets the paused state.
52 | func (c *Container) SetPaused(paused bool) {
53 | // c.Vis.SetPaused(paused)
54 | c.Controls.Buttons.Play.SetPlaying(!paused)
55 | }
56 |
57 | // SetVisualize sets the visualizer status.
58 | func (c *Container) SetVisualize(vis VisualizerStatus) {
59 | log.Println("visualizer unimplemented")
60 | }
61 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/controls/buttons.go:
--------------------------------------------------------------------------------
1 | package controls
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/state"
5 | "github.com/diamondburned/aqours/internal/ui/css"
6 | "github.com/diamondburned/gotk4/pkg/glib/v2"
7 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 | )
9 |
10 | var playbackButtonCSS = css.PrepareClass("playback-button", `
11 | .playback-button {
12 | margin: 2px 8px;
13 | margin-top: 12px;
14 | border-radius: 9999px;
15 |
16 | color: @theme_fg_color;
17 | opacity: 0.5;
18 |
19 | box-shadow: none;
20 | background: none;
21 | }
22 | .playback-button:hover {
23 | opacity: 1;
24 | }
25 | `)
26 |
27 | var prevCSS = css.PrepareClass("previous", ``)
28 |
29 | var nextCSS = css.PrepareClass("next", ``)
30 |
31 | var playPauseCSS = css.PrepareClass("playpause", `
32 | .playpause {
33 | opacity: 0.75;
34 | border: 1px solid alpha(@theme_fg_color, 0.45);
35 | }
36 | .playpause:hover {
37 | border: 1px solid alpha(@theme_fg_color, 0.85);
38 | }
39 | `)
40 |
41 | var repeatShuffleButtonCSS = css.PrepareClass("repeat-shuffle", `
42 | .repeat-shuffle:checked {
43 | color: @theme_selected_bg_color;
44 | opacity: 0.8;
45 | }
46 | .repeat-shuffle:hover {
47 | opacity: 1;
48 | }
49 | `)
50 |
51 | type Buttons struct {
52 | gtk.Box
53 | Shuffle *gtk.ToggleButton
54 | Prev *gtk.Button
55 | Play *PlayPause // Pause
56 | Next *gtk.Button
57 | Repeat *Repeat
58 | }
59 |
60 | func NewButtons(parent ParentController) *Buttons {
61 | shuf := gtk.NewToggleButton()
62 | shuf.SetChild(gtk.NewImageFromIconName("media-playlist-shuffle-symbolic"))
63 | shuf.SetVAlign(gtk.AlignCenter)
64 | shuf.ConnectToggled(func() { parent.SetShuffle(shuf.Active()) })
65 | playbackButtonCSS(shuf)
66 | repeatShuffleButtonCSS(shuf)
67 |
68 | prev := gtk.NewButton()
69 | prev.SetChild(gtk.NewImageFromIconName("media-skip-backward"))
70 | prev.SetVAlign(gtk.AlignCenter)
71 | prev.ConnectClicked(parent.Previous)
72 | playbackButtonCSS(prev)
73 | prevCSS(prev)
74 |
75 | pp := NewPlayPause(parent)
76 | playbackButtonCSS(pp)
77 | playPauseCSS(pp)
78 |
79 | next := gtk.NewButton()
80 | next.SetChild(gtk.NewImageFromIconName("media-skip-forward"))
81 | next.SetVAlign(gtk.AlignCenter)
82 | next.ConnectClicked(parent.Next)
83 | playbackButtonCSS(next)
84 | nextCSS(next)
85 |
86 | repeat := NewRepeat(parent)
87 | playbackButtonCSS(repeat)
88 | repeatShuffleButtonCSS(repeat)
89 |
90 | box := gtk.NewBox(gtk.OrientationHorizontal, 0)
91 | box.Append(shuf)
92 | box.Append(prev)
93 | box.Append(pp)
94 | box.Append(next)
95 | box.Append(repeat)
96 |
97 | return &Buttons{
98 | Box: *box,
99 | Shuffle: shuf,
100 | Prev: prev,
101 | Play: pp,
102 | Next: next,
103 | Repeat: repeat,
104 | }
105 | }
106 |
107 | // SetShuffle controls the shuffle button's state and triggers the callback.
108 | func (b *Buttons) SetShuffle(shuffle bool) {
109 | b.Shuffle.SetActive(shuffle)
110 | }
111 |
112 | // SetRepeat sets Repeat's mode. It does NOT trigger a callback to the parent.
113 | func (b *Buttons) SetRepeat(mode state.RepeatMode, callback bool) {
114 | b.Repeat.SetRepeat(mode, callback)
115 | }
116 |
117 | type Repeat struct {
118 | gtk.ToggleButton
119 | parent ParentController
120 | handleID glib.SignalHandle
121 |
122 | state state.RepeatMode
123 | icon *gtk.Image
124 | singleIcon *gtk.Image
125 | }
126 |
127 | func NewRepeat(parent ParentController) *Repeat {
128 | icon := gtk.NewImageFromIconName("media-playlist-repeat-symbolic")
129 | singleIcon := gtk.NewImageFromIconName("media-playlist-repeat-song-symbolic")
130 |
131 | button := gtk.NewToggleButton()
132 | button.SetVAlign(gtk.AlignCenter)
133 | button.SetChild(icon)
134 | button.SetActive(false)
135 |
136 | repeat := &Repeat{
137 | ToggleButton: *button,
138 | parent: parent,
139 | state: state.RepeatNone,
140 | icon: icon,
141 | singleIcon: singleIcon,
142 | }
143 |
144 | repeat.handleID = button.Connect("toggled", func() {
145 | parent.SetRepeat(repeat.state.Cycle())
146 | })
147 |
148 | return repeat
149 | }
150 |
151 | func (r *Repeat) SetRepeat(mode state.RepeatMode, callback bool) {
152 | // We should disable the handler here, as we don't want the callback to have
153 | // a feedback loop.
154 | if !callback {
155 | r.HandlerBlock(r.handleID)
156 | defer r.HandlerUnblock(r.handleID)
157 | }
158 |
159 | r.state = mode
160 |
161 | switch mode {
162 | case state.RepeatNone:
163 | r.SetActive(false)
164 | r.SetChild(r.icon)
165 |
166 | case state.RepeatSingle:
167 | r.SetActive(true)
168 | r.SetChild(r.singleIcon)
169 |
170 | case state.RepeatAll:
171 | r.SetActive(true)
172 | r.SetChild(r.icon)
173 | }
174 | }
175 |
176 | type PlayPause struct {
177 | gtk.Button
178 | playing bool
179 |
180 | playIcon *gtk.Image
181 | pauseIcon *gtk.Image
182 | }
183 |
184 | func NewPlayPause(parent ParentController) *PlayPause {
185 | play := gtk.NewImageFromIconName("media-playback-start-symbolic")
186 | pause := gtk.NewImageFromIconName("media-playback-pause-symbolic")
187 |
188 | pp := &PlayPause{
189 | playIcon: play,
190 | pauseIcon: pause,
191 | }
192 |
193 | btn := gtk.NewButton()
194 | btn.SetChild(pause)
195 | btn.SetVAlign(gtk.AlignCenter)
196 |
197 | pp.Button = *btn
198 |
199 | btn.Connect("clicked", func() { parent.SetPlay(!pp.playing) })
200 |
201 | return pp
202 | }
203 |
204 | func (pp *PlayPause) IsPlaying() bool {
205 | return pp.playing
206 | }
207 |
208 | func (pp *PlayPause) SetPlaying(playing bool) {
209 | pp.playing = playing
210 |
211 | if pp.playing {
212 | pp.SetChild(pp.pauseIcon)
213 | pp.SetTooltipText("Pause")
214 | } else {
215 | pp.SetChild(pp.playIcon)
216 | pp.SetTooltipText("Play")
217 | }
218 | }
219 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/controls/controls.go:
--------------------------------------------------------------------------------
1 | package controls
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/state"
5 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
6 | )
7 |
8 | type ParentController interface {
9 | Previous()
10 | Next()
11 | Seek(position float64)
12 | SetPlay(playing bool)
13 | SetRepeat(repeatMode state.RepeatMode)
14 | SetShuffle(shuffle bool)
15 | }
16 |
17 | type Container struct {
18 | gtk.Box
19 | Buttons *Buttons
20 | Seek *Seek
21 | }
22 |
23 | func NewContainer(parent ParentController) *Container {
24 | buttons := NewButtons(parent)
25 | buttons.SetHAlign(gtk.AlignCenter)
26 |
27 | seek := NewSeek(parent)
28 | seek.SetHAlign(gtk.AlignFill)
29 |
30 | box := gtk.NewBox(gtk.OrientationVertical, 0)
31 | box.Append(buttons)
32 | box.Append(seek)
33 |
34 | return &Container{
35 | Box: *box,
36 | Buttons: buttons,
37 | Seek: seek,
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/controls/seek.go:
--------------------------------------------------------------------------------
1 | package controls
2 |
3 | import (
4 | "fmt"
5 | "html"
6 | "math"
7 | "time"
8 |
9 | "github.com/diamondburned/aqours/internal/durafmt"
10 | "github.com/diamondburned/aqours/internal/ui/css"
11 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
12 | )
13 |
14 | var timePositionCSS = css.PrepareClass("time-position", "")
15 |
16 | var timeTotalCSS = css.PrepareClass("time-total", "")
17 |
18 | var seekBarCSS = css.PrepareClass("seek-bar", ``)
19 |
20 | var CleanScaleCSS = css.PrepareClass("clean-scale", `
21 | .clean-scale {
22 | margin: -2px 0px;
23 | }
24 | .clean-scale trough,
25 | .clean-scale highlight {
26 | border-radius: 9999px;
27 | }
28 | .clean-scale slider {
29 | padding: 1px;
30 | background: none;
31 | transition: linear 75ms background;
32 | }
33 | .clean-scale:hover slider {
34 | /* Shitty hack to limit background size. Thanks, GTK. */
35 | background: radial-gradient(
36 | circle,
37 | @theme_selected_bg_color 0%,
38 | @theme_selected_bg_color 25%,
39 | transparent 30%,
40 | transparent
41 | );
42 | }
43 | `)
44 |
45 | var seekCSS = css.PrepareClass("seek", "")
46 |
47 | const updateSeekEvery = 4 // update once every 4 spins
48 |
49 | type Seek struct {
50 | gtk.Box
51 | Position *gtk.Label
52 | SeekBar *gtk.Scale
53 | TotalTime *gtk.Label
54 |
55 | adj *gtk.Adjustment
56 | total float64 // rounded
57 | }
58 |
59 | func NewSeek(parent ParentController) *Seek {
60 | pos := gtk.NewLabel("")
61 | pos.SetSingleLineMode(true)
62 | pos.SetWidthChars(5)
63 |
64 | timePositionCSS(pos)
65 |
66 | time := gtk.NewLabel("")
67 | time.SetSingleLineMode(true)
68 | time.SetWidthChars(5)
69 |
70 | timeTotalCSS(time)
71 |
72 | adj := gtk.NewAdjustment(0, 0, 1, 1, 1, 0)
73 |
74 | bar := gtk.NewScale(gtk.OrientationHorizontal, adj)
75 | bar.SetDrawValue(false)
76 | bar.SetVAlign(gtk.AlignCenter)
77 | bar.SetHExpand(true)
78 | CleanScaleCSS(bar)
79 | seekBarCSS(bar)
80 |
81 | bar.Connect("change-value", func(_ *gtk.Scale, _ gtk.ScrollType, v float64) {
82 | parent.Seek(v)
83 | })
84 |
85 | box := gtk.NewBox(gtk.OrientationHorizontal, 0)
86 | box.Append(pos)
87 | box.Append(bar)
88 | box.Append(time)
89 |
90 | seekCSS(box)
91 |
92 | return &Seek{
93 | Box: *box,
94 | Position: pos,
95 | SeekBar: bar,
96 | TotalTime: time,
97 |
98 | adj: adj,
99 | }
100 | }
101 |
102 | const secondFloat = float64(time.Second)
103 |
104 | func (s *Seek) UpdatePosition(pos, total float64) {
105 | s.setTotal(math.Round(total))
106 | s.adj.SetValue(math.Min(pos, s.total))
107 |
108 | posDuration := time.Duration(pos * secondFloat)
109 | s.Position.SetMarkup(smallText(durafmt.Format(posDuration)))
110 | }
111 |
112 | func (s *Seek) setTotal(total float64) {
113 | if s.total != total {
114 | s.total = total
115 |
116 | s.adj.SetUpper(total)
117 | s.adj.SetPageIncrement(total / 10)
118 | s.adj.SetStepIncrement(total / 100)
119 |
120 | totalDuration := time.Duration(total * secondFloat)
121 | s.TotalTime.SetMarkup(smallText(durafmt.Format(totalDuration)))
122 | }
123 | }
124 |
125 | func smallText(text string) string {
126 | return fmt.Sprintf(
127 | `%s`,
128 | html.EscapeString(text),
129 | )
130 | }
131 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/playing.go:
--------------------------------------------------------------------------------
1 | package bar
2 |
3 | import (
4 | "html"
5 | "strings"
6 |
7 | "github.com/diamondburned/aqours/internal/state"
8 | "github.com/diamondburned/aqours/internal/ui/css"
9 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
10 | "github.com/diamondburned/gotk4/pkg/pango"
11 | )
12 |
13 | var titleCSS = css.PrepareClass("title", `
14 | label.now-playing.title {
15 | color: @theme_fg_color;
16 | font-weight: bold;
17 | }
18 | `)
19 |
20 | var subtitleCSS = css.PrepareClass("subtitle", `
21 | label.now-playing.subtitle {
22 | color: mix(@theme_bg_color, @theme_fg_color, 0.8);
23 | }
24 | `)
25 |
26 | var nowPlayingCSS = css.PrepareClass("now-playing", `
27 | button.now-playing {
28 | margin: 8px;
29 | padding: 0;
30 | background: none;
31 | box-shadow: none;
32 | transition: linear 45ms;
33 | }
34 | button.now-playing:active {
35 | margin-bottom: 6px;
36 | }
37 | `)
38 |
39 | type NowPlaying struct {
40 | gtk.Button
41 | Container *gtk.Box
42 |
43 | Title *gtk.Label
44 | SubReveal *gtk.Revealer
45 | Subtitle *gtk.Label
46 | }
47 |
48 | func NewNowPlaying(parent ParentController) *NowPlaying {
49 | title := gtk.NewLabel("")
50 | title.SetEllipsize(pango.EllipsizeEnd)
51 | title.SetXAlign(0)
52 | nowPlayingCSS(title)
53 | titleCSS(title)
54 |
55 | subtitle := gtk.NewLabel("")
56 | subtitle.SetEllipsize(pango.EllipsizeEnd)
57 | subtitle.SetXAlign(0)
58 | nowPlayingCSS(subtitle)
59 | subtitleCSS(subtitle)
60 |
61 | subrev := gtk.NewRevealer()
62 | subrev.SetTransitionDuration(100)
63 | subrev.SetTransitionType(gtk.RevealerTransitionTypeSlideDown)
64 | subrev.SetChild(subtitle)
65 |
66 | box := gtk.NewBox(gtk.OrientationVertical, 0)
67 | box.Append(title)
68 | box.Append(subrev)
69 | box.SetVAlign(gtk.AlignCenter)
70 |
71 | btn := gtk.NewButton()
72 | btn.SetChild(box)
73 | btn.ConnectClicked(parent.ScrollToPlaying)
74 | nowPlayingCSS(btn)
75 |
76 | np := &NowPlaying{
77 | Button: *btn,
78 | Container: box,
79 | Title: title,
80 | SubReveal: subrev,
81 | Subtitle: subtitle,
82 | }
83 |
84 | np.StopPlaying()
85 |
86 | return np
87 | }
88 |
89 | func (np *NowPlaying) StopPlaying() {
90 | np.Title.SetLabel("Not playing.")
91 | np.SubReveal.SetRevealChild(false)
92 | }
93 |
94 | func (np *NowPlaying) SetTrack(track *state.Track) {
95 | metadata := track.Metadata()
96 |
97 | np.Title.SetText(metadata.Title)
98 |
99 | var markup strings.Builder
100 | if metadata.Artist != "" {
101 | markup.WriteString(``)
102 | markup.WriteString(html.EscapeString(metadata.Artist))
103 | markup.WriteString("")
104 | }
105 |
106 | if metadata.Album != "" {
107 | markup.WriteByte(' ')
108 | markup.WriteString(``)
109 |
110 | if metadata.Artist != "" {
111 | markup.WriteString("- ")
112 | }
113 |
114 | markup.WriteString(html.EscapeString(metadata.Album))
115 | markup.WriteString("")
116 | }
117 |
118 | np.SubReveal.SetRevealChild(markup.Len() > 0)
119 | np.Subtitle.SetMarkup(markup.String())
120 | }
121 |
--------------------------------------------------------------------------------
/internal/ui/content/bar/volume.go:
--------------------------------------------------------------------------------
1 | package bar
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/diamondburned/aqours/internal/ui/content/bar/controls"
7 | "github.com/diamondburned/aqours/internal/ui/css"
8 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 | )
10 |
11 | type VisualizerStatus int8
12 |
13 | const (
14 | VisualizerDisabled VisualizerStatus = iota - 1
15 | VisualizerOnlyPlaying
16 | VisualizerAlwaysOn
17 | VisualizerMuted
18 | visualizerStatusLen
19 | )
20 |
21 | // Visualizer code is removed. Add it back once catnip is ported to GTK4 and
22 | // isn't garbage.
23 | const defaultVisStatus = VisualizerDisabled
24 |
25 | func (vs VisualizerStatus) IsPaused(paused bool) bool {
26 | switch vs {
27 | case VisualizerMuted, VisualizerDisabled:
28 | return true
29 | case VisualizerAlwaysOn:
30 | return false
31 | case VisualizerOnlyPlaying:
32 | fallthrough
33 | default:
34 | return paused
35 | }
36 | }
37 |
38 | func (vs VisualizerStatus) String() string {
39 | switch vs {
40 | case VisualizerDisabled:
41 | return "Disabled"
42 | case VisualizerMuted:
43 | return "Muted"
44 | case VisualizerOnlyPlaying:
45 | return "Only when Playing"
46 | case VisualizerAlwaysOn:
47 | return "Always On"
48 | default:
49 | return fmt.Sprintf("VisualizerStatus(%d)", vs)
50 | }
51 | }
52 |
53 | func (vs VisualizerStatus) cycle() VisualizerStatus {
54 | return (vs + 1) % visualizerStatusLen
55 | }
56 |
57 | func (vs VisualizerStatus) icon() string {
58 | switch vs {
59 | case VisualizerDisabled:
60 | return ""
61 | case VisualizerMuted:
62 | return "microphone-sensitivity-muted-symbolic"
63 | case VisualizerAlwaysOn:
64 | return "microphone-sensitivity-high-symbolic"
65 | case VisualizerOnlyPlaying:
66 | fallthrough
67 | default:
68 | return "microphone-sensitivity-medium-symbolic"
69 | }
70 | }
71 |
72 | type VolumeController interface {
73 | ParentController
74 | SetVisualize(visualize VisualizerStatus)
75 | }
76 |
77 | type Volume struct {
78 | gtk.Box
79 |
80 | VisIcon *gtk.Image
81 | Visualize *gtk.Button
82 |
83 | Icon *gtk.Image
84 | Mute *gtk.ToggleButton
85 | Slider *gtk.Scale
86 |
87 | volume float64
88 | muted bool
89 | visualize VisualizerStatus
90 | }
91 |
92 | var volumeSliderCSS = css.PrepareClass("volume-slider", `
93 | .volume-slider {
94 | margin: 0;
95 | padding-left: 2px;
96 | }
97 | `)
98 |
99 | var muteButtonCSS = css.PrepareClass("mute-button", ``)
100 |
101 | var rightButtonCSS = css.PrepareClass("right-button", `
102 | .right-button {
103 | margin: 0;
104 | color: @theme_fg_color;
105 | opacity: 0.5;
106 | box-shadow: none;
107 | background: none;
108 | }
109 | .right-button:hover {
110 | opacity: 1;
111 | }
112 | `)
113 |
114 | var volumeCSS = css.PrepareClass("volume", "")
115 |
116 | func NewVolume(parent VolumeController) *Volume {
117 | visIcon := gtk.NewImage()
118 |
119 | visualize := gtk.NewButton()
120 | visualize.SetChild(visIcon)
121 | rightButtonCSS(visualize)
122 |
123 | icon := gtk.NewImage()
124 |
125 | mute := gtk.NewToggleButton()
126 | mute.SetChild(icon)
127 |
128 | muteButtonCSS(mute)
129 | rightButtonCSS(mute)
130 |
131 | slider := gtk.NewScaleWithRange(gtk.OrientationHorizontal, 0, 100, 1)
132 | slider.SetSizeRequest(100, -1)
133 | slider.SetDrawValue(false)
134 | slider.SetHExpand(true)
135 |
136 | controls.CleanScaleCSS(slider)
137 | volumeSliderCSS(slider)
138 |
139 | box := gtk.NewBox(gtk.OrientationHorizontal, 0)
140 | if defaultVisStatus != VisualizerDisabled {
141 | box.Append(visualize)
142 | }
143 |
144 | box.Append(mute)
145 | box.Append(slider)
146 | box.SetVAlign(gtk.AlignCenter)
147 | box.SetHAlign(gtk.AlignEnd)
148 | box.SetHExpand(true)
149 |
150 | volume := &Volume{
151 | Box: *box,
152 | VisIcon: visIcon,
153 | Visualize: visualize,
154 | Icon: icon,
155 | Mute: mute,
156 | Slider: slider,
157 | volume: 100,
158 | muted: false,
159 | visualize: defaultVisStatus,
160 | }
161 | volumeCSS(volume)
162 |
163 | mute.SetActive(volume.muted)
164 | slider.SetValue(volume.volume)
165 | volume.updateIcon()
166 |
167 | visualize.Connect("clicked", func() {
168 | volume.visualize = volume.visualize.cycle()
169 | volume.updateIcon()
170 | parent.SetVisualize(volume.visualize)
171 | })
172 |
173 | mute.Connect("toggled", func() {
174 | volume.muted = mute.Active()
175 | volume.updateIcon()
176 | slider.SetSensitive(!volume.muted) // no sense to change volume while muted
177 | parent.SetMute(volume.muted)
178 | })
179 |
180 | slider.Connect("value-changed", func() {
181 | volume.volume = clampVolume(slider.Value())
182 | volume.updateIcon()
183 | parent.SetVolume(volume.volume)
184 | })
185 |
186 | return volume
187 | }
188 |
189 | // SetVolume sets the volume and triggers the callback to parent.
190 | func (v *Volume) SetVolume(perc float64) {
191 | v.Slider.SetValue(perc)
192 | }
193 |
194 | // GetVolume returns the volume.
195 | func (v *Volume) GetVolume() float64 {
196 | return v.volume
197 | }
198 |
199 | // IsMuted returns true if the volume is muted.
200 | func (v *Volume) IsMuted() bool {
201 | return v.muted
202 | }
203 |
204 | // VisualizerStatus returns the internal visualizer status.
205 | func (v *Volume) VisualizerStatus() VisualizerStatus {
206 | return v.visualize
207 | }
208 |
209 | func (v *Volume) updateIcon() {
210 | v.updateVisualizeIcon()
211 | v.updateVolumeIcon()
212 | }
213 |
214 | func (v *Volume) updateVisualizeIcon() {
215 | v.VisIcon.SetFromIconName(v.visualize.icon())
216 | v.Visualize.SetTooltipText(v.visualize.String())
217 | }
218 |
219 | func (v *Volume) updateVolumeIcon() {
220 | var icon string
221 |
222 | switch {
223 | case v.volume < 1 || v.muted:
224 | icon = "audio-volume-muted-symbolic"
225 | case v.volume < 30:
226 | icon = "audio-volume-low-symbolic"
227 | case v.volume < 80:
228 | icon = "audio-volume-medium-symbolic"
229 | default:
230 | icon = "audio-volume-high-symbolic"
231 | }
232 |
233 | v.Icon.SetFromIconName(icon)
234 | }
235 |
236 | func clampVolume(perc float64) float64 {
237 | switch {
238 | case perc < 0:
239 | return 0
240 | case perc > 100:
241 | return 100
242 | default:
243 | return perc
244 | }
245 | }
246 |
--------------------------------------------------------------------------------
/internal/ui/content/body/body.go:
--------------------------------------------------------------------------------
1 | package body
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/diamondburned/aqours/internal/ui/content/body/sidebar"
7 | "github.com/diamondburned/aqours/internal/ui/content/body/tracks"
8 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 | )
10 |
11 | type ParentController interface {
12 | tracks.ParentController
13 | sidebar.ParentController
14 | }
15 |
16 | type Container struct {
17 | *gtk.Box
18 | ParentController
19 |
20 | Sidebar *sidebar.Container
21 |
22 | RightStack *gtk.Stack
23 | TracksView *tracks.Container
24 | }
25 |
26 | func NewContainer(parent ParentController) *Container {
27 | c := &Container{ParentController: parent}
28 |
29 | c.Sidebar = sidebar.NewContainer(c)
30 | c.Sidebar.SetHExpand(false)
31 |
32 | sideSeparator := gtk.NewSeparator(gtk.OrientationVertical)
33 |
34 | c.TracksView = tracks.NewContainer(c)
35 | c.TracksView.SetHExpand(true)
36 |
37 | idleIcon := gtk.NewImageFromIconName("folder-music-symbolic")
38 |
39 | idleBox := gtk.NewBox(gtk.OrientationHorizontal, 0)
40 | idleBox.Append(idleIcon)
41 | idleBox.SetVAlign(gtk.AlignCenter)
42 | idleBox.SetHAlign(gtk.AlignCenter)
43 |
44 | c.RightStack = gtk.NewStack()
45 | c.RightStack.AddNamed(idleBox, "idle")
46 | c.RightStack.AddNamed(c.TracksView, "tracks")
47 |
48 | c.Box = gtk.NewBox(gtk.OrientationHorizontal, 0)
49 | c.Box.Append(c.Sidebar)
50 | c.Box.Append(sideSeparator)
51 | c.Box.Append(c.RightStack)
52 |
53 | return c
54 | }
55 |
56 | func (c *Container) SwipeBack() {
57 | log.Println("TODO: SwipeBack(): REMOVE ME")
58 | // c.Leaflet.SetVisibleChild(c.Sidebar)
59 | }
60 |
61 | func (c *Container) SelectPlaylist(path string) {
62 | if path == "" {
63 | c.RightStack.SetVisibleChildName("idle")
64 | } else {
65 | c.RightStack.SetVisibleChildName("tracks")
66 | }
67 |
68 | c.ParentController.SelectPlaylist(path)
69 | }
70 |
--------------------------------------------------------------------------------
/internal/ui/content/body/sidebar/albumart.go:
--------------------------------------------------------------------------------
1 | package sidebar
2 |
3 | import (
4 | "context"
5 | "io"
6 | "log"
7 | "time"
8 |
9 | "github.com/diamondburned/aqours/internal/muse/albumart"
10 | "github.com/diamondburned/aqours/internal/state"
11 | "github.com/diamondburned/aqours/internal/ui/css"
12 | "github.com/diamondburned/gotk4/pkg/core/gioutil"
13 | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
14 | "github.com/diamondburned/gotk4/pkg/glib/v2"
15 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
16 | )
17 |
18 | var albumArtCSS = css.PrepareClass("album-art", "")
19 |
20 | type AlbumArt struct {
21 | *gtk.Revealer
22 | Path string
23 | Image *gtk.Image
24 |
25 | stopLoading context.CancelFunc
26 | }
27 |
28 | const AlbumArtSize = 192
29 |
30 | func NewAlbumArt() *AlbumArt {
31 | img := gtk.NewImage()
32 | img.SetSizeRequest(AlbumArtSize, AlbumArtSize)
33 | img.SetIconSize(gtk.IconSizeLarge)
34 | img.SetVAlign(gtk.AlignCenter)
35 | img.SetHAlign(gtk.AlignCenter)
36 |
37 | albumArtCSS(img)
38 |
39 | rev := gtk.NewRevealer()
40 | rev.SetRevealChild(true)
41 | rev.SetTransitionType(gtk.RevealerTransitionTypeSlideUp)
42 | rev.SetChild(img)
43 |
44 | aa := &AlbumArt{
45 | Revealer: rev,
46 | Image: img,
47 | stopLoading: func() {}, // stub
48 | }
49 |
50 | aa.SetTrack(nil)
51 |
52 | return aa
53 | }
54 |
55 | func (aa *AlbumArt) SetTrack(track *state.Track) {
56 | aa.Image.SetFromIconName("media-optical-symbolic")
57 |
58 | if track == nil {
59 | return
60 | }
61 |
62 | aa.stopLoading()
63 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
64 | aa.stopLoading = cancel
65 |
66 | aa.Path = track.Filepath
67 | scale := aa.Image.ScaleFactor()
68 |
69 | go func() {
70 | defer cancel()
71 |
72 | pixbuf := FetchAlbumArtScaled(ctx, track, AlbumArtSize, scale)
73 | if pixbuf == nil {
74 | return
75 | }
76 |
77 | glib.IdleAdd(func() {
78 | // Make sure that the album art is still displaying the same file.
79 | if aa.Path == track.Filepath {
80 | aa.Image.SetFromPixbuf(pixbuf)
81 | }
82 | })
83 | }()
84 | }
85 |
86 | // FetchAlbumArtScaled fetches the track's album art into a scaled Cairo Surface
87 | // for HiDPI.
88 | func FetchAlbumArtScaled(ctx context.Context, track *state.Track, size, scale int) *gdkpixbuf.Pixbuf {
89 | return FetchAlbumArt(ctx, track, size*scale)
90 | }
91 |
92 | // FetchAlbumArt fetches the track's album art into a pixbuf with the given
93 | // size.
94 | func FetchAlbumArt(ctx context.Context, track *state.Track, size int) *gdkpixbuf.Pixbuf {
95 | f := albumart.AlbumArt(ctx, track.Filepath)
96 | if f == nil {
97 | return nil
98 | }
99 | defer f.Close()
100 |
101 | l, err := gdkpixbuf.NewPixbufLoaderWithType(f.Extension)
102 | if err != nil {
103 | log.Printf("PixbufLoaderNewWithType failed with %q: %v\n", f.Extension, err)
104 | return nil
105 | }
106 | defer l.Close()
107 |
108 | l.ConnectSizePrepared(func(w, h int) {
109 | l.SetSize(MaxSize(w, h, size, size))
110 | })
111 |
112 | // Trivial error that we can't handle.
113 | if _, err := io.Copy(gioutil.PixbufLoaderWriter(l), f.ReadCloser); err != nil {
114 | log.Println("PixbufLoader.Write:", err)
115 | return nil
116 | }
117 |
118 | if err := l.Close(); err != nil {
119 | log.Println("PixbufLoader.Close:", err)
120 | return nil
121 | }
122 |
123 | return l.Pixbuf()
124 | }
125 |
126 | // MaxSize returns the maximum size that can fit within the given max width and
127 | // height. Aspect ratio is preserved.
128 | func MaxSize(w, h, maxW, maxH int) (int, int) {
129 | if w < maxW && h < maxH {
130 | return w, h
131 | }
132 |
133 | if w > h {
134 | h = h * maxW / w
135 | w = maxW
136 | } else {
137 | w = w * maxH / h
138 | h = maxH
139 | }
140 |
141 | return w, h
142 | }
143 |
--------------------------------------------------------------------------------
/internal/ui/content/body/sidebar/playlists.go:
--------------------------------------------------------------------------------
1 | package sidebar
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/diamondburned/aqours/internal/state"
7 | "github.com/diamondburned/aqours/internal/ui/css"
8 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 | "github.com/diamondburned/gotk4/pkg/pango"
10 | )
11 |
12 | var playlistsCSS = css.PrepareClass("playlists", `
13 | list.playlists {
14 | background: @theme_bg_color;
15 | }
16 | `)
17 |
18 | type PlaylistList struct {
19 | gtk.ListBox
20 | parent ParentController
21 |
22 | Playlists []*Playlist
23 | }
24 |
25 | func NewPlaylistList(parent ParentController) *PlaylistList {
26 | list := &PlaylistList{parent: parent}
27 |
28 | lbox := gtk.NewListBox()
29 | lbox.SetSelectionMode(gtk.SelectionBrowse)
30 | lbox.SetActivateOnSingleClick(true)
31 | lbox.Connect("row-activated", func(_ *gtk.ListBox, r *gtk.ListBoxRow) {
32 | parent.SelectPlaylist(list.Playlists[r.Index()].Name)
33 | })
34 | playlistsCSS(lbox)
35 |
36 | list.ListBox = *lbox
37 |
38 | return list
39 | }
40 |
41 | func (l *PlaylistList) AddPlaylist(pl *state.Playlist) *Playlist {
42 | for _, playlist := range l.Playlists {
43 | if playlist.Name == pl.Name {
44 | return playlist
45 | }
46 | }
47 |
48 | playlist := NewPlaylist(pl.Name, len(pl.Tracks))
49 |
50 | l.ListBox.Append(playlist)
51 | l.Playlists = append(l.Playlists, playlist)
52 |
53 | return playlist
54 | }
55 |
56 | // SelectFirstPlaylist selects the first playlist. It does nothing if there are
57 | // no playlists.
58 | func (l *PlaylistList) SelectFirstPlaylist() *Playlist {
59 | if len(l.Playlists) > 0 {
60 | l.SelectPlaylist(l.Playlists[0])
61 | return l.Playlists[0]
62 | }
63 | return nil
64 | }
65 |
66 | // SelectPlaylist selects the given playlist.
67 | func (l *PlaylistList) SelectPlaylist(pl *Playlist) {
68 | l.SelectRow(pl.ListBoxRow)
69 | pl.Activate()
70 | l.parent.SelectPlaylist(pl.Name)
71 | }
72 |
73 | func (l *PlaylistList) SetUnsaved(pl *state.Playlist) {
74 | if p := l.Playlist(pl.Name); p != nil {
75 | p.SetUnsaved(pl.IsUnsaved())
76 | }
77 | }
78 |
79 | func (l *PlaylistList) Playlist(name string) *Playlist {
80 | for _, playlist := range l.Playlists {
81 | if playlist.Name == name {
82 | return playlist
83 | }
84 | }
85 | return nil
86 | }
87 |
88 | type Playlist struct {
89 | *gtk.ListBoxRow
90 | name *gtk.Label
91 | total *gtk.Label
92 |
93 | Name string
94 | Total int
95 | }
96 |
97 | var playlistEntryCSS = css.PrepareClass("playlist-entry", `
98 | .playlist-entry > box {
99 | margin: 6px 8px;
100 | }
101 | .playlist-entry > box > label:first-child {
102 | font-size: 1.1em;
103 | }
104 | .playlist-entry > box > label:last-child {
105 | font-size: 0.9em;
106 | color: alpha(@theme_fg_color, 0.75);
107 | }
108 | `)
109 |
110 | func NewPlaylist(name string, total int) *Playlist {
111 | pl := Playlist{}
112 | pl.name = gtk.NewLabel("")
113 | pl.name.SetXAlign(0)
114 | pl.name.SetEllipsize(pango.EllipsizeEnd)
115 |
116 | pl.total = gtk.NewLabel("")
117 | pl.total.SetXAlign(0)
118 |
119 | box := gtk.NewBox(gtk.OrientationVertical, 0)
120 | box.Append(pl.name)
121 | box.Append(pl.total)
122 |
123 | pl.ListBoxRow = gtk.NewListBoxRow()
124 | pl.ListBoxRow.SetChild(box)
125 | playlistEntryCSS(pl)
126 |
127 | pl.SetName(name)
128 | pl.SetTotal(total)
129 |
130 | return &pl
131 | }
132 |
133 | func (pl *Playlist) SetUnsaved(unsaved bool) {
134 | if !unsaved {
135 | pl.name.SetLabel(pl.Name)
136 | } else {
137 | pl.name.SetLabel(pl.Name + " ●")
138 | }
139 | }
140 |
141 | func (pl *Playlist) SetName(name string) {
142 | pl.name.SetLabel(name)
143 | pl.Name = name
144 | }
145 |
146 | func (pl *Playlist) SetTotal(total int) {
147 | pl.total.SetLabel(fmt.Sprintf("%d songs", total))
148 | pl.Total = total
149 | }
150 |
--------------------------------------------------------------------------------
/internal/ui/content/body/sidebar/sidebar.go:
--------------------------------------------------------------------------------
1 | package sidebar
2 |
3 | import "github.com/diamondburned/gotk4/pkg/gtk/v4"
4 |
5 | type ParentController interface {
6 | SelectPlaylist(name string)
7 | }
8 |
9 | type Container struct {
10 | gtk.Box
11 |
12 | ListScroll *gtk.ScrolledWindow
13 | PlaylistList *PlaylistList
14 |
15 | AlbumArt *AlbumArt
16 | }
17 |
18 | func NewContainer(parent ParentController) *Container {
19 | list := NewPlaylistList(parent)
20 |
21 | scroll := gtk.NewScrolledWindow()
22 | scroll.SetVExpand(true)
23 | scroll.SetPolicy(gtk.PolicyAutomatic, gtk.PolicyAutomatic)
24 | scroll.SetChild(list)
25 |
26 | separator := gtk.NewSeparator(gtk.OrientationVertical)
27 |
28 | aart := NewAlbumArt()
29 |
30 | box := gtk.NewBox(gtk.OrientationVertical, 0)
31 | box.Append(scroll)
32 | box.Append(separator)
33 | box.Append(aart)
34 |
35 | return &Container{
36 | Box: *box,
37 | ListScroll: scroll,
38 | PlaylistList: list,
39 | AlbumArt: aart,
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/internal/ui/content/body/tracks/sorter.go:
--------------------------------------------------------------------------------
1 | package tracks
2 |
3 | import (
4 | "sort"
5 |
6 | "github.com/diamondburned/aqours/internal/state"
7 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 | )
9 |
10 | // noopSort does nothing.
11 | type noopSort struct{}
12 |
13 | func (noopSort) Len() int { return 0 }
14 | func (noopSort) Less(i, j int) bool { return false }
15 | func (noopSort) Swap(i, j int) {}
16 |
17 | type trackSorter struct {
18 | store *gtk.ListStore
19 | rows map[*state.Track]*TrackRow
20 | tracks []*state.Track
21 | iters []*gtk.TreeIter
22 | }
23 |
24 | type trackMetadata struct {
25 | number int
26 | album string
27 | }
28 |
29 | func newTrackSorter(list *TrackList, start, end int) sort.Interface {
30 | tracks := list.Playlist.Tracks[start:end]
31 | iters := make([]*gtk.TreeIter, len(tracks))
32 |
33 | for i, track := range tracks {
34 | it, ok := list.TrackRows[track].Iter()
35 | if !ok {
36 | return noopSort{}
37 | }
38 | iters[i] = it
39 | }
40 |
41 | return trackSorter{
42 | store: list.Store,
43 | rows: list.TrackRows,
44 | tracks: tracks,
45 | iters: iters,
46 | }
47 | }
48 |
49 | func (sorter trackSorter) Len() int {
50 | return len(sorter.tracks)
51 | }
52 |
53 | func (sorter trackSorter) Less(i, j int) bool {
54 | ixA, albumA := sorter.metadata(i)
55 | ixB, albumB := sorter.metadata(j)
56 |
57 | return (albumA == albumB) && (ixA < ixB)
58 | }
59 |
60 | func (sorter trackSorter) Swap(i, j int) {
61 | sorter.store.Swap(sorter.iters[i], sorter.iters[j])
62 | sorter.tracks[i], sorter.tracks[j] = sorter.tracks[j], sorter.tracks[i]
63 | }
64 |
65 | func (sorter trackSorter) metadata(i int) (trackN int, album string) {
66 | metadata := sorter.tracks[i].Metadata()
67 | return metadata.Number, metadata.Album
68 | }
69 |
--------------------------------------------------------------------------------
/internal/ui/content/body/tracks/tracklist.go:
--------------------------------------------------------------------------------
1 | package tracks
2 |
3 | import (
4 | "io/fs"
5 | "log"
6 | "net/url"
7 | "os"
8 | "path/filepath"
9 | "sort"
10 | "strings"
11 |
12 | "github.com/diamondburned/aqours/internal/gtkutil"
13 | "github.com/diamondburned/aqours/internal/state"
14 | "github.com/diamondburned/aqours/internal/state/prober"
15 | "github.com/diamondburned/gotk4/pkg/gdk/v4"
16 | "github.com/diamondburned/gotk4/pkg/gio/v2"
17 | "github.com/diamondburned/gotk4/pkg/glib/v2"
18 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
19 | "github.com/lithammer/fuzzysearch/fuzzy"
20 | "github.com/pkg/errors"
21 | // coreglib "github.com/diamondburned/gotk4/pkg/glib/v2"
22 | )
23 |
24 | type TrackPath = string
25 |
26 | type TrackList struct {
27 | gtk.ScrolledWindow
28 | parent ParentController
29 |
30 | Tree *gtk.TreeView
31 | Store *gtk.ListStore
32 | Select *gtk.TreeSelection
33 |
34 | Playlist *state.Playlist
35 | TrackRows map[*state.Track]*TrackRow
36 |
37 | playing *state.Track
38 |
39 | menu *gtk.PopoverMenu
40 | }
41 |
42 | // searchFuzzy implements TreeViewSearchEqualFunc.
43 | func searchFuzzy(m gtk.TreeModeller, col int, k string, it *gtk.TreeIter) bool {
44 | data := m.Value(it, col)
45 | return !fuzzy.MatchNormalizedFold(k, data.String())
46 | }
47 |
48 | type columnType = int
49 |
50 | const (
51 | columnTitle columnType = iota
52 | columnArtist
53 | columnAlbum
54 | columnTime
55 | columnSelected
56 | columnSearchData
57 | )
58 |
59 | const maxDataSize = 10 * 1024 * 1024 // 10MB
60 |
61 | func NewTrackList(parent ParentController, pl *state.Playlist) *TrackList {
62 | store := gtk.NewListStore([]glib.Type{
63 | glib.TypeString, // columnTitle
64 | glib.TypeString, // columnArtist
65 | glib.TypeString, // columnAlbum
66 | glib.TypeString, // columnTime
67 | glib.TypeInt, // columnSelected - pango.Weight
68 | glib.TypeString, // columnSearchData
69 | })
70 |
71 | tree := gtk.NewTreeViewWithModel(store)
72 | tree.SetActivateOnSingleClick(false)
73 | tree.SetHasTooltip(true)
74 | tree.AppendColumn(newColumn("Title", columnTitle))
75 | tree.AppendColumn(newColumn("Artist", columnArtist))
76 | tree.AppendColumn(newColumn("Album", columnAlbum))
77 | tree.AppendColumn(newColumn("", columnTime))
78 | tree.AppendColumn(newColumn("", columnSelected))
79 | tree.AppendColumn(newColumn("", columnSearchData))
80 | tree.SetSearchColumn(columnSearchData)
81 | tree.SetSearchEqualFunc(searchFuzzy)
82 |
83 | s := tree.Selection()
84 | s.SetMode(gtk.SelectionMultiple)
85 |
86 | scroll := gtk.NewScrolledWindow()
87 | scroll.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
88 | scroll.SetVExpand(true)
89 | scroll.SetChild(tree)
90 |
91 | list := TrackList{
92 | ScrolledWindow: *scroll,
93 | parent: parent,
94 |
95 | Tree: tree,
96 | Store: store,
97 | Select: s,
98 |
99 | Playlist: pl,
100 | TrackRows: make(map[*state.Track]*TrackRow, len(pl.Tracks)),
101 | }
102 |
103 | // Create a nil slice and leave the allocation for later.
104 | var probeQueue []prober.Job
105 |
106 | for _, track := range pl.Tracks {
107 | iter := list.Store.Append()
108 | path := list.Store.Path(iter)
109 |
110 | row := &TrackRow{Bold: false}
111 | row.iter.path = path
112 | row.iter.store = list.Store
113 |
114 | row.setListStore(track)
115 | list.TrackRows[track] = row
116 |
117 | if !track.Metadata().IsProbed() {
118 | // Allocate a slice only if we have at least 1.
119 | if probeQueue == nil {
120 | probeQueue = make([]prober.Job, 0, len(pl.Tracks))
121 | }
122 |
123 | track := track // copy pointer
124 |
125 | job := prober.NewJob(track, func() {
126 | row.setListStore(track)
127 | pl.SetUnsaved()
128 | })
129 |
130 | probeQueue = append(probeQueue, job)
131 | }
132 | }
133 |
134 | // TODO: this has a cache stampede problem. We need to have a context to
135 | // cancel this.
136 | prober.Queue(probeQueue...)
137 |
138 | tree.ConnectRowActivated(func(path *gtk.TreePath, _ *gtk.TreeViewColumn) {
139 | parent.PlayTrack(pl, path.Indices()[0])
140 | })
141 |
142 | // TODO: Implement TreeView drag-and-drop for reordering. Effectively, the
143 | // application should use the standardized file URI as the data for
144 | // reordering, which should make it work with a range of applications.
145 | //
146 | // To deal with the lack of data when we move the tracks out and in the
147 | // list, we could have a remove track and add path functions. The add
148 | // function would simply treat the track as an unprocessed one.
149 |
150 | // TODO: Add this back once we can reliably create bindings for GTK v4.5.0.
151 |
152 | // drop := gtk.NewDropTarget(glib.TypeInvalid, gdk.ActionLink)
153 | // drop.SetGTypes([]glib.Type{
154 | // gio.GTypeFile,
155 | // // gdk.GTypeF
156 | // // glib.TypeFromName("GFile"),
157 | // // glib.TypeFromName("Gdk.FileList"),
158 | // })
159 | // drop.ConnectDrop(func(value glib.Value, x, y float64) bool {
160 | // switch v := value.GoValue().(type) {
161 | // case gio.Filer:
162 | // log.Println("got path", v.Path())
163 | // default:
164 | // log.Printf("dropped unknown value of type %T", v)
165 | // }
166 | // return true
167 | // })
168 | // tree.AddController(drop)
169 |
170 | // Bind the Delete key and such.
171 | tree.AddController(list.keyEventController())
172 |
173 | menu := gtkutil.MenuPair([][2]string{
174 | {"Add _Tracks...", "tracklist.add-files"},
175 | {"Add _Folders...", "tracklist.add-folders"},
176 | {"Refresh _Metadata", "tracklist.refresh"},
177 | {"_Sort", "tracklist.sort"},
178 | {"Remove", "tracklist.remove"},
179 | })
180 |
181 | // Hacks.
182 | var menuX, menuY float64
183 | gtkutil.BindRightClick(tree, func(x, y float64) {
184 | menuX, menuY = x, y
185 |
186 | // This is supposed to be relative to tree's coords, but that happens to
187 | // match with scroll's.
188 | p := gtkutil.NewPopoverMenuAt(scroll, gtk.PosBottom, x, y, menu)
189 | p.Popup()
190 | })
191 |
192 | gtkutil.BindActionMap(scroll, map[string]func(){
193 | "tracklist.refresh": list.refreshSelected,
194 | "tracklist.sort": list.SortSelected,
195 | "tracklist.remove": list.removeSelected,
196 | "tracklist.add-files": func() {
197 | list.promptAddTracks(menuX, menuY, gtk.FileChooserActionOpen)
198 | },
199 | "tracklist.add-folders": func() {
200 | list.promptAddTracks(menuX, menuY, gtk.FileChooserActionSelectFolder)
201 | },
202 | })
203 |
204 | trackTooltip := newTrackTooltipBox()
205 |
206 | tree.ConnectQueryTooltip(func(x, y int, kb bool, t *gtk.Tooltip) bool {
207 | var path *gtk.TreePath
208 | if !kb {
209 | path, _, _ = tree.DestRowAtPos(x, y)
210 | } else {
211 | _, iter, _ := list.Select.Selected()
212 | if iter != nil {
213 | path = list.Store.Path(iter)
214 | }
215 | }
216 |
217 | if path == nil {
218 | return false
219 | }
220 |
221 | ix := path.Indices()[0]
222 | trackTooltip.Attach(t, list.Playlist.Tracks[ix])
223 |
224 | return true
225 | })
226 |
227 | return &list
228 | }
229 |
230 | func parseURIList(list string) []string {
231 | // Get the files in form of line-delimited URIs
232 | var uris = strings.Fields(list)
233 |
234 | // Create a path slice that we decode URIs into.
235 | var paths = make([]string, 0, len(uris))
236 |
237 | // Decode the URIs.
238 | for _, uri := range uris {
239 | u, err := url.Parse(uri)
240 | if err != nil {
241 | log.Printf("Failed parsing URI %q: %v\n", uri, err)
242 | continue
243 | }
244 | if u.Scheme != "file" && u.Scheme != "" {
245 | log.Printf("Unknown file URI scheme (only locals): %q\n", uri)
246 | continue
247 | }
248 |
249 | if err := readDirOrFile(u.Path, &paths); err != nil {
250 | log.Printf("Failed to read %q: %v\n", u.Path, err)
251 | }
252 | }
253 |
254 | return paths
255 | }
256 |
257 | func readDirOrFile(path string, dest *[]string) error {
258 | f, err := os.Open(path)
259 | if err != nil {
260 | return errors.Wrap(err, "failed to open file")
261 | }
262 | defer f.Close()
263 |
264 | s, err := f.Stat()
265 | if err != nil {
266 | return errors.Wrap(err, "file stat failed while drag-and-drop")
267 | }
268 |
269 | if !s.IsDir() {
270 | *dest = append(*dest, path)
271 | return nil
272 | }
273 |
274 | files, err := f.Readdirnames(0)
275 | if err != nil {
276 | return errors.Wrap(err, "failed to read dir names")
277 | }
278 |
279 | for _, file := range files {
280 | *dest = append(*dest, filepath.Join(path, file))
281 | }
282 |
283 | return nil
284 | }
285 |
286 | func (list *TrackList) keyEventController() *gtk.EventControllerKey {
287 | key := gtk.NewEventControllerKey()
288 | key.ConnectKeyPressed(func(keyVal, _ uint, keyMod gdk.ModifierType) bool {
289 | switch keyVal {
290 | case gdk.KEY_Delete:
291 | list.removeSelected()
292 | return true
293 | }
294 |
295 | if modIsPressed(keyMod, gdk.ControlMask) {
296 | switch keyVal {
297 | case gdk.KEY_S: // Ctrl+S
298 | list.parent.SavePlaylist(list.Playlist)
299 | return true
300 | }
301 | }
302 |
303 | return false
304 | })
305 |
306 | return key
307 | }
308 |
309 | func modIsPressed(mod, press gdk.ModifierType) bool {
310 | return mod&press == press
311 | }
312 |
313 | func (list *TrackList) promptAddTracks(x, y float64, action gtk.FileChooserAction) {
314 | path, pos, ok := list.Tree.DestRowAtPos(int(x), int(y))
315 | if !ok {
316 | log.Printf("no path found at dragged pos (%.0f, %.0f)", x, y)
317 | return
318 | }
319 |
320 | before := false ||
321 | pos == gtk.TreeViewDropBefore ||
322 | pos == gtk.TreeViewDropIntoOrBefore
323 |
324 | var title string
325 | var isDir bool
326 |
327 | switch action {
328 | case gtk.FileChooserActionOpen:
329 | title = "Add Files"
330 | isDir = false
331 | case gtk.FileChooserActionSelectFolder:
332 | title = "Add Folders"
333 | isDir = true
334 | }
335 |
336 | chooser := gtk.NewFileChooserNative(title, gtkutil.ActiveWindow(), action, "Add", "Cancel")
337 | chooser.SetSelectMultiple(true)
338 | chooser.ConnectResponse(func(resp int) {
339 | if resp != int(gtk.ResponseAccept) {
340 | return
341 | }
342 |
343 | fList := chooser.Files()
344 | fileN := fList.NItems()
345 | paths := make([]string, 0, fileN)
346 |
347 | for i := uint(0); i < fileN; i++ {
348 | item := fList.Item(i)
349 | file := item.Cast().(gio.Filer)
350 |
351 | if path := file.Path(); path != "" {
352 | paths = append(paths, file.Path())
353 | }
354 | }
355 |
356 | list.addTracksAt(path, before, paths, isDir)
357 | })
358 | chooser.Show()
359 | }
360 |
361 | func (list *TrackList) addTracksAt(path *gtk.TreePath, before bool, paths []string, isDir bool) {
362 | addPaths := func() {
363 | ix := path.Indices()[0]
364 | start, end := list.Playlist.Add(ix, before, paths...)
365 | probeQueue := make([]prober.Job, 0, end-start)
366 |
367 | for i := start; i < end; i++ {
368 | iter := list.Store.Insert(i)
369 | path := list.Store.Path(iter)
370 |
371 | row := &TrackRow{Bold: false}
372 | row.iter.path = path
373 | row.iter.store = list.Store
374 |
375 | track := list.Playlist.Tracks[i]
376 |
377 | row.setListStore(track)
378 | list.TrackRows[track] = row
379 |
380 | job := prober.NewJob(track, func() {
381 | row.setListStore(track)
382 | })
383 |
384 | probeQueue = append(probeQueue, job)
385 | }
386 |
387 | list.parent.UpdateTracks(list.Playlist)
388 | prober.Queue(probeQueue...)
389 | }
390 |
391 | if !isDir {
392 | addPaths()
393 | return
394 | }
395 |
396 | go func() {
397 | walkedPaths := make([]string, 0, len(paths))
398 |
399 | for _, path := range paths {
400 | s, err := os.Stat(path)
401 | if err != nil {
402 | log.Println("cannot stat adding path:", err)
403 | continue
404 | }
405 |
406 | if !s.IsDir() {
407 | walkedPaths = append(walkedPaths, path)
408 | continue
409 | }
410 |
411 | err = fs.WalkDir(
412 | os.DirFS("/"), strings.TrimPrefix(path, "/"), // fs to os
413 | func(path string, s fs.DirEntry, err error) error {
414 | if err != nil {
415 | return err
416 | }
417 | if !s.IsDir() {
418 | walkedPaths = append(walkedPaths, "/"+path)
419 | }
420 | return nil
421 | },
422 | )
423 | if err != nil {
424 | log.Println("cannot walk adding path:", err)
425 | continue
426 | }
427 | }
428 |
429 | paths = walkedPaths
430 | glib.IdleAdd(addPaths)
431 | }()
432 | }
433 |
434 | func (list *TrackList) removeSelected() {
435 | selectIxs := selectedIxs(list.Select)
436 | if len(selectIxs) == 0 {
437 | return
438 | }
439 |
440 | // Sort the selected indexes in reverse to remove them in that order.
441 | sort.Sort(sort.Reverse(sort.IntSlice(selectIxs)))
442 |
443 | for _, ix := range selectIxs {
444 | track := list.Playlist.Tracks[ix]
445 | trRow := list.TrackRows[track]
446 |
447 | delete(list.TrackRows, track)
448 | if !trRow.Remove() {
449 | log.Panicln("cannot remove track", ix)
450 | }
451 | }
452 |
453 | list.Playlist.Remove(selectIxs...)
454 | list.parent.UpdateTracks(list.Playlist)
455 | }
456 |
457 | // SortSelected sorts the selected tracks.
458 | func (list *TrackList) SortSelected() {
459 | _, selectedRows := list.Select.SelectedRows()
460 | selectMin := -1
461 | selectMax := -1
462 |
463 | for _, selected := range selectedRows {
464 | ix := selected.Indices()[0]
465 |
466 | // Get max and min bounds without allocating a slice.
467 | if selectMin == -1 || ix < selectMin {
468 | selectMin = ix
469 | }
470 | if selectMax == -1 || ix > selectMax {
471 | selectMax = ix
472 | }
473 | }
474 |
475 | list.sortTracksBounds(selectMin, selectMax)
476 | }
477 |
478 | func (list *TrackList) sortTracksBounds(start, end int) {
479 | // Exit if we have nothing.
480 | if start == end {
481 | return
482 | }
483 |
484 | sorter := newTrackSorter(list, start, end)
485 | sort.Stable(sorter)
486 | list.parent.UpdateTracks(list.Playlist)
487 | }
488 |
489 | func (list *TrackList) refreshSelected() {
490 | selectIx := selectedIxs(list.Select)
491 | if len(selectIx) == 0 {
492 | return
493 | }
494 |
495 | probeQueue := make([]prober.Job, len(selectIx))
496 |
497 | for i, ix := range selectIx {
498 | track := list.Playlist.Tracks[ix]
499 |
500 | j := prober.NewJob(track, func() {
501 | row := list.TrackRows[track]
502 | row.setListStore(track)
503 | })
504 | j.Force = true
505 |
506 | probeQueue[i] = j
507 | }
508 |
509 | prober.Queue(probeQueue...)
510 | }
511 |
512 | func (list *TrackList) SelectPlaying() {
513 | rw, ok := list.TrackRows[list.playing]
514 | if !ok {
515 | return
516 | }
517 |
518 | list.Select.UnselectAll()
519 | list.Select.SelectPath(rw.Path())
520 | list.Tree.ScrollToCell(rw.Path(), nil, true, 0.5, 0.0)
521 | }
522 |
523 | // SetPlaying unbolds the last track (if any) and bolds the given track. It does
524 | // not trigger any callback.
525 | func (list *TrackList) SetPlaying(playing *state.Track) {
526 | rw, ok := list.TrackRows[playing]
527 | if !ok {
528 | log.Printf("Track not found on (*Tracklist).SetPlaying: %q\n", playing.Filepath)
529 | return
530 | }
531 |
532 | // If we have nothing playing, then we should reselect. I'm not sure of this
533 | // behavior.
534 | reselect := list.playing == nil
535 |
536 | if playingRow, ok := list.TrackRows[list.playing]; ok {
537 | playingRow.SetBold(list.Store, false)
538 |
539 | // Decide if we should move the selection.
540 | selectedRows := list.Select.CountSelectedRows()
541 | reselect = selectedRows == 1 && list.Select.PathIsSelected(playingRow.Path())
542 |
543 | if reselect {
544 | list.Select.UnselectPath(playingRow.Path())
545 | }
546 | }
547 |
548 | if reselect {
549 | list.Select.SelectPath(rw.Path())
550 | list.Tree.ScrollToCell(rw.Path(), nil, false, 0, 0)
551 | }
552 |
553 | list.playing = playing
554 | rw.SetBold(list.Store, true)
555 | }
556 |
557 | func selectedIxs(sel *gtk.TreeSelection) []int {
558 | _, selectedRows := sel.SelectedRows()
559 | if len(selectedRows) == 0 {
560 | return nil
561 | }
562 |
563 | selectIxs := make([]int, len(selectedRows))
564 | for i, selected := range selectedRows {
565 | selectIxs[i] = selected.Indices()[0]
566 | }
567 |
568 | return selectIxs
569 | }
570 |
--------------------------------------------------------------------------------
/internal/ui/content/body/tracks/tracks.go:
--------------------------------------------------------------------------------
1 | package tracks
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "html"
7 | "io"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/diamondburned/aqours/internal/durafmt"
12 | "github.com/diamondburned/aqours/internal/state"
13 | "github.com/diamondburned/aqours/internal/ui/css"
14 | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2"
15 | "github.com/diamondburned/gotk4/pkg/glib/v2"
16 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
17 | "github.com/diamondburned/gotk4/pkg/pango"
18 | )
19 |
20 | type ParentController interface {
21 | PlayTrack(p *state.Playlist, index int)
22 | SavePlaylist(p *state.Playlist)
23 | UpdateTracks(p *state.Playlist)
24 | }
25 |
26 | type Container struct {
27 | gtk.Stack
28 | parent ParentController
29 |
30 | Lists map[string]*TrackList // tree model
31 |
32 | // current treeview playlist name
33 | current string
34 | }
35 |
36 | func NewContainer(parent ParentController) *Container {
37 | stack := gtk.NewStack()
38 | stack.SetTransitionType(gtk.StackTransitionTypeCrossfade)
39 | stack.SetTransitionDuration(25)
40 |
41 | return &Container{
42 | parent: parent,
43 | Stack: *stack,
44 | Lists: map[string]*TrackList{},
45 | }
46 | }
47 |
48 | func (c *Container) SelectPlaylist(playlist *state.Playlist) *TrackList {
49 | pl, ok := c.Lists[playlist.Name]
50 | if !ok {
51 | pl = NewTrackList(c.parent, playlist)
52 | c.Lists[playlist.Name] = pl
53 | c.Stack.AddNamed(pl, playlist.Name)
54 | }
55 |
56 | c.current = playlist.Name
57 | c.Stack.SetVisibleChild(pl)
58 | return pl
59 | }
60 |
61 | func (c *Container) DeletePlaylist(name string) {
62 | pl, ok := c.Lists[name]
63 | if !ok {
64 | return
65 | }
66 |
67 | c.current = ""
68 | c.Stack.Remove(pl)
69 | delete(c.Lists, name)
70 | }
71 |
72 | func newColumn(text string, col columnType) *gtk.TreeViewColumn {
73 | r := gtk.NewCellRendererText()
74 | r.SetObjectProperty("weight-set", true)
75 | r.SetObjectProperty("ellipsize", pango.EllipsizeEnd)
76 | r.SetObjectProperty("ellipsize-set", true)
77 |
78 | c := gtk.NewTreeViewColumn()
79 | c.SetTitle(text)
80 | c.PackStart(r, false)
81 | c.AddAttribute(r, "text", int(col))
82 | c.AddAttribute(r, "weight", int(columnSelected))
83 | c.SetSizing(gtk.TreeViewColumnFixed)
84 | c.SetResizable(true)
85 |
86 | switch col {
87 | case columnTime:
88 | c.SetMinWidth(50)
89 | case columnSelected, columnSearchData:
90 | c.SetVisible(false)
91 | default:
92 | c.SetExpand(true)
93 | c.SetMinWidth(150)
94 | }
95 |
96 | return c
97 | }
98 |
99 | type TrackRow struct {
100 | Bold bool
101 | iter struct {
102 | path *gtk.TreePath
103 | store *gtk.ListStore
104 | }
105 | // Path *gtk.TreePath
106 | }
107 |
108 | func (row *TrackRow) Iter() (*gtk.TreeIter, bool) {
109 | return row.iter.store.Iter(row.iter.path)
110 | }
111 |
112 | func (row *TrackRow) Path() *gtk.TreePath { return row.iter.path }
113 |
114 | func (row *TrackRow) Remove() bool {
115 | if iter, ok := row.Iter(); ok {
116 | row.iter.store.Remove(iter)
117 | return true
118 | }
119 | return false
120 | }
121 |
122 | func (row *TrackRow) SetBold(store *gtk.ListStore, bold bool) {
123 | it, ok := row.Iter()
124 | if !ok {
125 | return
126 | }
127 |
128 | row.Bold = bold
129 | store.SetValue(it, columnSelected, glib.NewValue(weight(row.Bold)))
130 | }
131 |
132 | func (row *TrackRow) setListStore(t *state.Track) {
133 | it, ok := row.Iter()
134 | if !ok {
135 | return
136 | }
137 |
138 | metadata := t.Metadata()
139 |
140 | searchData := strings.Builder{}
141 | searchData.WriteString(metadata.Title)
142 | searchData.WriteByte(' ')
143 | searchData.WriteString(metadata.Artist)
144 | searchData.WriteByte(' ')
145 | searchData.WriteString(metadata.Album)
146 | searchData.WriteByte(' ')
147 | searchData.WriteString(metadata.Filepath)
148 |
149 | row.iter.store.Set(
150 | it,
151 | []int{
152 | columnTitle,
153 | columnArtist,
154 | columnAlbum,
155 | columnTime,
156 | columnSelected,
157 | columnSearchData,
158 | },
159 | []glib.Value{
160 | *glib.NewValue(metadata.Title),
161 | *glib.NewValue(metadata.Artist),
162 | *glib.NewValue(metadata.Album),
163 | *glib.NewValue(durafmt.Format(metadata.Length)),
164 | *glib.NewValue(weight(row.Bold)),
165 | *glib.NewValue(searchData.String()),
166 | },
167 | )
168 | }
169 |
170 | func weight(bold bool) pango.Weight {
171 | if bold {
172 | return pango.WeightBold
173 | }
174 | return pango.WeightBook
175 | }
176 |
177 | const (
178 | AlbumIconSize = gtk.IconSizeLarge
179 | PixelIconSize = 96
180 | )
181 |
182 | var trackTooltipCSS = css.PrepareClass("track-tooltip", "")
183 |
184 | var trackTooltipImageCSS = css.PrepareClass("track-tooltip-image", `
185 | .track-tooltip-image {
186 | margin: 6px;
187 | }
188 | `)
189 |
190 | type trackTooltipBox struct {
191 | image *gdkpixbuf.Pixbuf
192 | trackPath string
193 | stopFetch context.CancelFunc
194 | }
195 |
196 | func newTrackTooltipBox() *trackTooltipBox {
197 | return &trackTooltipBox{
198 | stopFetch: func() {}, // stub
199 | }
200 | }
201 |
202 | func (tt *trackTooltipBox) Attach(t *gtk.Tooltip, track *state.Track) {
203 | mdata := track.Metadata()
204 |
205 | newTrack := tt.trackPath != mdata.Filepath
206 | if newTrack {
207 | tt.trackPath = mdata.Filepath
208 | tt.image = nil
209 | }
210 |
211 | // TODO: album art fetchign is very expensive. Until we can reduce the call
212 | // frequency, we should absolutely not do this.
213 |
214 | // if tt.image != nil {
215 | // t.SetIcon(tt.image)
216 | // } else {
217 | // t.SetIconFromIconName("folder-music-symbolic", AlbumIconSize)
218 |
219 | // // Gtk is very, VERY dumb, so it'll try and fetch an album art just by
220 | // // hovering the cursor on things. I simply cannot fix stupidity of this
221 | // // level, so just deal with the fd spam. At least I have a context to
222 | // // cancel things.
223 | // if newTrack {
224 | // tt.stopFetch()
225 | // ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
226 | // tt.stopFetch = cancel
227 |
228 | // go func() {
229 | // defer cancel()
230 |
231 | // p := sidebar.FetchAlbumArt(ctx, track, PixelIconSize)
232 | // if p == nil {
233 | // return
234 | // }
235 |
236 | // glib.IdleAdd(func() {
237 | // if tt.trackPath == mdata.Filepath {
238 | // tt.image = p
239 | // t.SetIcon(p)
240 | // }
241 | // })
242 | // }()
243 | // }
244 | // }
245 |
246 | var builder strings.Builder
247 | writeHTMLField(&builder, "Title: %s\n", mdata.Title)
248 | writeHTMLField(&builder, "Artist: %s\n", mdata.Artist)
249 | writeHTMLField(&builder, "Album: %s\n", mdata.Album)
250 | writeHTMLField(&builder, "Number: %s\n", strconv.Itoa(mdata.Number))
251 | writeHTMLField(&builder, "Length: %s\n", durafmt.Format(mdata.Length))
252 | writeHTMLField(&builder,
253 | "Filepath: %s",
254 | mdata.Filepath,
255 | )
256 |
257 | t.SetMarkup(builder.String())
258 | }
259 |
260 | func writeHTMLField(w io.Writer, f, v string) {
261 | if v != "" {
262 | fmt.Fprintf(w, f, html.EscapeString(v))
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/internal/ui/content/content.go:
--------------------------------------------------------------------------------
1 | package content
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/ui/content/bar"
5 | "github.com/diamondburned/aqours/internal/ui/content/body"
6 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
7 | )
8 |
9 | type ParentController interface {
10 | body.ParentController
11 | bar.ParentController
12 | }
13 |
14 | type Container struct {
15 | ContentBox *gtk.Box
16 |
17 | Body *body.Container
18 | Bar *bar.Container
19 | }
20 |
21 | func NewContainer(parent ParentController) Container {
22 | body := body.NewContainer(parent)
23 | body.SetHExpand(true)
24 |
25 | separator := gtk.NewSeparator(gtk.OrientationVertical)
26 |
27 | bar := bar.NewContainer(parent)
28 |
29 | box := gtk.NewBox(gtk.OrientationVertical, 0)
30 | box.SetHExpand(true)
31 | box.Append(body)
32 | box.Append(separator)
33 | box.Append(bar)
34 |
35 | return Container{
36 | ContentBox: box,
37 | Body: body,
38 | Bar: bar,
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/internal/ui/css/css.go:
--------------------------------------------------------------------------------
1 | package css
2 |
3 | import (
4 | "bytes"
5 | "log"
6 | "strings"
7 |
8 | "github.com/diamondburned/gotk4/pkg/gdk/v4"
9 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
10 | )
11 |
12 | var globalCSS bytes.Buffer
13 |
14 | // PrepareClass prepares the CSS and returns a function that applies the class
15 | // onto the given widget. The CSS should be for the class only, and the class
16 | // should reflect what's in the CSS block.
17 | func PrepareClass(class, css string) (attach func(gtk.Widgetter)) {
18 | globalCSS.WriteString(css)
19 |
20 | return func(widget gtk.Widgetter) {
21 | w := gtk.BaseWidget(widget)
22 | w.AddCSSClass(class)
23 | }
24 | }
25 |
26 | // Prepare parses the given CSS and returns the CSSProvider.
27 | func Prepare(css string) *gtk.CSSProvider {
28 | p := gtk.NewCSSProvider()
29 | p.ConnectParsingError(func(sec *gtk.CSSSection, err error) {
30 | // Optional line parsing routine.
31 | loc := sec.StartLocation()
32 | lines := strings.Split(css, "\n")
33 | log.Printf("CSS error (%v) at line: %q", err, lines[loc.Lines()])
34 | })
35 | p.LoadFromData(css)
36 | return p
37 | }
38 |
39 | // AddGlobal adds CSS to the global CSS buffer.
40 | func AddGlobal(css string) {
41 | globalCSS.WriteString(css)
42 | }
43 |
44 | // LoadGlobal loads the global CSS buffer into the given display.
45 | func LoadGlobal(disp *gdk.Display) {
46 | prov := Prepare(globalCSS.String())
47 | gtk.StyleContextAddProviderForDisplay(disp, prov, gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
48 | }
49 |
--------------------------------------------------------------------------------
/internal/ui/header/appcontrols.go:
--------------------------------------------------------------------------------
1 | package header
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/diamondburned/aqours/internal/gtkutil"
7 | "github.com/diamondburned/aqours/internal/muse/playlist"
8 | "github.com/diamondburned/gotk4/pkg/gio/v2"
9 | "github.com/diamondburned/gotk4/pkg/glib/v2"
10 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
11 | )
12 |
13 | type AppControls struct {
14 | *gtk.Box
15 | OpenPlaylistButton *gtk.Button
16 | }
17 |
18 | func NewAppControls(parent ParentController) *AppControls {
19 | openBtn := gtk.NewButtonFromIconName("list-add-symbolic")
20 | openBtn.ConnectClicked(func() { spawnChooser(parent) })
21 | openBtn.SetTooltipMarkup("Add Playlist")
22 |
23 | box := gtk.NewBox(gtk.OrientationHorizontal, 5)
24 | box.Append(openBtn)
25 |
26 | return &AppControls{
27 | Box: box,
28 | OpenPlaylistButton: openBtn,
29 | }
30 | }
31 |
32 | func spawnChooser(parent ParentController) {
33 | dialog := gtk.NewFileChooserNative(
34 | "Choose Playlist", gtkutil.ActiveWindow(),
35 | gtk.FileChooserActionOpen, "Add", "Cancel",
36 | )
37 |
38 | p, err := os.Getwd()
39 | if err != nil {
40 | p = glib.GetUserDataDir()
41 | }
42 |
43 | ff := gtk.NewFileFilter()
44 | ff.SetName("Playlists")
45 | for _, ext := range playlist.SupportedExtensions() {
46 | ff.AddPattern("*" + ext)
47 | }
48 |
49 | dialog.SetFilter(ff)
50 | dialog.SetCurrentFolder(gio.NewFileForPath(p))
51 | dialog.SetSelectMultiple(false)
52 | dialog.ConnectResponse(func(id int) {
53 | defer dialog.Destroy()
54 |
55 | if id != int(gtk.ResponseAccept) {
56 | return
57 | }
58 |
59 | fileList := dialog.Files()
60 |
61 | for i := uint(0); true; i++ {
62 | filer := fileList.Item(i).Cast().(gio.Filer)
63 | if filer == nil {
64 | continue
65 | }
66 |
67 | path := filer.Path()
68 | if path == "" {
69 | continue
70 | }
71 |
72 | parent.AddPlaylist(path)
73 | }
74 | })
75 | }
76 |
--------------------------------------------------------------------------------
/internal/ui/header/header.go:
--------------------------------------------------------------------------------
1 | // Package header is the top bar that contains the logo, buttons, and the
2 | // playlist name.
3 | package header
4 |
5 | import (
6 | "fmt"
7 | "math"
8 |
9 | "github.com/diamondburned/aqours/internal/state"
10 | "github.com/diamondburned/aqours/internal/ui/css"
11 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
12 | )
13 |
14 | type ParentController interface {
15 | AddPlaylist(path string)
16 | // ParentPlaylistController methods.
17 | GoBack()
18 | HasPlaylist(name string) bool
19 | SavePlaylist(pl *state.Playlist)
20 | RenamePlaylist(pl *state.Playlist, newName string) bool
21 | SortSelectedTracks()
22 | }
23 |
24 | var bitrateCSS = css.PrepareClass("bitrate", `
25 | .bitrate {
26 | margin: 0 6px;
27 | }
28 | `)
29 |
30 | type Container struct {
31 | gtk.HeaderBar
32 | ParentController
33 |
34 | Left *AppControls
35 | Info *PlaylistInfo
36 |
37 | RightSide *gtk.Box
38 | Bitrate *gtk.Label
39 | Right *PlaylistControls
40 |
41 | current *state.Playlist
42 | }
43 |
44 | func NewContainer(parent ParentController) *Container {
45 | c := &Container{ParentController: parent}
46 |
47 | c.Left = NewAppControls(parent)
48 |
49 | c.Info = NewPlaylistInfo()
50 |
51 | c.Bitrate = gtk.NewLabel("")
52 | c.Bitrate.SetSingleLineMode(true)
53 |
54 | bitrateCSS(c.Bitrate)
55 |
56 | c.Right = NewPlaylistControls(c)
57 |
58 | c.RightSide = gtk.NewBox(gtk.OrientationHorizontal, 0)
59 | c.RightSide.Append(c.Bitrate)
60 | c.RightSide.Append(c.Right)
61 |
62 | empty := gtk.NewBox(gtk.OrientationHorizontal, 0)
63 |
64 | h := gtk.NewHeaderBar()
65 | h.SetTitleWidget(empty)
66 | h.PackStart(c.Left)
67 | h.PackStart(c.Info)
68 | h.PackEnd(c.RightSide)
69 |
70 | c.HeaderBar = *h
71 |
72 | c.Reset()
73 |
74 | return c
75 | }
76 |
77 | func (c *Container) Reset() {
78 | c.Info.Reset()
79 | c.Right.SetRevealChild(false)
80 | }
81 |
82 | // SetBitrate sets the bitrate to display. The indicator is empty if bits is
83 | // less than 0.
84 | func (c *Container) SetBitrate(bits float64) {
85 | if bits < 1 {
86 | c.Bitrate.SetText("")
87 | return
88 | }
89 |
90 | c.Bitrate.SetMarkup(fmt.Sprintf(
91 | `%g kbits/s`, math.Round(bits/1000),
92 | ))
93 | }
94 |
95 | // SetUnsaved sets the header info to display the name as unchanged if the
96 | // given playlist is indeed being displayed. It does nothing otherwise.
97 | func (c *Container) SetUnsaved(pl *state.Playlist) {
98 | if pl == c.current {
99 | c.Info.SetUnsaved(pl.IsUnsaved())
100 | }
101 | }
102 |
103 | func (c *Container) SetPlaylist(pl *state.Playlist) {
104 | c.current = pl
105 |
106 | if pl != nil {
107 | c.Info.SetPlaylist(pl)
108 | c.Right.SetRevealChild(true)
109 | } else {
110 | c.Info.Reset()
111 | c.Right.SetRevealChild(false)
112 | }
113 | }
114 |
115 | func (c *Container) SaveCurrentPlaylist() {
116 | c.SavePlaylist(c.current)
117 | }
118 |
119 | // RenamePlaylist calls the parent's RenamePlaylist with the current name.
120 | func (c *Container) RenamePlaylist(newName string) {
121 | renamed := c.ParentController.RenamePlaylist(c.current, newName)
122 | if renamed {
123 | c.Info.SetPlaylist(c.current)
124 | }
125 | }
126 |
127 | // PlaylistName returns the current playlist, or an empty string if none.
128 | func (c *Container) PlaylistName() string {
129 | return c.Info.Playlist
130 | }
131 |
--------------------------------------------------------------------------------
/internal/ui/header/info.go:
--------------------------------------------------------------------------------
1 | package header
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/state"
5 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
6 | )
7 |
8 | type PlaylistInfo struct {
9 | gtk.Label // name
10 | Playlist string
11 | }
12 |
13 | func NewPlaylistInfo() *PlaylistInfo {
14 | name := gtk.NewLabel("")
15 | name.SetYAlign(0)
16 | name.SetVAlign(gtk.AlignCenter)
17 | name.SetSingleLineMode(true)
18 |
19 | info := &PlaylistInfo{
20 | Label: *name,
21 | }
22 |
23 | info.Reset()
24 |
25 | return info
26 | }
27 |
28 | func (info *PlaylistInfo) Reset() {
29 | info.Playlist = ""
30 | info.SetMarkup("Aqours")
31 | }
32 |
33 | func (info *PlaylistInfo) SetPlaylist(pl *state.Playlist) {
34 | info.Playlist = pl.Name
35 | info.SetText(pl.Name)
36 | info.SetUnsaved(pl.IsUnsaved())
37 | }
38 |
39 | func (info *PlaylistInfo) SetUnsaved(unsaved bool) {
40 | if !unsaved {
41 | info.SetText(info.Playlist)
42 | } else {
43 | info.SetText(info.Playlist + " ●")
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/internal/ui/header/playlistcontrols.go:
--------------------------------------------------------------------------------
1 | package header
2 |
3 | import (
4 | "github.com/diamondburned/aqours/internal/gtkutil"
5 | "github.com/diamondburned/aqours/internal/ui/actions"
6 | "github.com/diamondburned/aqours/internal/ui/css"
7 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 | )
9 |
10 | var renameEntryCSS = css.PrepareClass("rename-entry", `
11 | entry.rename-entry {
12 | margin: 8px;
13 | }
14 | `)
15 |
16 | type ParentPlaylistController interface {
17 | // RenamePlaylist renames the current playlist.
18 | RenamePlaylist(newName string)
19 | // HasPlaylist returns true if the playlist already exists with the given
20 | // name.
21 | HasPlaylist(name string) bool
22 | // PlaylistName gets the playlist name.
23 | PlaylistName() string
24 | // GoBack navigates the body leaflet to the left panel.
25 | GoBack()
26 | // SaveCurrentPlaylist saves the current playlist and marks the playlist
27 | // name as saved.
28 | SaveCurrentPlaylist()
29 | // SortSelectedTracks sorts the selected songs.
30 | SortSelectedTracks()
31 | }
32 |
33 | type PlaylistControls struct {
34 | gtk.Revealer
35 | Hamburger *actions.MenuButton
36 | HamMenu *actions.Menu
37 | }
38 |
39 | func NewPlaylistControls(parent ParentPlaylistController) *PlaylistControls {
40 | hamMenu := actions.NewMenu("playlist-ctrl")
41 |
42 | hamburger := actions.NewMenuButton()
43 | hamburger.SetIconName("open-menu-symbolic")
44 | hamburger.Bind(hamMenu)
45 |
46 | rev := gtk.NewRevealer()
47 | rev.SetRevealChild(false)
48 | rev.SetTransitionDuration(50)
49 | rev.SetTransitionType(gtk.RevealerTransitionTypeCrossfade)
50 | rev.SetChild(hamburger)
51 |
52 | hamMenu.AddAction("Rename Playlist", func() { spawnRenameDialog(parent) })
53 | hamMenu.AddAction("Save Playlist", parent.SaveCurrentPlaylist)
54 | hamMenu.AddAction("Sort Selected Tracks", parent.SortSelectedTracks)
55 |
56 | return &PlaylistControls{
57 | Revealer: *rev,
58 | Hamburger: hamburger,
59 | HamMenu: hamMenu,
60 | }
61 | }
62 |
63 | const nameCollideMsg = "Playlist already exists with the same name."
64 |
65 | func spawnRenameDialog(parent ParentPlaylistController) {
66 | window := gtkutil.ActiveWindow()
67 | dialog := gtk.NewDialogWithFlags(
68 | "Rename Playlist", window, gtk.DialogModal|gtk.DialogUseHeaderBar)
69 |
70 | dialog.AddButton("Rename", int(gtk.ResponseApply))
71 | // We're starting w/ the same name, so we shouldn't let it apply.
72 | dialog.SetResponseSensitive(int(gtk.ResponseApply), false)
73 |
74 | var newName string
75 |
76 | entry := gtk.NewEntry()
77 | entry.SetText(parent.PlaylistName())
78 | entry.SetPlaceholderText("New Playlist")
79 | entry.Connect("changed", func() {
80 | t := entry.Text()
81 | if t == "" || parent.HasPlaylist(t) {
82 | dialog.SetResponseSensitive(int(gtk.ResponseApply), false)
83 | entry.SetIconFromIconName(gtk.EntryIconSecondary, "dialog-error-symbolic")
84 | entry.SetIconTooltipText(gtk.EntryIconSecondary, nameCollideMsg)
85 | newName = ""
86 | } else {
87 | dialog.SetResponseSensitive(int(gtk.ResponseApply), true)
88 | entry.SetIconFromIconName(gtk.EntryIconSecondary, "")
89 | newName = t
90 | }
91 | })
92 |
93 | renameEntryCSS(entry)
94 |
95 | c := dialog.ContentArea()
96 | c.Append(entry)
97 |
98 | dialog.ConnectResponse(func(res int) {
99 | defer dialog.Destroy()
100 |
101 | if res != int(gtk.ResponseApply) || newName == "" {
102 | return
103 | }
104 |
105 | parent.RenamePlaylist(newName)
106 | })
107 | }
108 |
--------------------------------------------------------------------------------
/internal/ui/preferences.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | // things to make preferences for:
4 | // - visualizer
5 |
6 | // TODO
7 |
8 | func (w *MainWindow) SpawnPreferences() {}
9 |
--------------------------------------------------------------------------------
/internal/ui/ui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "time"
7 |
8 | "github.com/diamondburned/aqours/internal/muse"
9 | "github.com/diamondburned/aqours/internal/muse/playlist"
10 | "github.com/diamondburned/aqours/internal/state"
11 | "github.com/diamondburned/aqours/internal/ui/content"
12 | "github.com/diamondburned/aqours/internal/ui/css"
13 | "github.com/diamondburned/aqours/internal/ui/header"
14 | "github.com/diamondburned/gotk4/pkg/gdk/v4"
15 | "github.com/diamondburned/gotk4/pkg/glib/v2"
16 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
17 | )
18 |
19 | func init() {
20 | css.AddGlobal(``)
21 | }
22 |
23 | func assert(b bool, e string) {
24 | if !b {
25 | log.Panicln("BUG: assertion failed:", e)
26 | }
27 | }
28 |
29 | // TODO
30 | // // MusicPlayer is an interface for a music player backend. All its methods
31 | // // should preferably be non-blocking, and thus should handle error reporting on
32 | // // its own.
33 | // type MusicPlayer interface {
34 | // PlayTrack(path string)
35 | // Seek(pos float64)
36 | // SetPlay(bool)
37 | // SetMute(bool)
38 | // SetVolume(float64)
39 | // }
40 |
41 | // maxErrorThreshold is the error threshold before the player stops seeking.
42 | // Refer to errCounter.
43 | const maxErrorThreshold = 3
44 |
45 | const minPlayLength = 250 * time.Millisecond
46 |
47 | type MainWindow struct {
48 | content.Container
49 |
50 | Window *gtk.ApplicationWindow
51 | Header *header.Container
52 |
53 | muse *muse.Session
54 | state *state.State
55 |
56 | lastPlayed time.Time
57 | skipCount int
58 | }
59 |
60 | func NewMainWindow(
61 | a *gtk.Application, session *muse.Session, s *state.State) (*MainWindow, error) {
62 |
63 | window := gtk.NewApplicationWindow(a)
64 | window.SetTitle("Aqours")
65 | window.SetDefaultSize(800, 500)
66 | window.ConnectMap(func() {
67 | surface := window.Surface()
68 | display := gdk.BaseSurface(surface).Display()
69 | css.LoadGlobal(display)
70 | })
71 |
72 | w := &MainWindow{
73 | Window: window,
74 | muse: session,
75 | }
76 |
77 | w.Header = header.NewContainer(w)
78 | window.SetTitlebar(w.Header)
79 |
80 | w.Container = content.NewContainer(w)
81 | window.SetChild(w.ContentBox)
82 |
83 | w.useState(s)
84 |
85 | // Use a low-priority 250ms poller instead of updating live.
86 | glib.TimeoutAddPriority(250, glib.PriorityDefaultIdle, func() bool {
87 | pos, rem := session.PlayState.PlayTime()
88 | w.Bar.Controls.Seek.UpdatePosition(pos, pos+rem)
89 |
90 | w.Header.SetBitrate(session.PlayState.Bitrate())
91 |
92 | return true
93 | })
94 |
95 | return w, nil
96 | }
97 |
98 | // Present shows and focuses the window.
99 | func (w *MainWindow) Present() {
100 | w.Window.Present()
101 | }
102 |
103 | // PlaySession returns the internal playback session.
104 | func (w *MainWindow) PlaySession() *muse.Session {
105 | return w.muse
106 | }
107 |
108 | // State exposes the local state that was passed in.
109 | func (w *MainWindow) State() *state.State {
110 | return w.state
111 | }
112 |
113 | // useState makes the MainWindow use an existing state.
114 | func (w *MainWindow) useState(s *state.State) {
115 | w.state = s
116 |
117 | // Restore the state. These calls will update the observer.
118 | w.SetRepeat(w.state.RepeatMode())
119 | w.SetShuffle(w.state.IsShuffling())
120 | // These calls will update MainWindow through signals.
121 | w.Bar.SetMute(w.state.IsMuted())
122 | w.Bar.Volume.SetVolume(w.state.Volume())
123 |
124 | var selected *state.Playlist
125 |
126 | playlistNames := w.state.PlaylistNames()
127 | if len(playlistNames) == 0 {
128 | // First run; can't restore state.
129 | return
130 | }
131 |
132 | for _, name := range playlistNames {
133 | playlist, _ := w.state.Playlist(name)
134 | uiPl := w.Body.Sidebar.PlaylistList.AddPlaylist(playlist)
135 |
136 | if name == w.state.PlayingPlaylistName() {
137 | w.Body.Sidebar.PlaylistList.SelectPlaylist(uiPl)
138 | selected = playlist
139 | }
140 | }
141 |
142 | // If there's no active selection, then try the first playlist.
143 | if selected == nil {
144 | w.Body.Sidebar.PlaylistList.SelectFirstPlaylist()
145 | selected, _ = w.state.Playlist(playlistNames[0])
146 |
147 | // Ensure we're selecting the right playlist.
148 | w.state.SetPlayingPlaylist(selected)
149 | }
150 |
151 | // If there is finally a selection, then update the track list. This is nil
152 | // when there is no playlist.
153 | trackList := w.Body.TracksView.SelectPlaylist(selected)
154 |
155 | // Update the playing track if we have one. NowPlaying should return a track
156 | // from the given playlist.
157 | _, track := w.state.NowPlaying()
158 | if track != nil {
159 | trackList.SetPlaying(track)
160 | }
161 | }
162 |
163 | func (w *MainWindow) GoBack() { w.Body.SwipeBack() }
164 |
165 | // OnSongFinish plays the next song in the playlist. If the error given is not
166 | // nil, then it'll gradually seek to the next song until either no error is
167 | // given anymore or the error counter hits its max.
168 | func (w *MainWindow) OnSongFinish() {
169 | // Seek the bar back to 0 immediately.
170 | w.Bar.Controls.Seek.UpdatePosition(0, 0)
171 |
172 | now := time.Now()
173 |
174 | // Are we going too quickly?
175 | if w.lastPlayed.Add(minPlayLength).After(now) {
176 | // Increment skip count. If we're over the bound, then stop.
177 | w.skipCount++
178 | log.Println("Track too short. Skipped tracks:", w.skipCount)
179 | } else {
180 | w.skipCount = 0
181 | }
182 |
183 | if w.skipCount > maxErrorThreshold {
184 | log.Println("Skipped tracks over threshold, stopping.")
185 | return
186 | }
187 |
188 | w.lastPlayed = now
189 |
190 | // Play the next song.
191 | _, track := w.state.AutoNext()
192 | if track != nil {
193 | w.playTrack(track)
194 | }
195 | }
196 |
197 | func (w *MainWindow) OnPauseUpdate(pause bool) {
198 | w.Bar.SetPaused(pause)
199 | if pause {
200 | w.Header.SetBitrate(-1)
201 | }
202 | }
203 |
204 | func (w *MainWindow) AddPlaylist(path string) {
205 | w.Window.SetSensitive(false)
206 |
207 | go func() {
208 | p, err := playlist.ParseFile(path)
209 | if err != nil {
210 | log.Println("Failed parsing playlist:", err)
211 | }
212 |
213 | glib.IdleAdd(func() {
214 | w.Window.SetSensitive(true)
215 |
216 | if err != nil {
217 | return
218 | }
219 |
220 | if _, ok := w.state.Playlist(p.Name); ok {
221 | // Try and mangle the name.
222 | var mangle int
223 | var name string
224 |
225 | for {
226 | if _, ok := w.state.Playlist(p.Name); !ok {
227 | break
228 | }
229 | mangle++
230 | p.Name = fmt.Sprintf("%s~%d", name, mangle)
231 | }
232 | }
233 |
234 | playlist := w.state.AddPlaylist(p)
235 | w.Body.Sidebar.PlaylistList.AddPlaylist(playlist)
236 | })
237 | }()
238 | }
239 |
240 | func (w *MainWindow) HasPlaylist(name string) bool {
241 | _, ok := w.state.Playlist(name)
242 | return ok
243 | }
244 |
245 | func (w *MainWindow) SaveAllPlaylists() {
246 | for _, name := range w.state.PlaylistNames() {
247 | pl, _ := w.state.Playlist(name)
248 | w.SavePlaylist(pl)
249 | }
250 | }
251 |
252 | func (w *MainWindow) SavePlaylist(pl *state.Playlist) {
253 | if !pl.IsUnsaved() {
254 | return
255 | }
256 |
257 | refresh := func() {
258 | w.Header.SetUnsaved(pl)
259 | w.Body.Sidebar.PlaylistList.SetUnsaved(pl)
260 | }
261 | // Visually indicate the saved status.
262 | refresh()
263 |
264 | pl.Save(func(err error) {
265 | glib.IdleAdd(refresh)
266 | if err != nil {
267 | log.Println("failed to save playlist:", err)
268 | }
269 | })
270 | }
271 |
272 | // RenamePlaylist renames a playlist. It only works if we're renaming the
273 | // current playlist.
274 | func (w *MainWindow) RenamePlaylist(pl *state.Playlist, newName string) bool {
275 | // Collision check.
276 | if _, exists := w.state.Playlist(newName); exists {
277 | return false
278 | }
279 |
280 | plName := pl.Name
281 | pl.Name = newName
282 | w.state.RenamePlaylist(pl, plName)
283 |
284 | w.Body.TracksView.DeletePlaylist(plName)
285 | w.Body.Sidebar.PlaylistList.Playlist(plName).SetName(newName)
286 | w.SelectPlaylist(newName)
287 |
288 | return true
289 | }
290 |
291 | func (w *MainWindow) Seek(pos float64) {
292 | if err := w.muse.Seek(pos); err != nil {
293 | log.Println("Seek failed:", err)
294 | }
295 | }
296 |
297 | func (w *MainWindow) Next() {
298 | _, track := w.state.Next()
299 | if track != nil {
300 | w.playTrack(track)
301 | }
302 | }
303 |
304 | func (w *MainWindow) Previous() {
305 | _, track := w.state.Previous()
306 | if track != nil {
307 | w.playTrack(track)
308 | }
309 | }
310 |
311 | func (w *MainWindow) SetPlay(playing bool) {
312 | if err := w.muse.SetPlay(playing); err != nil {
313 | log.Println("SetPlay failed:", err)
314 | }
315 | }
316 |
317 | func (w *MainWindow) SetShuffle(shuffle bool) {
318 | w.state.SetShuffling(shuffle)
319 | w.Bar.Controls.Buttons.SetShuffle(shuffle)
320 | }
321 |
322 | func (w *MainWindow) SetRepeat(mode state.RepeatMode) {
323 | w.state.SetRepeatMode(mode)
324 | w.Bar.Controls.Buttons.SetRepeat(mode, false)
325 | }
326 |
327 | func (w *MainWindow) PlayTrack(playlist *state.Playlist, n int) {
328 | // Change the playing playlist if needed.
329 | if w.state.PlayingPlaylistName() != playlist.Name {
330 | w.state.SetPlayingPlaylist(playlist)
331 | }
332 |
333 | w.playTrack(w.state.Play(n))
334 | }
335 |
336 | func (w *MainWindow) UpdateTracks(playlist *state.Playlist) {
337 | w.Header.SetUnsaved(playlist)
338 | w.Body.Sidebar.PlaylistList.SetUnsaved(playlist)
339 |
340 | // If we've updated the current playlist, then we should also refresh the
341 | // play queue.
342 | if w.state.PlayingPlaylist() == playlist {
343 | w.state.RefreshQueue()
344 | }
345 | }
346 |
347 | func (w *MainWindow) playTrack(track *state.Track) {
348 | var nextPath string
349 | if _, nextTrack := w.state.Peek(); nextTrack != nil {
350 | nextPath = nextTrack.Filepath
351 | }
352 |
353 | w.muse.PlayTrack(track.Filepath, nextPath)
354 | playing := w.state.PlayingPlaylist()
355 |
356 | trackList, ok := w.Body.TracksView.Lists[playing.Name]
357 | assert(ok, "track list not found from name: "+playing.Name)
358 |
359 | trackList.SetPlaying(track)
360 | w.Bar.NowPlaying.SetTrack(track)
361 | w.Body.Sidebar.AlbumArt.SetTrack(track)
362 |
363 | // Save the state asynchronously.
364 | w.state.SaveState()
365 | }
366 |
367 | // SortSelectedTracks sorts the selected tracks.
368 | func (w *MainWindow) SortSelectedTracks() {
369 | list := w.Body.TracksView.SelectPlaylist(w.state.PlayingPlaylist())
370 | list.SortSelected()
371 | }
372 |
373 | func (w *MainWindow) SelectPlaylist(name string) {
374 | pl, ok := w.state.Playlist(name)
375 | if !ok {
376 | log.Println("Playlist not found:", name)
377 | return
378 | }
379 |
380 | w.selectPlaylist(pl)
381 | }
382 |
383 | func (w *MainWindow) selectPlaylist(pl *state.Playlist) {
384 | // Don't change the state's playing playlist.
385 |
386 | trackList := w.Body.TracksView.SelectPlaylist(pl)
387 | trackList.SelectPlaying()
388 |
389 | w.Header.SetPlaylist(pl)
390 | w.Window.SetTitle(fmt.Sprintf("%s - Aqours", pl.Name))
391 | }
392 |
393 | func (w *MainWindow) ScrollToPlaying() {
394 | w.selectPlaylist(w.state.PlayingPlaylist())
395 | }
396 |
397 | func (w *MainWindow) SetVolume(perc float64) {
398 | if err := w.muse.SetVolume(perc); err != nil {
399 | log.Println("SetVolume failed:", err)
400 | return
401 | }
402 |
403 | w.state.SetVolume(perc)
404 | }
405 |
406 | func (w *MainWindow) SetMute(mute bool) {
407 | if err := w.muse.SetMute(mute); err != nil {
408 | log.Println("SetMute failed:", mute)
409 | return
410 | }
411 |
412 | w.state.SetMute(mute)
413 | }
414 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | "github.com/diamondburned/aqours/internal/mpris"
9 | "github.com/diamondburned/aqours/internal/muse"
10 | "github.com/diamondburned/aqours/internal/state"
11 | "github.com/diamondburned/aqours/internal/ui"
12 | "github.com/diamondburned/gotk4/pkg/gio/v2"
13 | "github.com/diamondburned/gotk4/pkg/glib/v2"
14 | "github.com/diamondburned/gotk4/pkg/gtk/v4"
15 | )
16 |
17 | const (
18 | appFlags = gio.ApplicationFlagsNone
19 | appID = "com.github.diamondburned.aqours"
20 | )
21 |
22 | func main() {
23 | log.SetFlags(log.Lmicroseconds | log.Ltime)
24 | glib.LogUseDefaultLogger()
25 |
26 | var w *ui.MainWindow
27 |
28 | app := gtk.NewApplication(appID, appFlags)
29 | app.Connect("activate", func() {
30 | if w == nil {
31 | w = activate(app)
32 | }
33 | w.Window.Present()
34 | })
35 |
36 | if exitCode := app.Run(os.Args); exitCode > 0 {
37 | panic(fmt.Sprintf("exit status %d", exitCode))
38 | }
39 | }
40 |
41 | func activate(app *gtk.Application) *ui.MainWindow {
42 | ses, err := muse.NewSession()
43 | if err != nil {
44 | log.Fatalln("Failed to create mpv session:", err)
45 | }
46 |
47 | st, err := state.ReadFromFile()
48 | if err != nil {
49 | log.Printf("failed to restore state (%v); creating a new one.\n", err)
50 | st = state.NewState()
51 | }
52 |
53 | w, err := ui.NewMainWindow(app, ses, st)
54 | if err != nil {
55 | log.Fatalln("Failed to create main window:", err)
56 | }
57 |
58 | // Bind MPRIS.
59 | m, err := mpris.New()
60 | if err != nil {
61 | log.Println("Failed to bind MPRIS:", err)
62 | }
63 |
64 | // Bind window methods.
65 | ses.SetHandler(m.PassthroughEvents(w))
66 | st.OnUpdate(m.Update)
67 |
68 | // Start is non-blocking, as it should be when ran inside the main
69 | // thread.
70 | ses.Start()
71 |
72 | // TODO: add a saving spinner circle.
73 |
74 | // Try to save the state and all playlists every 15 seconds.
75 | glib.TimeoutSecondsAdd(15, func() bool {
76 | st.SaveState()
77 | w.SaveAllPlaylists()
78 | return true
79 | })
80 |
81 | app.ConnectShutdown(func() {
82 | ses.Stop()
83 | m.Close()
84 |
85 | st.SaveAll()
86 | st.WaitUntilSaved()
87 | })
88 |
89 | return w
90 | }
91 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/diamondburned/aqours/e62810afda6c4dbbc56b2de6ae81ed0f749c29bc/screenshot.png
--------------------------------------------------------------------------------
/shell.nix:
--------------------------------------------------------------------------------
1 | { pkgs ? import {} }:
2 |
3 | let go = pkgs.go.overrideAttrs (old: {
4 | version = "1.17.6";
5 | src = builtins.fetchurl {
6 | url = "https://go.dev/dl/go1.17.6.src.tar.gz";
7 | sha256 = "sha256:1j288zwnws3p2iv7r938c89706hmi1nmwd8r5gzw3w31zzrvphad";
8 | };
9 | doCheck = false;
10 | patches = [
11 | # cmd/go/internal/work: concurrent ccompile routines
12 | (builtins.fetchurl "https://github.com/diamondburned/go/commit/4e07fa9fe4e905d89c725baed404ae43e03eb08e.patch")
13 | # cmd/cgo: concurrent file generation
14 | (builtins.fetchurl "https://github.com/diamondburned/go/commit/432db23601eeb941cf2ae3a539a62e6f7c11ed06.patch")
15 | ];
16 | });
17 |
18 | in pkgs.stdenv.mkDerivation rec {
19 | name = "aqours";
20 | version = "0.0.2";
21 |
22 | CGO_ENABLED = "1";
23 |
24 | buildInputs = with pkgs; [
25 | gobject-introspection
26 | gnome3.glib
27 | (gnome3.gtk or gtk3)
28 | gtk4
29 | mpv
30 | ffmpeg
31 | # fftw portaudio
32 | ];
33 |
34 | nativeBuildInputs = [ go pkgs.pkgconfig ];
35 | }
36 |
--------------------------------------------------------------------------------