Compare commits
57 Commits
3b2fac133a
...
master
Author | SHA1 | Date | |
---|---|---|---|
3afc944a67 | |||
1d0b310228 | |||
315919186b | |||
1528931a29 | |||
a13cbabdb4 | |||
fdbc84aee5 | |||
e51be9697c | |||
31bd4e1b54 | |||
7c36302aec | |||
dbb70fc743 | |||
ccea5db9e9 | |||
fe6a45bc1d | |||
5309f1a080 | |||
21796e768e | |||
fbd342c6a7 | |||
d501272870 | |||
d3ddfe135a | |||
1c66ab2a6e | |||
36a2fddfa6 | |||
7824fc4b7c | |||
47eabdce96 | |||
7a22d8056b | |||
c513b15d13 | |||
b991672d92 | |||
a631691fd6 | |||
bde9fda78f | |||
172b43fc8b | |||
a722a404dc | |||
c61bf226d2 | |||
e29b6f8f55 | |||
b5082315ab | |||
6a507b5a9e | |||
9776f1fb15 | |||
91f94d894c | |||
1f61c59ea0 | |||
81b6cbeeb0 | |||
f9c8fb76d3 | |||
3ea7142b24 | |||
3d35b0a327 | |||
d6a8b82349 | |||
509367fd35 | |||
373f081a6b | |||
d85d8697bc | |||
8faf5de77f | |||
0ee80f4b49 | |||
e709c35bc1 | |||
2247c69259 | |||
1c07198fa4 | |||
a10cf5ccbe | |||
b0cd37ced3 | |||
9c4c917a1d | |||
02c461c076 | |||
9e894ebecf | |||
c05b38e27a | |||
f8bf23dd4f | |||
cc833e6694 | |||
814bbbff85 |
BIN
public/images/kode.png
Normal file
BIN
public/images/kode.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
@@ -1,20 +0,0 @@
|
||||
Copyright (c) 2013-2020 James Simpson and GoldFire Studios, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
@@ -1,564 +0,0 @@
|
||||
[](https://howlerjs.com)
|
||||
|
||||
# Description
|
||||
[howler.js](https://howlerjs.com) is an audio library for the modern web. It defaults to [Web Audio API](http://webaudio.github.io/web-audio-api/) and falls back to [HTML5 Audio](https://html.spec.whatwg.org/multipage/embedded-content.html#the-audio-element). This makes working with audio in JavaScript easy and reliable across all platforms.
|
||||
|
||||
Additional information, live demos and a user showcase are available at [howlerjs.com](https://howlerjs.com).
|
||||
|
||||
Follow on Twitter for howler.js and development-related discussion: [@GoldFireStudios](https://twitter.com/goldfirestudios).
|
||||
|
||||
### Features
|
||||
* Single API for all audio needs
|
||||
* Defaults to Web Audio API and falls back to HTML5 Audio
|
||||
* Handles edge cases and bugs across environments
|
||||
* Supports all codecs for full cross-browser support
|
||||
* Automatic caching for improved performance
|
||||
* Control sounds individually, in groups or globally
|
||||
* Playback of multiple sounds at once
|
||||
* Easy sound sprite definition and playback
|
||||
* Full control for fading, rate, seek, volume, etc.
|
||||
* Easily add 3D spatial sound or stereo panning
|
||||
* Modular - use what you want and easy to extend
|
||||
* No outside dependencies, just pure JavaScript
|
||||
* As light as 7kb gzipped
|
||||
|
||||
### Browser Compatibility
|
||||
Tested in the following browsers/versions:
|
||||
* Google Chrome 7.0+
|
||||
* Internet Explorer 9.0+
|
||||
* Firefox 4.0+
|
||||
* Safari 5.1.4+
|
||||
* Mobile Safari 6.0+ (after user input)
|
||||
* Opera 12.0+
|
||||
* Microsoft Edge
|
||||
|
||||
### Live Demos
|
||||
* [Audio Player](https://howlerjs.com/#player)
|
||||
* [Radio](https://howlerjs.com/#radio)
|
||||
* [Spatial Audio](https://howlerjs.com/#spatial)
|
||||
* [Audio Sprites](https://howlerjs.com/#sprite)
|
||||
|
||||
# Documentation
|
||||
|
||||
### Contents
|
||||
* [Quick Start](#quick-start)
|
||||
* [Examples](#examples)
|
||||
* [Core](#core)
|
||||
* [Options](#options)
|
||||
* [Methods](#methods)
|
||||
* [Global Options](#global-options)
|
||||
* [Global Methods](#global-methods)
|
||||
* [Plugin: Spatial](#plugin-spatial)
|
||||
* [Options](#options-1)
|
||||
* [Methods](#methods-1)
|
||||
* [Global Methods](#global-methods-1)
|
||||
* [Group Playback](#group-playback)
|
||||
* [Mobile Playback](#mobilechrome-playback)
|
||||
* [Dolby Audio Playback](#dolby-audio-playback)
|
||||
* [Facebook Instant Games](#facebook-instant-games)
|
||||
* [Format Recommendations](#format-recommendations)
|
||||
* [License](#license)
|
||||
|
||||
### Quick Start
|
||||
|
||||
Several options to get up and running:
|
||||
|
||||
* Clone the repo: `git clone https://github.com/goldfire/howler.js.git`
|
||||
* Install with [npm](https://www.npmjs.com/package/howler): `npm install howler`
|
||||
* Install with [Yarn](https://yarnpkg.com/en/package/howler): `yarn add howler`
|
||||
* Install with [Bower](http://bower.io/): `bower install howler`
|
||||
* Hosted CDN: [`cdnjs`](https://cdnjs.com/libraries/howler) [`jsDelivr`](https://www.jsdelivr.com/projects/howler.js)
|
||||
|
||||
In the browser:
|
||||
|
||||
```html
|
||||
<script src="/path/to/howler.js"></script>
|
||||
<script>
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3']
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
As a dependency:
|
||||
|
||||
```javascript
|
||||
import {Howl, Howler} from 'howler';
|
||||
```
|
||||
|
||||
```javascript
|
||||
const {Howl, Howler} = require('howler');
|
||||
```
|
||||
|
||||
Included distribution files:
|
||||
|
||||
* **howler**: This is the default and fully bundled source that includes `howler.core` and `howler.spatial`. It includes all functionality that howler comes with.
|
||||
* **howler.core**: This includes only the core functionality that aims to create parity between Web Audio and HTML5 Audio. It doesn't include any of the spatial/stereo audio functionality.
|
||||
* **howler.spatial**: This is a plugin that adds spatial/stereo audio functionality. It requires `howler.core` to operate as it is simply an add-on to the core.
|
||||
|
||||
|
||||
### Examples
|
||||
|
||||
##### Most basic, play an MP3:
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.mp3']
|
||||
});
|
||||
|
||||
sound.play();
|
||||
```
|
||||
|
||||
##### Streaming audio (for live audio or large files):
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['stream.mp3'],
|
||||
html5: true
|
||||
});
|
||||
|
||||
sound.play();
|
||||
```
|
||||
|
||||
##### More playback options:
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3', 'sound.wav'],
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
onend: function() {
|
||||
console.log('Finished!');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
##### Define and play a sound sprite:
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sounds.webm', 'sounds.mp3'],
|
||||
sprite: {
|
||||
blast: [0, 3000],
|
||||
laser: [4000, 1000],
|
||||
winner: [6000, 5000]
|
||||
}
|
||||
});
|
||||
|
||||
// Shoot the laser!
|
||||
sound.play('laser');
|
||||
```
|
||||
|
||||
##### Listen for events:
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3']
|
||||
});
|
||||
|
||||
// Clear listener after first call.
|
||||
sound.once('load', function(){
|
||||
sound.play();
|
||||
});
|
||||
|
||||
// Fires when the sound finishes playing.
|
||||
sound.on('end', function(){
|
||||
console.log('Finished!');
|
||||
});
|
||||
```
|
||||
|
||||
##### Control multiple sounds:
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3']
|
||||
});
|
||||
|
||||
// Play returns a unique Sound ID that can be passed
|
||||
// into any method on Howl to control that specific sound.
|
||||
var id1 = sound.play();
|
||||
var id2 = sound.play();
|
||||
|
||||
// Fade out the first sound and speed up the second.
|
||||
sound.fade(1, 0, 1000, id1);
|
||||
sound.rate(1.5, id2);
|
||||
```
|
||||
|
||||
##### ES6:
|
||||
```javascript
|
||||
import {Howl, Howler} from 'howler';
|
||||
|
||||
// Setup the new Howl.
|
||||
const sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3']
|
||||
});
|
||||
|
||||
// Play the sound.
|
||||
sound.play();
|
||||
|
||||
// Change global volume.
|
||||
Howler.volume(0.5);
|
||||
```
|
||||
|
||||
|
||||
More in-depth examples (with accompanying live demos) can be found in the [examples directory](https://github.com/goldfire/howler.js/tree/master/examples).
|
||||
|
||||
|
||||
## Core
|
||||
|
||||
### Options
|
||||
#### src `Array/String` `[]` *`required`*
|
||||
The sources to the track(s) to be loaded for the sound (URLs or base64 data URIs). These should be in order of preference, howler.js will automatically load the first one that is compatible with the current browser. If your files have no extensions, you will need to explicitly specify the extension using the `format` property.
|
||||
#### volume `Number` `1.0`
|
||||
The volume of the specific track, from `0.0` to `1.0`.
|
||||
#### html5 `Boolean` `false`
|
||||
Set to `true` to force HTML5 Audio. This should be used for large audio files so that you don't have to wait for the full file to be downloaded and decoded before playing.
|
||||
#### loop `Boolean` `false`
|
||||
Set to `true` to automatically loop the sound forever.
|
||||
#### preload `Boolean|String` `true`
|
||||
Automatically begin downloading the audio file when the `Howl` is defined. If using HTML5 Audio, you can set this to `'metadata'` to only preload the file's metadata (to get its duration without download the entire file, for example).
|
||||
#### autoplay `Boolean` `false`
|
||||
Set to `true` to automatically start playback when sound is loaded.
|
||||
#### mute `Boolean` `false`
|
||||
Set to `true` to load the audio muted.
|
||||
#### sprite `Object` `{}`
|
||||
Define a sound sprite for the sound. The offset and duration are defined in milliseconds. A third (optional) parameter is available to set a sprite as looping. An easy way to generate compatible sound sprites is with [audiosprite](https://github.com/tonistiigi/audiosprite).
|
||||
```javascript
|
||||
new Howl({
|
||||
sprite: {
|
||||
key1: [offset, duration, (loop)]
|
||||
},
|
||||
});
|
||||
```
|
||||
#### rate `Number` `1.0`
|
||||
The rate of playback. 0.5 to 4.0, with 1.0 being normal speed.
|
||||
#### pool `Number` `5`
|
||||
The size of the inactive sounds pool. Once sounds are stopped or finish playing, they are marked as ended and ready for cleanup. We keep a pool of these to recycle for improved performance. Generally this doesn't need to be changed. It is important to keep in mind that when a sound is paused, it won't be removed from the pool and will still be considered active so that it can be resumed later.
|
||||
#### format `Array` `[]`
|
||||
howler.js automatically detects your file format from the extension, but you may also specify a format in situations where extraction won't work (such as with a SoundCloud stream).
|
||||
#### xhr `Object` `null`
|
||||
When using Web Audio, howler.js uses an XHR request to load the audio files. If you need to send custom headers, set the HTTP method or enable `withCredentials` ([see reference](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials)), include them with this parameter. Each is optional (method defaults to `GET`, headers default to `null` and withCredentials defaults to `false`). For example:
|
||||
```javascript
|
||||
// Using each of the properties.
|
||||
new Howl({
|
||||
xhr: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer:' + token,
|
||||
},
|
||||
withCredentials: true,
|
||||
}
|
||||
});
|
||||
|
||||
// Only changing the method.
|
||||
new Howl({
|
||||
xhr: {
|
||||
method: 'POST',
|
||||
}
|
||||
});
|
||||
```
|
||||
#### onload `Function`
|
||||
Fires when the sound is loaded.
|
||||
#### onloaderror `Function`
|
||||
Fires when the sound is unable to load. The first parameter is the ID of the sound (if it exists) and the second is the error message/code.
|
||||
|
||||
The load error codes are [defined in the spec](http://dev.w3.org/html5/spec-author-view/spec.html#mediaerror):
|
||||
* **1** - The fetching process for the media resource was aborted by the user agent at the user's request.
|
||||
* **2** - A network error of some description caused the user agent to stop fetching the media resource, after the resource was established to be usable.
|
||||
* **3** - An error of some description occurred while decoding the media resource, after the resource was established to be usable.
|
||||
* **4** - The media resource indicated by the src attribute or assigned media provider object was not suitable.
|
||||
#### onplayerror `Function`
|
||||
Fires when the sound is unable to play. The first parameter is the ID of the sound and the second is the error message/code.
|
||||
#### onplay `Function`
|
||||
Fires when the sound begins playing. The first parameter is the ID of the sound.
|
||||
#### onend `Function`
|
||||
Fires when the sound finishes playing (if it is looping, it'll fire at the end of each loop). The first parameter is the ID of the sound.
|
||||
#### onpause `Function`
|
||||
Fires when the sound has been paused. The first parameter is the ID of the sound.
|
||||
#### onstop `Function`
|
||||
Fires when the sound has been stopped. The first parameter is the ID of the sound.
|
||||
#### onmute `Function`
|
||||
Fires when the sound has been muted/unmuted. The first parameter is the ID of the sound.
|
||||
#### onvolume `Function`
|
||||
Fires when the sound's volume has changed. The first parameter is the ID of the sound.
|
||||
#### onrate `Function`
|
||||
Fires when the sound's playback rate has changed. The first parameter is the ID of the sound.
|
||||
#### onseek `Function`
|
||||
Fires when the sound has been seeked. The first parameter is the ID of the sound.
|
||||
#### onfade `Function`
|
||||
Fires when the current sound finishes fading in/out. The first parameter is the ID of the sound.
|
||||
#### onunlock `Function`
|
||||
Fires when audio has been automatically unlocked through a touch/click event.
|
||||
|
||||
|
||||
### Methods
|
||||
#### play([sprite/id])
|
||||
Begins playback of a sound. Returns the sound id to be used with other methods. Only method that can't be chained.
|
||||
* **sprite/id**: `String/Number` `optional` Takes one parameter that can either be a sprite or sound ID. If a sprite is passed, a new sound will play based on the sprite's definition. If a sound ID is passed, the previously played sound will be played (for example, after pausing it). However, if an ID of a sound that has been drained from the pool is passed, nothing will play.
|
||||
|
||||
#### pause([id])
|
||||
Pauses playback of sound or group, saving the `seek` of playback.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group are paused.
|
||||
|
||||
#### stop([id])
|
||||
Stops playback of sound, resetting `seek` to `0`.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group are stopped.
|
||||
|
||||
#### mute([muted], [id])
|
||||
Mutes the sound, but doesn't pause the playback.
|
||||
* **muted**: `Boolean` `optional` True to mute and false to unmute.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group are stopped.
|
||||
|
||||
#### volume([volume], [id])
|
||||
Get/set volume of this sound or the group. This method optionally takes 0, 1 or 2 arguments.
|
||||
* **volume**: `Number` `optional` Volume from `0.0` to `1.0`.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group have volume altered relative to their own volume.
|
||||
|
||||
#### fade(from, to, duration, [id])
|
||||
Fade a currently playing sound between two volumes. Fires the `fade` event when complete.
|
||||
* **from**: `Number` Volume to fade from (`0.0` to `1.0`).
|
||||
* **to**: `Number` Volume to fade to (`0.0` to `1.0`).
|
||||
* **duration**: `Number` Time in milliseconds to fade.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group will fade.
|
||||
|
||||
#### rate([rate], [id])
|
||||
Get/set the rate of playback for a sound. This method optionally takes 0, 1 or 2 arguments.
|
||||
* **rate**: `Number` `optional` The rate of playback. 0.5 to 4.0, with 1.0 being normal speed.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, playback rate of all sounds in group will change.
|
||||
|
||||
#### seek([seek], [id])
|
||||
Get/set the position of playback for a sound. This method optionally takes 0, 1 or 2 arguments.
|
||||
* **seek**: `Number` `optional` The position to move current playback to (in seconds).
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, the first sound will seek.
|
||||
|
||||
#### loop([loop], [id])
|
||||
Get/set whether to loop the sound or group. This method can optionally take 0, 1 or 2 arguments.
|
||||
* **loop**: `Boolean` `optional` To loop or not to loop, that is the question.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all sounds in group will have their `loop` property updated.
|
||||
|
||||
#### state()
|
||||
Check the load status of the `Howl`, returns a `unloaded`, `loading` or `loaded`.
|
||||
|
||||
#### playing([id])
|
||||
Check if a sound is currently playing or not, returns a `Boolean`. If no sound ID is passed, check if any sound in the `Howl` group is playing.
|
||||
* **id**: `Number` `optional` The sound ID to check.
|
||||
|
||||
#### duration([id])
|
||||
Get the duration of the audio source (in seconds). Will return 0 until after the `load` event fires.
|
||||
* **id**: `Number` `optional` The sound ID to check. Passing an ID will return the duration of the sprite being played on this instance; otherwise, the full source duration is returned.
|
||||
|
||||
#### on(event, function, [id])
|
||||
Listen for events. Multiple events can be added by calling this multiple times.
|
||||
* **event**: `String` Name of event to fire/set (`load`, `loaderror`, `playerror`, `play`, `end`, `pause`, `stop`, `mute`, `volume`, `rate`, `seek`, `fade`, `unlock`).
|
||||
* **function**: `Function` Define function to fire on event.
|
||||
* **id**: `Number` `optional` Only listen to events for this sound id.
|
||||
|
||||
#### once(event, function, [id])
|
||||
Same as `on`, but it removes itself after the callback is fired.
|
||||
* **event**: `String` Name of event to fire/set (`load`, `loaderror`, `playerror`, `play`, `end`, `pause`, `stop`, `mute`, `volume`, `rate`, `seek`, `fade`, `unlock`).
|
||||
* **function**: `Function` Define function to fire on event.
|
||||
* **id**: `Number` `optional` Only listen to events for this sound id.
|
||||
|
||||
#### off(event, [function], [id])
|
||||
Remove event listener that you've set. Call without parameters to remove all events.
|
||||
* **event**: `String` Name of event (`load`, `loaderror`, `playerror`, `play`, `end`, `pause`, `stop`, `mute`, `volume`, `rate`, `seek`, `fade`, `unlock`).
|
||||
* **function**: `Function` `optional` The listener to remove. Omit this to remove all events of type.
|
||||
* **id**: `Number` `optional` Only remove events for this sound id.
|
||||
|
||||
#### load()
|
||||
This is called by default, but if you set `preload` to false, you must call `load` before you can play any sounds.
|
||||
|
||||
#### unload()
|
||||
Unload and destroy a Howl object. This will immediately stop all sounds attached to this sound and remove it from the cache.
|
||||
|
||||
|
||||
### Global Options
|
||||
#### usingWebAudio `Boolean`
|
||||
`true` if the Web Audio API is available.
|
||||
#### noAudio `Boolean`
|
||||
`true` if no audio is available.
|
||||
#### autoUnlock `Boolean` `true`
|
||||
Automatically attempts to enable audio on mobile (iOS, Android, etc) devices and desktop Chrome/Safari.
|
||||
#### html5PoolSize `Number` `10`
|
||||
Each HTML5 Audio object must be unlocked individually, so we keep a global pool of unlocked nodes to share between all `Howl` instances. This pool gets created on the first user interaction and is set to the size of this property.
|
||||
#### autoSuspend `Boolean` `true`
|
||||
Automatically suspends the Web Audio AudioContext after 30 seconds of inactivity to decrease processing and energy usage. Automatically resumes upon new playback. Set this property to `false` to disable this behavior.
|
||||
#### ctx `Boolean` *`Web Audio Only`*
|
||||
Exposes the `AudioContext` with Web Audio API.
|
||||
#### masterGain `Boolean` *`Web Audio Only`*
|
||||
Exposes the master `GainNode` with Web Audio API. This can be useful for writing plugins or advanced usage.
|
||||
|
||||
|
||||
### Global Methods
|
||||
The following methods are used to modify all sounds globally, and are called from the `Howler` object.
|
||||
#### mute(muted)
|
||||
Mute or unmute all sounds.
|
||||
* **muted**: `Boolean` True to mute and false to unmute.
|
||||
|
||||
#### volume([volume])
|
||||
Get/set the global volume for all sounds, relative to their own volume.
|
||||
* **volume**: `Number` `optional` Volume from `0.0` to `1.0`.
|
||||
|
||||
#### stop()
|
||||
Stop all sounds and reset their seek position to the beginning.
|
||||
|
||||
#### codecs(ext)
|
||||
Check supported audio codecs. Returns `true` if the codec is supported in the current browser.
|
||||
* **ext**: `String` File extension. One of: "mp3", "mpeg", "opus", "ogg", "oga", "wav", "aac", "caf", "m4a", "m4b", "mp4", "weba", "webm", "dolby", "flac".
|
||||
|
||||
#### unload()
|
||||
Unload and destroy all currently loaded Howl objects. This will immediately stop all sounds and remove them from cache.
|
||||
|
||||
|
||||
## Plugin: Spatial
|
||||
|
||||
### Options
|
||||
#### orientation `Array` `[1, 0, 0]`
|
||||
Sets the direction the audio source is pointing in the 3D cartesian coordinate space. Depending on how directional the sound is, based on the `cone` attributes, a sound pointing away from the listener can be quiet or silent.
|
||||
#### stereo `Number` `null`
|
||||
Sets the stereo panning value of the audio source for this sound or group. This makes it easy to setup left/right panning with a value of `-1.0` being far left and a value of `1.0` being far right.
|
||||
#### pos `Array` `null`
|
||||
Sets the 3D spatial position of the audio source for this sound or group relative to the global listener.
|
||||
#### pannerAttr `Object`
|
||||
Sets the panner node's attributes for a sound or group of sounds. See the `pannerAttr` method for all available options.
|
||||
#### onstereo `Function`
|
||||
Fires when the current sound has the stereo panning changed. The first parameter is the ID of the sound.
|
||||
#### onpos `Function`
|
||||
Fires when the current sound has the listener position changed. The first parameter is the ID of the sound.
|
||||
#### onorientation `Function`
|
||||
Fires when the current sound has the direction of the listener changed. The first parameter is the ID of the sound.
|
||||
|
||||
|
||||
### Methods
|
||||
#### stereo(pan, [id])
|
||||
Get/set the stereo panning of the audio source for this sound or all in the group.
|
||||
* **pan**: `Number` A value of `-1.0` is all the way left and `1.0` is all the way right.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all in group will be updated.
|
||||
|
||||
#### pos(x, y, z, [id])
|
||||
Get/set the 3D spatial position of the audio source for this sound or group relative to the global listener.
|
||||
* **x**: `Number` The x-position of the audio source.
|
||||
* **y**: `Number` The y-position of the audio source.
|
||||
* **z**: `Number` The z-position of the audio source.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all in group will be updated.
|
||||
|
||||
#### orientation(x, y, z, [id])
|
||||
Get/set the direction the audio source is pointing in the 3D cartesian coordinate space. Depending on how directional the sound is, based on the `cone` attributes, a sound pointing away from the listener can be quiet or silent.
|
||||
* **x**: `Number` The x-orientation of the source.
|
||||
* **y**: `Number` The y-orientation of the source.
|
||||
* **z**: `Number` The z-orientation of the source.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all in group will be updated.
|
||||
|
||||
#### pannerAttr(o, [id])
|
||||
Get/set the panner node's attributes for a sound or group of sounds.
|
||||
* **o**: `Object` All values to update.
|
||||
* **coneInnerAngle** `360` A parameter for directional audio sources, this is an angle, in degrees, inside of which there will be no volume reduction.
|
||||
* **coneOuterAngle** `360` A parameter for directional audio sources, this is an angle, in degrees, outside of which the volume will be reduced to a constant value of `coneOuterGain`.
|
||||
* **coneOuterGain** `0` A parameter for directional audio sources, this is the gain outside of the `coneOuterAngle`. It is a linear value in the range `[0, 1]`.
|
||||
* **distanceModel** `inverse` Determines algorithm used to reduce volume as audio moves away from listener. Can be `linear`, `inverse` or `exponential`. You can find the implementations of each in the [spec](https://webaudio.github.io/web-audio-api/#idl-def-DistanceModelType).
|
||||
* **maxDistance** `10000` The maximum distance between source and listener, after which the volume will not be reduced any further.
|
||||
* **refDistance** `1` A reference distance for reducing volume as source moves further from the listener. This is simply a variable of the distance model and has a different effect depending on which model is used and the scale of your coordinates. Generally, volume will be equal to 1 at this distance.
|
||||
* **rolloffFactor** `1` How quickly the volume reduces as source moves from listener. This is simply a variable of the distance model and can be in the range of `[0, 1]` with `linear` and `[0, ∞]` with `inverse` and `exponential`.
|
||||
* **panningModel** `HRTF` Determines which spatialization algorithm is used to position audio. Can be `HRTF` or `equalpower`.
|
||||
* **id**: `Number` `optional` The sound ID. If none is passed, all in group will be updated.
|
||||
|
||||
|
||||
### Global Methods
|
||||
#### stereo(pan)
|
||||
Helper method to update the stereo panning position of all current `Howls`. Future `Howls` will not use this value unless explicitly set.
|
||||
* **pan**: `Number` A value of -1.0 is all the way left and 1.0 is all the way right.
|
||||
|
||||
#### pos(x, y, z)
|
||||
Get/set the position of the listener in 3D cartesian space. Sounds using 3D position will be relative to the listener's position.
|
||||
* **x**: `Number` The x-position of the listener.
|
||||
* **y**: `Number` The y-position of the listener.
|
||||
* **z**: `Number` The z-position of the listener.
|
||||
|
||||
#### orientation(x, y, z, xUp, yUp, zUp)
|
||||
Get/set the direction the listener is pointing in the 3D cartesian space. A front and up vector must be provided. The front is the direction the face of the listener is pointing, and up is the direction the top of the listener is pointing. Thus, these values are expected to be at right angles from each other.
|
||||
* **x**: `Number` The x-orientation of listener.
|
||||
* **y**: `Number` The y-orientation of listener.
|
||||
* **z**: `Number` The z-orientation of listener.
|
||||
* **xUp**: `Number` The x-orientation of the top of the listener.
|
||||
* **yUp**: `Number` The y-orientation of the top of the listener.
|
||||
* **zUp**: `Number` The z-orientation of the top of the listener.
|
||||
|
||||
|
||||
### Group Playback
|
||||
Each `new Howl()` instance is also a group. You can play multiple sound instances from the `Howl` and control them individually or as a group (note: each `Howl` can only contain a single audio file). For example, the following plays two sounds from a sprite, changes their volume together and then pauses both of them at the same time.
|
||||
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3'],
|
||||
sprite: {
|
||||
track01: [0, 20000],
|
||||
track02: [21000, 41000]
|
||||
}
|
||||
});
|
||||
|
||||
// Play each of the track.s
|
||||
sound.play('track01');
|
||||
sound.play('track02');
|
||||
|
||||
// Change the volume of both tracks.
|
||||
sound.volume(0.5);
|
||||
|
||||
// After a second, pause both sounds in the group.
|
||||
setTimeout(function() {
|
||||
sound.pause();
|
||||
}, 1000);
|
||||
```
|
||||
|
||||
|
||||
### Mobile/Chrome Playback
|
||||
By default, audio on mobile browsers and Chrome/Safari is locked until a sound is played within a user interaction, and then it plays normally the rest of the page session ([Apple documentation](https://developer.apple.com/library/safari/documentation/audiovideo/conceptual/using_html5_audio_video/PlayingandSynthesizingSounds/PlayingandSynthesizingSounds.html)). The default behavior of howler.js is to attempt to silently unlock audio playback by playing an empty buffer on the first `touchend` event. This behavior can be disabled by calling:
|
||||
|
||||
```javascript
|
||||
Howler.autoUnlock = false;
|
||||
```
|
||||
|
||||
If you try to play audio automatically on page load, you can listen to a `playerror` event and then wait for the `unlock` event to try and play the audio again:
|
||||
|
||||
```javascript
|
||||
var sound = new Howl({
|
||||
src: ['sound.webm', 'sound.mp3'],
|
||||
onplayerror: function() {
|
||||
sound.once('unlock', function() {
|
||||
sound.play();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sound.play();
|
||||
```
|
||||
|
||||
|
||||
### Dolby Audio Playback
|
||||
Full support for playback of the Dolby Audio format (currently support in Edge and Safari) is included. However, you must specify that the file you are loading is `dolby` since it is in a `mp4` container.
|
||||
|
||||
```javascript
|
||||
var dolbySound = new Howl({
|
||||
src: ['sound.mp4', 'sound.webm', 'sound.mp3'],
|
||||
format: ['dolby', 'webm', 'mp3']
|
||||
});
|
||||
```
|
||||
|
||||
### Facebook Instant Games
|
||||
Howler.js provides audio support for the new [Facebook Instant Games](https://developers.facebook.com/docs/games/instant-games/engine-recommendations) platform. If you encounter any issues while developing for Instant Games, open an issue with the tag `[IG]`.
|
||||
|
||||
### Format Recommendations
|
||||
Howler.js supports a wide array of audio codecs that have varying browser support ("mp3", "opus", "ogg", "wav", "aac", "m4a", "m4b", "mp4", "webm", ...), but if you want full browser coverage you still need to use at least two of them. If your goal is to have the best balance of small filesize and high quality, based on extensive production testing, your best bet is to default to `webm` and fallback to `mp3`. `webm` has nearly full browser coverage with a great combination of compression and quality. You'll need the `mp3` fallback for Internet Explorer.
|
||||
|
||||
It is important to remember that howler.js selects the first compatible sound from your array of sources. So if you want `webm` to be used before `mp3`, you need to put the sources in that order.
|
||||
|
||||
If you want your `webm` files to be seekable in Firefox, be sure to encode them with the cues element. One way to do this is by using the `dash` flag in [ffmpeg](https://www.ffmpeg.org/):
|
||||
|
||||
```
|
||||
ffmpeg -i sound1.wav -dash 1 sound1.webm
|
||||
```
|
||||
|
||||
### Sponsors
|
||||
Support the ongoing development of howler.js and get your logo on our README with a link to your site [[become a sponsor](https://github.com/sponsors/goldfire)]. You can also become a backer at a lower tier and get your name in the [BACKERS](https://github.com/goldfire/howler.js/blob/master/BACKERS.md) list. All support is greatly appreciated!
|
||||
|
||||
[](https://goldfirestudios.com)
|
||||
|
||||
### License
|
||||
|
||||
Copyright (c) 2013-2021 [James Simpson](https://twitter.com/GoldFireStudios) and [GoldFire Studios, Inc.](http://goldfirestudios.com)
|
||||
|
||||
Released under the [MIT License](https://github.com/goldfire/howler.js/blob/master/LICENSE.md).
|
File diff suppressed because one or more lines are too long
3248
public/scripts/howler/dist/howler.js
vendored
3248
public/scripts/howler/dist/howler.js
vendored
File diff suppressed because it is too large
Load Diff
4
public/scripts/howler/dist/howler.min.js
vendored
4
public/scripts/howler/dist/howler.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "howler",
|
||||
"version": "2.2.4",
|
||||
"description": "Javascript audio library for the modern web.",
|
||||
"homepage": "https://howlerjs.com",
|
||||
"keywords": [
|
||||
"howler",
|
||||
"howler.js",
|
||||
"audio",
|
||||
"sound",
|
||||
"web audio",
|
||||
"webaudio",
|
||||
"browser",
|
||||
"html5",
|
||||
"html5 audio",
|
||||
"audio sprite",
|
||||
"audiosprite"
|
||||
],
|
||||
"author": "James Simpson <james@goldfirestudios.com> (http://goldfirestudios.com)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/goldfire/howler.js.git"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "VERSION=`printf 'v' && node -e 'console.log(require(\"./package.json\").version)'` && sed -i '' '2s/.*/ * howler.js '\"$VERSION\"'/' src/howler.core.js && sed -i '' '4s/.*/ * howler.js '\"$VERSION\"'/' src/plugins/howler.spatial.js && uglifyjs --preamble \"/*! howler.js $VERSION | (c) 2013-2020, James Simpson of GoldFire Studios | MIT License | howlerjs.com */\" src/howler.core.js -c -m --screw-ie8 -o dist/howler.core.min.js && uglifyjs --preamble \"/*! howler.js $VERSION | Spatial Plugin | (c) 2013-2020, James Simpson of GoldFire Studios | MIT License | howlerjs.com */\" src/plugins/howler.spatial.js -c -m --screw-ie8 -o dist/howler.spatial.min.js && awk 'FNR==1{echo \"\"}1' dist/howler.core.min.js dist/howler.spatial.min.js | sed '3s~.*~/*! Spatial Plugin */~' | perl -pe 'chomp if eof' > dist/howler.min.js && awk '(NR>1 && FNR==1){printf (\"\\n\\n\")};1' src/howler.core.js src/plugins/howler.spatial.js > dist/howler.js",
|
||||
"release": "VERSION=`printf 'v' && node -e 'console.log(require(\"./package.json\").version)'` && git tag $VERSION && git push && git push origin $VERSION && npm publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"uglify-js": "2.x"
|
||||
},
|
||||
"main": "dist/howler.js",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src",
|
||||
"dist/howler.js",
|
||||
"dist/howler.min.js",
|
||||
"dist/howler.core.min.js",
|
||||
"dist/howler.spatial.min.js",
|
||||
"LICENSE.md"
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -1,659 +0,0 @@
|
||||
/*!
|
||||
* Spatial Plugin - Adds support for stereo and 3D audio where Web Audio is supported.
|
||||
*
|
||||
* howler.js v2.2.4
|
||||
* howlerjs.com
|
||||
*
|
||||
* (c) 2013-2020, James Simpson of GoldFire Studios
|
||||
* goldfirestudios.com
|
||||
*
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
'use strict';
|
||||
|
||||
// Setup default properties.
|
||||
HowlerGlobal.prototype._pos = [0, 0, 0];
|
||||
HowlerGlobal.prototype._orientation = [0, 0, -1, 0, 1, 0];
|
||||
|
||||
/** Global Methods **/
|
||||
/***************************************************************************/
|
||||
|
||||
/**
|
||||
* Helper method to update the stereo panning position of all current Howls.
|
||||
* Future Howls will not use this value unless explicitly set.
|
||||
* @param {Number} pan A value of -1.0 is all the way left and 1.0 is all the way right.
|
||||
* @return {Howler/Number} Self or current stereo panning value.
|
||||
*/
|
||||
HowlerGlobal.prototype.stereo = function(pan) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self.ctx || !self.ctx.listener) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// Loop through all Howls and update their stereo panning.
|
||||
for (var i=self._howls.length-1; i>=0; i--) {
|
||||
self._howls[i].stereo(pan);
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get/set the position of the listener in 3D cartesian space. Sounds using
|
||||
* 3D position will be relative to the listener's position.
|
||||
* @param {Number} x The x-position of the listener.
|
||||
* @param {Number} y The y-position of the listener.
|
||||
* @param {Number} z The z-position of the listener.
|
||||
* @return {Howler/Array} Self or current listener position.
|
||||
*/
|
||||
HowlerGlobal.prototype.pos = function(x, y, z) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self.ctx || !self.ctx.listener) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// Set the defaults for optional 'y' & 'z'.
|
||||
y = (typeof y !== 'number') ? self._pos[1] : y;
|
||||
z = (typeof z !== 'number') ? self._pos[2] : z;
|
||||
|
||||
if (typeof x === 'number') {
|
||||
self._pos = [x, y, z];
|
||||
|
||||
if (typeof self.ctx.listener.positionX !== 'undefined') {
|
||||
self.ctx.listener.positionX.setTargetAtTime(self._pos[0], Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.positionY.setTargetAtTime(self._pos[1], Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.positionZ.setTargetAtTime(self._pos[2], Howler.ctx.currentTime, 0.1);
|
||||
} else {
|
||||
self.ctx.listener.setPosition(self._pos[0], self._pos[1], self._pos[2]);
|
||||
}
|
||||
} else {
|
||||
return self._pos;
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get/set the direction the listener is pointing in the 3D cartesian space.
|
||||
* A front and up vector must be provided. The front is the direction the
|
||||
* face of the listener is pointing, and up is the direction the top of the
|
||||
* listener is pointing. Thus, these values are expected to be at right angles
|
||||
* from each other.
|
||||
* @param {Number} x The x-orientation of the listener.
|
||||
* @param {Number} y The y-orientation of the listener.
|
||||
* @param {Number} z The z-orientation of the listener.
|
||||
* @param {Number} xUp The x-orientation of the top of the listener.
|
||||
* @param {Number} yUp The y-orientation of the top of the listener.
|
||||
* @param {Number} zUp The z-orientation of the top of the listener.
|
||||
* @return {Howler/Array} Returns self or the current orientation vectors.
|
||||
*/
|
||||
HowlerGlobal.prototype.orientation = function(x, y, z, xUp, yUp, zUp) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self.ctx || !self.ctx.listener) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// Set the defaults for optional 'y' & 'z'.
|
||||
var or = self._orientation;
|
||||
y = (typeof y !== 'number') ? or[1] : y;
|
||||
z = (typeof z !== 'number') ? or[2] : z;
|
||||
xUp = (typeof xUp !== 'number') ? or[3] : xUp;
|
||||
yUp = (typeof yUp !== 'number') ? or[4] : yUp;
|
||||
zUp = (typeof zUp !== 'number') ? or[5] : zUp;
|
||||
|
||||
if (typeof x === 'number') {
|
||||
self._orientation = [x, y, z, xUp, yUp, zUp];
|
||||
|
||||
if (typeof self.ctx.listener.forwardX !== 'undefined') {
|
||||
self.ctx.listener.forwardX.setTargetAtTime(x, Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.forwardY.setTargetAtTime(y, Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.forwardZ.setTargetAtTime(z, Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.upX.setTargetAtTime(xUp, Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.upY.setTargetAtTime(yUp, Howler.ctx.currentTime, 0.1);
|
||||
self.ctx.listener.upZ.setTargetAtTime(zUp, Howler.ctx.currentTime, 0.1);
|
||||
} else {
|
||||
self.ctx.listener.setOrientation(x, y, z, xUp, yUp, zUp);
|
||||
}
|
||||
} else {
|
||||
return or;
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/** Group Methods **/
|
||||
/***************************************************************************/
|
||||
|
||||
/**
|
||||
* Add new properties to the core init.
|
||||
* @param {Function} _super Core init method.
|
||||
* @return {Howl}
|
||||
*/
|
||||
Howl.prototype.init = (function(_super) {
|
||||
return function(o) {
|
||||
var self = this;
|
||||
|
||||
// Setup user-defined default properties.
|
||||
self._orientation = o.orientation || [1, 0, 0];
|
||||
self._stereo = o.stereo || null;
|
||||
self._pos = o.pos || null;
|
||||
self._pannerAttr = {
|
||||
coneInnerAngle: typeof o.coneInnerAngle !== 'undefined' ? o.coneInnerAngle : 360,
|
||||
coneOuterAngle: typeof o.coneOuterAngle !== 'undefined' ? o.coneOuterAngle : 360,
|
||||
coneOuterGain: typeof o.coneOuterGain !== 'undefined' ? o.coneOuterGain : 0,
|
||||
distanceModel: typeof o.distanceModel !== 'undefined' ? o.distanceModel : 'inverse',
|
||||
maxDistance: typeof o.maxDistance !== 'undefined' ? o.maxDistance : 10000,
|
||||
panningModel: typeof o.panningModel !== 'undefined' ? o.panningModel : 'HRTF',
|
||||
refDistance: typeof o.refDistance !== 'undefined' ? o.refDistance : 1,
|
||||
rolloffFactor: typeof o.rolloffFactor !== 'undefined' ? o.rolloffFactor : 1
|
||||
};
|
||||
|
||||
// Setup event listeners.
|
||||
self._onstereo = o.onstereo ? [{fn: o.onstereo}] : [];
|
||||
self._onpos = o.onpos ? [{fn: o.onpos}] : [];
|
||||
self._onorientation = o.onorientation ? [{fn: o.onorientation}] : [];
|
||||
|
||||
// Complete initilization with howler.js core's init function.
|
||||
return _super.call(this, o);
|
||||
};
|
||||
})(Howl.prototype.init);
|
||||
|
||||
/**
|
||||
* Get/set the stereo panning of the audio source for this sound or all in the group.
|
||||
* @param {Number} pan A value of -1.0 is all the way left and 1.0 is all the way right.
|
||||
* @param {Number} id (optional) The sound ID. If none is passed, all in group will be updated.
|
||||
* @return {Howl/Number} Returns self or the current stereo panning value.
|
||||
*/
|
||||
Howl.prototype.stereo = function(pan, id) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self._webAudio) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// If the sound hasn't loaded, add it to the load queue to change stereo pan when capable.
|
||||
if (self._state !== 'loaded') {
|
||||
self._queue.push({
|
||||
event: 'stereo',
|
||||
action: function() {
|
||||
self.stereo(pan, id);
|
||||
}
|
||||
});
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// Check for PannerStereoNode support and fallback to PannerNode if it doesn't exist.
|
||||
var pannerType = (typeof Howler.ctx.createStereoPanner === 'undefined') ? 'spatial' : 'stereo';
|
||||
|
||||
// Setup the group's stereo panning if no ID is passed.
|
||||
if (typeof id === 'undefined') {
|
||||
// Return the group's stereo panning if no parameters are passed.
|
||||
if (typeof pan === 'number') {
|
||||
self._stereo = pan;
|
||||
self._pos = [pan, 0, 0];
|
||||
} else {
|
||||
return self._stereo;
|
||||
}
|
||||
}
|
||||
|
||||
// Change the streo panning of one or all sounds in group.
|
||||
var ids = self._getSoundIds(id);
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
// Get the sound.
|
||||
var sound = self._soundById(ids[i]);
|
||||
|
||||
if (sound) {
|
||||
if (typeof pan === 'number') {
|
||||
sound._stereo = pan;
|
||||
sound._pos = [pan, 0, 0];
|
||||
|
||||
if (sound._node) {
|
||||
// If we are falling back, make sure the panningModel is equalpower.
|
||||
sound._pannerAttr.panningModel = 'equalpower';
|
||||
|
||||
// Check if there is a panner setup and create a new one if not.
|
||||
if (!sound._panner || !sound._panner.pan) {
|
||||
setupPanner(sound, pannerType);
|
||||
}
|
||||
|
||||
if (pannerType === 'spatial') {
|
||||
if (typeof sound._panner.positionX !== 'undefined') {
|
||||
sound._panner.positionX.setValueAtTime(pan, Howler.ctx.currentTime);
|
||||
sound._panner.positionY.setValueAtTime(0, Howler.ctx.currentTime);
|
||||
sound._panner.positionZ.setValueAtTime(0, Howler.ctx.currentTime);
|
||||
} else {
|
||||
sound._panner.setPosition(pan, 0, 0);
|
||||
}
|
||||
} else {
|
||||
sound._panner.pan.setValueAtTime(pan, Howler.ctx.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
self._emit('stereo', sound._id);
|
||||
} else {
|
||||
return sound._stereo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get/set the 3D spatial position of the audio source for this sound or group relative to the global listener.
|
||||
* @param {Number} x The x-position of the audio source.
|
||||
* @param {Number} y The y-position of the audio source.
|
||||
* @param {Number} z The z-position of the audio source.
|
||||
* @param {Number} id (optional) The sound ID. If none is passed, all in group will be updated.
|
||||
* @return {Howl/Array} Returns self or the current 3D spatial position: [x, y, z].
|
||||
*/
|
||||
Howl.prototype.pos = function(x, y, z, id) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self._webAudio) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// If the sound hasn't loaded, add it to the load queue to change position when capable.
|
||||
if (self._state !== 'loaded') {
|
||||
self._queue.push({
|
||||
event: 'pos',
|
||||
action: function() {
|
||||
self.pos(x, y, z, id);
|
||||
}
|
||||
});
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// Set the defaults for optional 'y' & 'z'.
|
||||
y = (typeof y !== 'number') ? 0 : y;
|
||||
z = (typeof z !== 'number') ? -0.5 : z;
|
||||
|
||||
// Setup the group's spatial position if no ID is passed.
|
||||
if (typeof id === 'undefined') {
|
||||
// Return the group's spatial position if no parameters are passed.
|
||||
if (typeof x === 'number') {
|
||||
self._pos = [x, y, z];
|
||||
} else {
|
||||
return self._pos;
|
||||
}
|
||||
}
|
||||
|
||||
// Change the spatial position of one or all sounds in group.
|
||||
var ids = self._getSoundIds(id);
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
// Get the sound.
|
||||
var sound = self._soundById(ids[i]);
|
||||
|
||||
if (sound) {
|
||||
if (typeof x === 'number') {
|
||||
sound._pos = [x, y, z];
|
||||
|
||||
if (sound._node) {
|
||||
// Check if there is a panner setup and create a new one if not.
|
||||
if (!sound._panner || sound._panner.pan) {
|
||||
setupPanner(sound, 'spatial');
|
||||
}
|
||||
|
||||
if (typeof sound._panner.positionX !== 'undefined') {
|
||||
sound._panner.positionX.setValueAtTime(x, Howler.ctx.currentTime);
|
||||
sound._panner.positionY.setValueAtTime(y, Howler.ctx.currentTime);
|
||||
sound._panner.positionZ.setValueAtTime(z, Howler.ctx.currentTime);
|
||||
} else {
|
||||
sound._panner.setPosition(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
self._emit('pos', sound._id);
|
||||
} else {
|
||||
return sound._pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get/set the direction the audio source is pointing in the 3D cartesian coordinate
|
||||
* space. Depending on how direction the sound is, based on the `cone` attributes,
|
||||
* a sound pointing away from the listener can be quiet or silent.
|
||||
* @param {Number} x The x-orientation of the source.
|
||||
* @param {Number} y The y-orientation of the source.
|
||||
* @param {Number} z The z-orientation of the source.
|
||||
* @param {Number} id (optional) The sound ID. If none is passed, all in group will be updated.
|
||||
* @return {Howl/Array} Returns self or the current 3D spatial orientation: [x, y, z].
|
||||
*/
|
||||
Howl.prototype.orientation = function(x, y, z, id) {
|
||||
var self = this;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self._webAudio) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// If the sound hasn't loaded, add it to the load queue to change orientation when capable.
|
||||
if (self._state !== 'loaded') {
|
||||
self._queue.push({
|
||||
event: 'orientation',
|
||||
action: function() {
|
||||
self.orientation(x, y, z, id);
|
||||
}
|
||||
});
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// Set the defaults for optional 'y' & 'z'.
|
||||
y = (typeof y !== 'number') ? self._orientation[1] : y;
|
||||
z = (typeof z !== 'number') ? self._orientation[2] : z;
|
||||
|
||||
// Setup the group's spatial orientation if no ID is passed.
|
||||
if (typeof id === 'undefined') {
|
||||
// Return the group's spatial orientation if no parameters are passed.
|
||||
if (typeof x === 'number') {
|
||||
self._orientation = [x, y, z];
|
||||
} else {
|
||||
return self._orientation;
|
||||
}
|
||||
}
|
||||
|
||||
// Change the spatial orientation of one or all sounds in group.
|
||||
var ids = self._getSoundIds(id);
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
// Get the sound.
|
||||
var sound = self._soundById(ids[i]);
|
||||
|
||||
if (sound) {
|
||||
if (typeof x === 'number') {
|
||||
sound._orientation = [x, y, z];
|
||||
|
||||
if (sound._node) {
|
||||
// Check if there is a panner setup and create a new one if not.
|
||||
if (!sound._panner) {
|
||||
// Make sure we have a position to setup the node with.
|
||||
if (!sound._pos) {
|
||||
sound._pos = self._pos || [0, 0, -0.5];
|
||||
}
|
||||
|
||||
setupPanner(sound, 'spatial');
|
||||
}
|
||||
|
||||
if (typeof sound._panner.orientationX !== 'undefined') {
|
||||
sound._panner.orientationX.setValueAtTime(x, Howler.ctx.currentTime);
|
||||
sound._panner.orientationY.setValueAtTime(y, Howler.ctx.currentTime);
|
||||
sound._panner.orientationZ.setValueAtTime(z, Howler.ctx.currentTime);
|
||||
} else {
|
||||
sound._panner.setOrientation(x, y, z);
|
||||
}
|
||||
}
|
||||
|
||||
self._emit('orientation', sound._id);
|
||||
} else {
|
||||
return sound._orientation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get/set the panner node's attributes for a sound or group of sounds.
|
||||
* This method can optionall take 0, 1 or 2 arguments.
|
||||
* pannerAttr() -> Returns the group's values.
|
||||
* pannerAttr(id) -> Returns the sound id's values.
|
||||
* pannerAttr(o) -> Set's the values of all sounds in this Howl group.
|
||||
* pannerAttr(o, id) -> Set's the values of passed sound id.
|
||||
*
|
||||
* Attributes:
|
||||
* coneInnerAngle - (360 by default) A parameter for directional audio sources, this is an angle, in degrees,
|
||||
* inside of which there will be no volume reduction.
|
||||
* coneOuterAngle - (360 by default) A parameter for directional audio sources, this is an angle, in degrees,
|
||||
* outside of which the volume will be reduced to a constant value of `coneOuterGain`.
|
||||
* coneOuterGain - (0 by default) A parameter for directional audio sources, this is the gain outside of the
|
||||
* `coneOuterAngle`. It is a linear value in the range `[0, 1]`.
|
||||
* distanceModel - ('inverse' by default) Determines algorithm used to reduce volume as audio moves away from
|
||||
* listener. Can be `linear`, `inverse` or `exponential.
|
||||
* maxDistance - (10000 by default) The maximum distance between source and listener, after which the volume
|
||||
* will not be reduced any further.
|
||||
* refDistance - (1 by default) A reference distance for reducing volume as source moves further from the listener.
|
||||
* This is simply a variable of the distance model and has a different effect depending on which model
|
||||
* is used and the scale of your coordinates. Generally, volume will be equal to 1 at this distance.
|
||||
* rolloffFactor - (1 by default) How quickly the volume reduces as source moves from listener. This is simply a
|
||||
* variable of the distance model and can be in the range of `[0, 1]` with `linear` and `[0, ∞]`
|
||||
* with `inverse` and `exponential`.
|
||||
* panningModel - ('HRTF' by default) Determines which spatialization algorithm is used to position audio.
|
||||
* Can be `HRTF` or `equalpower`.
|
||||
*
|
||||
* @return {Howl/Object} Returns self or current panner attributes.
|
||||
*/
|
||||
Howl.prototype.pannerAttr = function() {
|
||||
var self = this;
|
||||
var args = arguments;
|
||||
var o, id, sound;
|
||||
|
||||
// Stop right here if not using Web Audio.
|
||||
if (!self._webAudio) {
|
||||
return self;
|
||||
}
|
||||
|
||||
// Determine the values based on arguments.
|
||||
if (args.length === 0) {
|
||||
// Return the group's panner attribute values.
|
||||
return self._pannerAttr;
|
||||
} else if (args.length === 1) {
|
||||
if (typeof args[0] === 'object') {
|
||||
o = args[0];
|
||||
|
||||
// Set the grou's panner attribute values.
|
||||
if (typeof id === 'undefined') {
|
||||
if (!o.pannerAttr) {
|
||||
o.pannerAttr = {
|
||||
coneInnerAngle: o.coneInnerAngle,
|
||||
coneOuterAngle: o.coneOuterAngle,
|
||||
coneOuterGain: o.coneOuterGain,
|
||||
distanceModel: o.distanceModel,
|
||||
maxDistance: o.maxDistance,
|
||||
refDistance: o.refDistance,
|
||||
rolloffFactor: o.rolloffFactor,
|
||||
panningModel: o.panningModel
|
||||
};
|
||||
}
|
||||
|
||||
self._pannerAttr = {
|
||||
coneInnerAngle: typeof o.pannerAttr.coneInnerAngle !== 'undefined' ? o.pannerAttr.coneInnerAngle : self._coneInnerAngle,
|
||||
coneOuterAngle: typeof o.pannerAttr.coneOuterAngle !== 'undefined' ? o.pannerAttr.coneOuterAngle : self._coneOuterAngle,
|
||||
coneOuterGain: typeof o.pannerAttr.coneOuterGain !== 'undefined' ? o.pannerAttr.coneOuterGain : self._coneOuterGain,
|
||||
distanceModel: typeof o.pannerAttr.distanceModel !== 'undefined' ? o.pannerAttr.distanceModel : self._distanceModel,
|
||||
maxDistance: typeof o.pannerAttr.maxDistance !== 'undefined' ? o.pannerAttr.maxDistance : self._maxDistance,
|
||||
refDistance: typeof o.pannerAttr.refDistance !== 'undefined' ? o.pannerAttr.refDistance : self._refDistance,
|
||||
rolloffFactor: typeof o.pannerAttr.rolloffFactor !== 'undefined' ? o.pannerAttr.rolloffFactor : self._rolloffFactor,
|
||||
panningModel: typeof o.pannerAttr.panningModel !== 'undefined' ? o.pannerAttr.panningModel : self._panningModel
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Return this sound's panner attribute values.
|
||||
sound = self._soundById(parseInt(args[0], 10));
|
||||
return sound ? sound._pannerAttr : self._pannerAttr;
|
||||
}
|
||||
} else if (args.length === 2) {
|
||||
o = args[0];
|
||||
id = parseInt(args[1], 10);
|
||||
}
|
||||
|
||||
// Update the values of the specified sounds.
|
||||
var ids = self._getSoundIds(id);
|
||||
for (var i=0; i<ids.length; i++) {
|
||||
sound = self._soundById(ids[i]);
|
||||
|
||||
if (sound) {
|
||||
// Merge the new values into the sound.
|
||||
var pa = sound._pannerAttr;
|
||||
pa = {
|
||||
coneInnerAngle: typeof o.coneInnerAngle !== 'undefined' ? o.coneInnerAngle : pa.coneInnerAngle,
|
||||
coneOuterAngle: typeof o.coneOuterAngle !== 'undefined' ? o.coneOuterAngle : pa.coneOuterAngle,
|
||||
coneOuterGain: typeof o.coneOuterGain !== 'undefined' ? o.coneOuterGain : pa.coneOuterGain,
|
||||
distanceModel: typeof o.distanceModel !== 'undefined' ? o.distanceModel : pa.distanceModel,
|
||||
maxDistance: typeof o.maxDistance !== 'undefined' ? o.maxDistance : pa.maxDistance,
|
||||
refDistance: typeof o.refDistance !== 'undefined' ? o.refDistance : pa.refDistance,
|
||||
rolloffFactor: typeof o.rolloffFactor !== 'undefined' ? o.rolloffFactor : pa.rolloffFactor,
|
||||
panningModel: typeof o.panningModel !== 'undefined' ? o.panningModel : pa.panningModel
|
||||
};
|
||||
|
||||
// Create a new panner node if one doesn't already exist.
|
||||
var panner = sound._panner;
|
||||
if (!panner) {
|
||||
// Make sure we have a position to setup the node with.
|
||||
if (!sound._pos) {
|
||||
sound._pos = self._pos || [0, 0, -0.5];
|
||||
}
|
||||
|
||||
// Create a new panner node.
|
||||
setupPanner(sound, 'spatial');
|
||||
panner = sound._panner
|
||||
}
|
||||
|
||||
// Update the panner values or create a new panner if none exists.
|
||||
panner.coneInnerAngle = pa.coneInnerAngle;
|
||||
panner.coneOuterAngle = pa.coneOuterAngle;
|
||||
panner.coneOuterGain = pa.coneOuterGain;
|
||||
panner.distanceModel = pa.distanceModel;
|
||||
panner.maxDistance = pa.maxDistance;
|
||||
panner.refDistance = pa.refDistance;
|
||||
panner.rolloffFactor = pa.rolloffFactor;
|
||||
panner.panningModel = pa.panningModel;
|
||||
}
|
||||
}
|
||||
|
||||
return self;
|
||||
};
|
||||
|
||||
/** Single Sound Methods **/
|
||||
/***************************************************************************/
|
||||
|
||||
/**
|
||||
* Add new properties to the core Sound init.
|
||||
* @param {Function} _super Core Sound init method.
|
||||
* @return {Sound}
|
||||
*/
|
||||
Sound.prototype.init = (function(_super) {
|
||||
return function() {
|
||||
var self = this;
|
||||
var parent = self._parent;
|
||||
|
||||
// Setup user-defined default properties.
|
||||
self._orientation = parent._orientation;
|
||||
self._stereo = parent._stereo;
|
||||
self._pos = parent._pos;
|
||||
self._pannerAttr = parent._pannerAttr;
|
||||
|
||||
// Complete initilization with howler.js core Sound's init function.
|
||||
_super.call(this);
|
||||
|
||||
// If a stereo or position was specified, set it up.
|
||||
if (self._stereo) {
|
||||
parent.stereo(self._stereo);
|
||||
} else if (self._pos) {
|
||||
parent.pos(self._pos[0], self._pos[1], self._pos[2], self._id);
|
||||
}
|
||||
};
|
||||
})(Sound.prototype.init);
|
||||
|
||||
/**
|
||||
* Override the Sound.reset method to clean up properties from the spatial plugin.
|
||||
* @param {Function} _super Sound reset method.
|
||||
* @return {Sound}
|
||||
*/
|
||||
Sound.prototype.reset = (function(_super) {
|
||||
return function() {
|
||||
var self = this;
|
||||
var parent = self._parent;
|
||||
|
||||
// Reset all spatial plugin properties on this sound.
|
||||
self._orientation = parent._orientation;
|
||||
self._stereo = parent._stereo;
|
||||
self._pos = parent._pos;
|
||||
self._pannerAttr = parent._pannerAttr;
|
||||
|
||||
// If a stereo or position was specified, set it up.
|
||||
if (self._stereo) {
|
||||
parent.stereo(self._stereo);
|
||||
} else if (self._pos) {
|
||||
parent.pos(self._pos[0], self._pos[1], self._pos[2], self._id);
|
||||
} else if (self._panner) {
|
||||
// Disconnect the panner.
|
||||
self._panner.disconnect(0);
|
||||
self._panner = undefined;
|
||||
parent._refreshBuffer(self);
|
||||
}
|
||||
|
||||
// Complete resetting of the sound.
|
||||
return _super.call(this);
|
||||
};
|
||||
})(Sound.prototype.reset);
|
||||
|
||||
/** Helper Methods **/
|
||||
/***************************************************************************/
|
||||
|
||||
/**
|
||||
* Create a new panner node and save it on the sound.
|
||||
* @param {Sound} sound Specific sound to setup panning on.
|
||||
* @param {String} type Type of panner to create: 'stereo' or 'spatial'.
|
||||
*/
|
||||
var setupPanner = function(sound, type) {
|
||||
type = type || 'spatial';
|
||||
|
||||
// Create the new panner node.
|
||||
if (type === 'spatial') {
|
||||
sound._panner = Howler.ctx.createPanner();
|
||||
sound._panner.coneInnerAngle = sound._pannerAttr.coneInnerAngle;
|
||||
sound._panner.coneOuterAngle = sound._pannerAttr.coneOuterAngle;
|
||||
sound._panner.coneOuterGain = sound._pannerAttr.coneOuterGain;
|
||||
sound._panner.distanceModel = sound._pannerAttr.distanceModel;
|
||||
sound._panner.maxDistance = sound._pannerAttr.maxDistance;
|
||||
sound._panner.refDistance = sound._pannerAttr.refDistance;
|
||||
sound._panner.rolloffFactor = sound._pannerAttr.rolloffFactor;
|
||||
sound._panner.panningModel = sound._pannerAttr.panningModel;
|
||||
|
||||
if (typeof sound._panner.positionX !== 'undefined') {
|
||||
sound._panner.positionX.setValueAtTime(sound._pos[0], Howler.ctx.currentTime);
|
||||
sound._panner.positionY.setValueAtTime(sound._pos[1], Howler.ctx.currentTime);
|
||||
sound._panner.positionZ.setValueAtTime(sound._pos[2], Howler.ctx.currentTime);
|
||||
} else {
|
||||
sound._panner.setPosition(sound._pos[0], sound._pos[1], sound._pos[2]);
|
||||
}
|
||||
|
||||
if (typeof sound._panner.orientationX !== 'undefined') {
|
||||
sound._panner.orientationX.setValueAtTime(sound._orientation[0], Howler.ctx.currentTime);
|
||||
sound._panner.orientationY.setValueAtTime(sound._orientation[1], Howler.ctx.currentTime);
|
||||
sound._panner.orientationZ.setValueAtTime(sound._orientation[2], Howler.ctx.currentTime);
|
||||
} else {
|
||||
sound._panner.setOrientation(sound._orientation[0], sound._orientation[1], sound._orientation[2]);
|
||||
}
|
||||
} else {
|
||||
sound._panner = Howler.ctx.createStereoPanner();
|
||||
sound._panner.pan.setValueAtTime(sound._stereo, Howler.ctx.currentTime);
|
||||
}
|
||||
|
||||
sound._panner.connect(sound._node);
|
||||
|
||||
// Update the connections.
|
||||
if (!sound._paused) {
|
||||
sound._parent.pause(sound._id, true).play(sound._id, true);
|
||||
}
|
||||
};
|
||||
})();
|
24
src/assets/styles/MemeGrid.css
Normal file
24
src/assets/styles/MemeGrid.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meme-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
@@ -30,8 +30,6 @@ html {
|
||||
@apply absolute invisible no-underline;
|
||||
margin-left: -1em;
|
||||
padding-right: 0.5em;
|
||||
width: 80%;
|
||||
max-width: 700px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -106,6 +104,43 @@ pre {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0px 1000px #121212 inset !important; /* match your dark bg */
|
||||
box-shadow: 0 0 0px 1000px #121212 inset !important;
|
||||
-webkit-text-fill-color: white !important; /* match your text color */
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
input:invalid {
|
||||
outline: none; /* or a custom outline */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Standard placeholder */
|
||||
input:focus::placeholder {
|
||||
color: transparent;
|
||||
opacity: 0; /* optional, for smoother fade in some browsers */
|
||||
}
|
||||
|
||||
/* WebKit (Safari, Chrome, iOS) */
|
||||
input:focus::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input:focus::-moz-placeholder {
|
||||
color: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Microsoft Edge / IE */
|
||||
input:focus:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Remove Safari input shadow on mobile */
|
||||
input[type="text"],
|
||||
input[type="email"] {
|
||||
@@ -198,6 +233,7 @@ Custom
|
||||
|
||||
#lyric-search-input {
|
||||
margin-right: 1.5%;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
#lyrics-info {
|
||||
@@ -211,9 +247,17 @@ Custom
|
||||
}
|
||||
|
||||
.lyrics-card {
|
||||
border: 1px solid grey;
|
||||
border-radius: 5px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
padding: 1.5rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.lyrics-content {
|
||||
line-height: 2.0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.lyrics-card-dark {
|
||||
@@ -241,4 +285,48 @@ Custom
|
||||
max-height: 200px !important;
|
||||
overflow-y: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.p-autocomplete-input {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
transition: border 0.2s;
|
||||
}
|
||||
.p-autocomplete-input:focus {
|
||||
border-color: #4f46e5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.d-dark > * {
|
||||
background-color: rgba(35, 35, 35, 0.9);
|
||||
color: #ffffff;
|
||||
}
|
||||
.d-light > * {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/*
|
||||
Toastify customizations
|
||||
*/
|
||||
.Toastify__toast--error {
|
||||
background-color: rgba(255, 0, 0, 0.5) !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
.Toastify__toast--info {
|
||||
background-color: rgba(217, 242, 255, 0.8) !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success {
|
||||
background-color: rgba(46, 186, 106, 0.8) !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--success > .Toastify__toast-icon svg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Toastify__toast--success > .Toastify__toast-icon::after {
|
||||
content: "🦄" !important;
|
||||
}
|
||||
|
@@ -1,263 +1,325 @@
|
||||
div > img {
|
||||
transition: 300ms;
|
||||
}
|
||||
div > img:hover {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
/* Universal box-sizing for consistency */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
div {
|
||||
box-sizing: border-box;
|
||||
:root {
|
||||
--lrc-text-color: #333; /* darker text */
|
||||
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
||||
--lrc-active-color: #000; /* bold black for active */
|
||||
--lrc-active-shadow: none; /* no glow in light mode */
|
||||
--lrc-hover-color: #005fcc; /* darker blue hover */
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.album-cover > img {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.3em;
|
||||
[data-theme="dark"] {
|
||||
--lrc-text-color: #ccc; /* original gray */
|
||||
--lrc-bg-color: rgba(255, 255, 255, 0.02);
|
||||
--lrc-active-color: #fff; /* bright white for active */
|
||||
--lrc-active-shadow: 0 0 4px rgba(212, 175, 55, 0.6); /* gold glow */
|
||||
--lrc-hover-color: #4fa2ff; /* original blue hover */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Mukta", sans-serif;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
/* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */
|
||||
}
|
||||
|
||||
div,
|
||||
section {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.c-containter {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 30vh;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.station-tabs {
|
||||
top: 4rem;
|
||||
font-family: sans-serif;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
/* background: linear-gradient(-45deg, #FFCDD2 50%, #B2EBF2 50%); */
|
||||
}
|
||||
|
||||
/* Container for the player and album cover */
|
||||
.music-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
/* top: 20%;
|
||||
left: 30%; */
|
||||
width: 40%;
|
||||
max-width: 700px;
|
||||
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
|
||||
max-height: 290px;
|
||||
margin-bottom: 50px;
|
||||
width: 800px; /* fixed desktop width */
|
||||
max-width: 90vw; /* prevent overflow on smaller screens */
|
||||
height: auto !important;
|
||||
margin: 0 auto 120px auto; /* increased bottom margin */
|
||||
overflow-x: visible; /* allow horizontal overflow if needed */
|
||||
overflow-y: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Album cover section */
|
||||
.album-cover {
|
||||
flex: 1 0 30%;
|
||||
}
|
||||
.album-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
max-width: 30%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: calc(50% - 2.5em);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 0;
|
||||
width: 5em;
|
||||
height: 5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.arrow:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.arrow img {
|
||||
display: block;
|
||||
width: 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.arrow.left {
|
||||
left: -5em;
|
||||
}
|
||||
.arrow.right {
|
||||
right: -5em;
|
||||
.album-cover > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
||||
.album-cover > img:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Player info and controls */
|
||||
.music-player {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
justify-content: center;
|
||||
background: inherit;
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
width: 500px;
|
||||
max-width: 500px;
|
||||
flex: 1 1 70%; /* Take remaining ~70% */
|
||||
max-width: 70%;
|
||||
width: auto;
|
||||
height: auto !important; /* Match container height */
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: inherit;
|
||||
box-sizing: border-box;
|
||||
min-width: 0; /* Fix flex overflow */
|
||||
flex-shrink: 0; /* Prevent shrinking that hides content */
|
||||
}
|
||||
|
||||
/* Text styling */
|
||||
.music-player__title {
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.1em 0;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.1em 0;
|
||||
}
|
||||
|
||||
.music-player__header {
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
margin: 1 0 0.1em 0;
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
margin: 1 0 0.1em 0;
|
||||
}
|
||||
|
||||
.music-player__author {
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.music-player__genre {
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.music-player__genre:before {
|
||||
font-weight: bold;
|
||||
content: "Genre: "
|
||||
font-weight: bold;
|
||||
content: "Genre: ";
|
||||
}
|
||||
|
||||
.station-tabs {
|
||||
padding-top: 6%;
|
||||
padding-bottom: 4%;
|
||||
}
|
||||
|
||||
.music-bar {
|
||||
background: #efefef;
|
||||
stroke-width: 1;
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
.music-player__album {
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.music-bar:hover {
|
||||
cursor: pointer;
|
||||
|
||||
/* Music bar and progress */
|
||||
/* .music-bar {
|
||||
background: #efefef;
|
||||
stroke-width: 1;
|
||||
height: 8px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.music-bar #length {
|
||||
width: 0%;
|
||||
background: #2196F3;
|
||||
height: 100%;
|
||||
transition: width linear 200ms;
|
||||
}
|
||||
width: 0%;
|
||||
background: #2196F3;
|
||||
height: 100%;
|
||||
transition: width linear 200ms;
|
||||
} */
|
||||
|
||||
/* Time display */
|
||||
.music-time {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
.music-time__last {
|
||||
margin-left: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Playback order icons */
|
||||
.music-order {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
.music-order__shuffle, .music-order__loop {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
opacity: 0.2;
|
||||
margin: 0.3em 0;
|
||||
|
||||
.music-order__shuffle,
|
||||
.music-order__loop {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
opacity: 0.2;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
.music-order__shuffle.is-loop, .music-order__loop.is-loop {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.music-order__shuffle.is-loop-one, .music-order__loop.is-loop-one {
|
||||
opacity: 1 !important;
|
||||
|
||||
.music-order__shuffle.is-loop,
|
||||
.music-order__loop.is-loop,
|
||||
.music-order__shuffle.is-loop-one,
|
||||
.music-order__loop.is-loop-one {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.music-order__shuffle {
|
||||
margin-left: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Controls container */
|
||||
.music-control {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 2em;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.music-control__play {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
margin: 0 1em;
|
||||
}
|
||||
.music-control__backward, .music-control__forward {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
margin: 0 1em;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.music-control__backward,
|
||||
.music-control__forward {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
|
||||
/* Mobile override */
|
||||
@media all and (max-width: 960px) {
|
||||
.c-containter {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
max-height: inherit;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.music-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column wrap;
|
||||
margin: 0 auto;
|
||||
padding-left: 25%;
|
||||
overflow: auto;
|
||||
}
|
||||
.music-container {
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
margin-bottom: 50px !important;
|
||||
overflow: visible !important;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.music-player__author {
|
||||
font-size: 0.4rem;
|
||||
}
|
||||
.album-cover {
|
||||
flex: none !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
margin-bottom: 1rem !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.music-player__title {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.album-cover > img {
|
||||
aspect-ratio: unset !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.music-player__genre {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.music-player {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
position: relative;
|
||||
flex: 1 1 100%;
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
top: calc(50% - 1.5em);
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
}
|
||||
.arrow.left {
|
||||
left: 0;
|
||||
}
|
||||
.arrow.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.music-control__play {
|
||||
width: 2.2em;
|
||||
height: 2.2em;
|
||||
}
|
||||
.music-player {
|
||||
flex: none !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
min-width: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
.progress-bar-container {
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
height: 0.5rem; /* Ensure visible height */
|
||||
background-color: #e5e7eb; /* fallback light gray */
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.2s linear;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.lrc-text {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.3s ease;
|
||||
max-height: 125px;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
margin-top: 1rem;
|
||||
padding: 0 1rem 1rem;
|
||||
text-align: center;
|
||||
font-family: sans-serif;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--lrc-text-color);
|
||||
user-select: none;
|
||||
background-color: var(--lrc-bg-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.8);
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #999 transparent;
|
||||
scroll-padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.lrc-text.empty {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lrc-line {
|
||||
margin: 0.1rem 0;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
transition: color 0.3s ease, font-weight 0.3s ease, text-shadow 0.3s ease;
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.lrc-line.active {
|
||||
color: var(--lrc-active-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
text-shadow: var(--lrc-active-shadow);
|
||||
}
|
||||
|
||||
.lrc-line:hover {
|
||||
color: var(--lrc-hover-color);
|
||||
text-decoration: dotted underline;
|
||||
}
|
||||
|
||||
.lrc-line:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.lrc-text::-webkit-scrollbar-thumb {
|
||||
background-color: #999;
|
||||
border-radius: 3px;
|
||||
}
|
@@ -1,38 +1,45 @@
|
||||
// Root.jsx
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Memes from './Memes.jsx';
|
||||
import { toast } from 'react-toastify';
|
||||
import { API_URL } from '../config';
|
||||
import { Player } from './AudioPlayer.jsx';
|
||||
import { JoyUIRootIsland } from './Components.jsx';
|
||||
import { PrimeReactProvider } from "primereact/api";
|
||||
import Alert from '@mui/joy/Alert';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { usePrimeReactThemeSwitcher } from '@/hooks/usePrimeReactThemeSwitcher.jsx';
|
||||
import CustomToastContainer from '../components/ToastProvider.jsx';
|
||||
import LyricSearch from './LyricSearch.jsx';
|
||||
import 'primereact/resources/themes/bootstrap4-light-blue/theme.css';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
import "primeicons/primeicons.css";
|
||||
|
||||
export default function Root({child}) {
|
||||
const LoginPage = lazy(() => import('./Login.jsx'));
|
||||
const LyricSearch = lazy(() => import('./LyricSearch'));
|
||||
const MediaRequestForm = lazy(() => import('./TRip/MediaRequestForm.jsx'));
|
||||
const RequestManagement = lazy(() => import('./TRip/RequestManagement.jsx'));
|
||||
const Player = lazy(() => import('./AudioPlayer.jsx'));
|
||||
|
||||
export default function Root({ child, user = undefined }) {
|
||||
window.toast = toast;
|
||||
window.API_URL = API_URL;
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
// console.log(opts.children);
|
||||
usePrimeReactThemeSwitcher(theme);
|
||||
return (
|
||||
<PrimeReactProvider>
|
||||
<CustomToastContainer
|
||||
theme={theme}
|
||||
newestOnTop={true}
|
||||
closeOnClick={true}/>
|
||||
<JoyUIRootIsland>
|
||||
<Alert
|
||||
<CustomToastContainer
|
||||
theme={theme}
|
||||
newestOnTop={true}
|
||||
closeOnClick={true} />
|
||||
<JoyUIRootIsland>
|
||||
{/* <Alert
|
||||
className="alert"
|
||||
startDecorator={<WarningIcon />}
|
||||
variant="soft"
|
||||
color="danger">
|
||||
Work in progress... bugs are to be expected.
|
||||
</Alert>
|
||||
{child == "LyricSearch" && (<LyricSearch client:only="react" />)}
|
||||
{child == "Player" && (<Player client:only="react"></Player>)}
|
||||
</JoyUIRootIsland>
|
||||
</Alert> */}
|
||||
{child == "LoginPage" && (<LoginPage client:only="react" />)}
|
||||
{child == "LyricSearch" && (<LyricSearch client:only="react" />)}
|
||||
{child == "Player" && (<Player client:only="react" user={user} />)}
|
||||
{child == "Memes" && <Memes client:only="react" />}
|
||||
{child == "qs2.MediaRequestForm" && <MediaRequestForm client:only="react" />}
|
||||
{child == "qs2.RequestManagement" && <RequestManagement client:only="react" />}
|
||||
</JoyUIRootIsland>
|
||||
</PrimeReactProvider>
|
||||
);
|
||||
}
|
||||
|
@@ -1,217 +1,283 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Howl, Howler } from "howler";
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||
import { metaData } from "../config";
|
||||
import Play from "@mui/icons-material/PlayArrow";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
import "@styles/player.css";
|
||||
|
||||
const API_URL = "https://api.codey.lol";
|
||||
Howler.html5PoolSize = 32;
|
||||
import { API_URL } from "@/config";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
const STATIONS = {
|
||||
main: { label: "Main", streamPath: "/sfm.ogg" },
|
||||
rock: { label: "Rock", streamPath: "/rock.ogg" },
|
||||
rap: { label: "Rap", streamPath: "/rap.ogg" },
|
||||
electronic: { label: "Electronic", streamPath: "/electronic.ogg" },
|
||||
classical: { label: "Classical", streamPath: "/classical.ogg" },
|
||||
pop: { label: "Pop", streamPath: "/pop.ogg" },
|
||||
main: { label: "Main" },
|
||||
rock: { label: "Rock" },
|
||||
rap: { label: "Rap" },
|
||||
electronic: { label: "Electronic" },
|
||||
pop: { label: "Pop" },
|
||||
};
|
||||
|
||||
// Global interval tracking (required for Astro + client:only)
|
||||
let activeInterval = null;
|
||||
let currentStationForInterval = null;
|
||||
|
||||
function clearGlobalMetadataInterval() {
|
||||
if (activeInterval) {
|
||||
clearInterval(activeInterval);
|
||||
activeInterval = null;
|
||||
currentStationForInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function Player() {
|
||||
export default function Player({ user }) {
|
||||
const [activeStation, setActiveStation] = useState("main");
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [trackTitle, setTrackTitle] = useState("");
|
||||
const [trackArtist, setTrackArtist] = useState("");
|
||||
const [trackGenre, setTrackGenre] = useState("");
|
||||
const [trackAlbum, setTrackAlbum] = useState("");
|
||||
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [lyrics, setLyrics] = useState([]);
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [trackDuration, setTrackDuration] = useState(0);
|
||||
|
||||
const soundRef = useRef(null);
|
||||
const uuidRef = useRef(null);
|
||||
const lastStationData = useRef(null);
|
||||
const audioElement = useRef(null);
|
||||
const hlsInstance = useRef(null);
|
||||
const currentTrackUuid = useRef(null);
|
||||
const baseTrackElapsed = useRef(0);
|
||||
const lastUpdateTimestamp = useRef(Date.now());
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const m = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||
const s = String(Math.floor(seconds % 60)).padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
||||
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
||||
return `${mins}:${secs}`;
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
|
||||
// Set page title based on current track and station
|
||||
const setPageTitle = (artist, song) => {
|
||||
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
||||
};
|
||||
|
||||
// Create Howl instance on activeStation change
|
||||
useEffect(() => {
|
||||
if (soundRef.current) {
|
||||
soundRef.current.unload();
|
||||
soundRef.current = null;
|
||||
}
|
||||
// Initialize or switch HLS stream
|
||||
const initializeStream = (station) => {
|
||||
import('hls.js').then(({ default: Hls }) => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
||||
|
||||
const streamUrl = "https://stream.codey.lol" + STATIONS[activeStation].streamPath;
|
||||
// Clean up previous HLS
|
||||
if (hlsInstance.current) {
|
||||
hlsInstance.current.destroy();
|
||||
hlsInstance.current = null;
|
||||
}
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load();
|
||||
|
||||
const howl = new Howl({
|
||||
src: [streamUrl],
|
||||
html5: true,
|
||||
onend: () => howl.play(),
|
||||
onplay: () => setIsPlaying(true),
|
||||
onpause: () => setIsPlaying(false),
|
||||
onstop: () => setIsPlaying(false),
|
||||
onloaderror: (id, err) => console.error("Load error", err),
|
||||
onplayerror: (id, err) => {
|
||||
console.error("Play error", err);
|
||||
setTimeout(() => howl.play(), 1000);
|
||||
},
|
||||
});
|
||||
// Handle audio load errors
|
||||
audio.onerror = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
soundRef.current = howl;
|
||||
uuidRef.current = null;
|
||||
lastStationData.current = null;
|
||||
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
audio.src = streamUrl;
|
||||
audio.load();
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Autoplay if previously playing
|
||||
if (isPlaying) {
|
||||
setTimeout(() => {
|
||||
if (soundRef.current) {
|
||||
soundRef.current.play();
|
||||
if (!Hls.isSupported()) {
|
||||
console.error("HLS not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
abrEnabled: false,
|
||||
liveSyncDuration: 1.0, // seconds behind live edge target
|
||||
liveMaxLatencyDuration: 2.0, // max allowed latency before catchup
|
||||
liveCatchUpPlaybackRate: 1.05, // playback speed when catching up
|
||||
});
|
||||
|
||||
hlsInstance.current = hls;
|
||||
hls.attachMedia(audio);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
||||
setIsPlaying(false);
|
||||
});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.warn("HLS error:", data);
|
||||
if (data.fatal) {
|
||||
hls.destroy();
|
||||
hlsInstance.current = null;
|
||||
setTrackTitle("Offline");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return () => {
|
||||
howl.stop();
|
||||
howl.unload();
|
||||
};
|
||||
// Update elapsed time smoothly
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
||||
let liveElapsed = baseTrackElapsed.current + deltaSec;
|
||||
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
||||
setElapsedTime(liveElapsed);
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isPlaying, trackDuration]);
|
||||
|
||||
|
||||
|
||||
// Use both base elapsed and audio time for precise lyric syncing
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !lyrics.length) return;
|
||||
|
||||
const lyricsInterval = setInterval(() => {
|
||||
let newIndex = 0;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
if (elapsedTime >= lyrics[i].timestamp) newIndex = i;
|
||||
else break;
|
||||
}
|
||||
setCurrentLyricIndex(newIndex);
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(lyricsInterval);
|
||||
}, [isPlaying, lyrics, elapsedTime]);
|
||||
|
||||
|
||||
|
||||
|
||||
// Scroll active lyric into view
|
||||
useEffect(() => {
|
||||
const activeElement = document.querySelector('.lrc-line.active');
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [currentLyricIndex]);
|
||||
|
||||
// Handle station changes: reset and start new stream
|
||||
useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
setTrackTitle("");
|
||||
setTrackArtist("");
|
||||
setTrackGenre("");
|
||||
setTrackAlbum("");
|
||||
setCoverArt("/images/radio_art_default.jpg");
|
||||
|
||||
setLyrics([]);
|
||||
setCurrentLyricIndex(0);
|
||||
setElapsedTime(0);
|
||||
setTrackDuration(0);
|
||||
|
||||
currentTrackUuid.current = null;
|
||||
baseTrackElapsed.current = 0;
|
||||
lastUpdateTimestamp.current = Date.now();
|
||||
|
||||
initializeStream(activeStation);
|
||||
}, [activeStation]);
|
||||
|
||||
const togglePlayback = () => {
|
||||
if (isPlaying) {
|
||||
if (soundRef.current) {
|
||||
soundRef.current.pause();
|
||||
}
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// If a previous Howl exists, unload it
|
||||
if (soundRef.current) {
|
||||
soundRef.current.stop();
|
||||
soundRef.current.unload();
|
||||
}
|
||||
|
||||
const streamUrl =
|
||||
"https://stream.codey.lol" +
|
||||
STATIONS[activeStation].streamPath +
|
||||
`?t=${Date.now()}`; // Cache-busting param
|
||||
|
||||
const newHowl = new Howl({
|
||||
src: [streamUrl],
|
||||
html5: true,
|
||||
onend: () => newHowl.play(),
|
||||
onplay: () => {
|
||||
setIsPlaying(true);
|
||||
},
|
||||
onpause: () => setIsPlaying(false),
|
||||
onstop: () => setIsPlaying(false),
|
||||
onloaderror: (_, err) => console.error("Load error", err),
|
||||
onplayerror: (_, err) => {
|
||||
console.error("Play error", err);
|
||||
setTimeout(() => newHowl.play(), 1000);
|
||||
},
|
||||
});
|
||||
|
||||
soundRef.current = newHowl;
|
||||
newHowl.play();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Metadata fetcher: global-safe
|
||||
// Fetch metadata and lyrics periodically
|
||||
useEffect(() => {
|
||||
clearGlobalMetadataInterval();
|
||||
|
||||
currentStationForInterval = activeStation;
|
||||
|
||||
const fetchTrackData = async () => {
|
||||
const fetchMetadataAndLyrics = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' });
|
||||
const trackData = await response.json();
|
||||
|
||||
// Ignore stale interval calls
|
||||
if (currentStationForInterval !== activeStation) return;
|
||||
|
||||
if (data.artist === "N/A" && data.song === "N/A") {
|
||||
if (lastStationData.current !== "offline") {
|
||||
setTrackTitle("Offline");
|
||||
setTrackArtist("");
|
||||
setTrackGenre("");
|
||||
setCoverArt("/images/radio_art_default.jpg");
|
||||
setElapsed(0);
|
||||
setDuration(0);
|
||||
lastStationData.current = "offline";
|
||||
}
|
||||
if (trackData.artist === 'N/A') {
|
||||
setTrackTitle('Offline');
|
||||
setLyrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.uuid === uuidRef.current) {
|
||||
if (lastStationData.current === data.uuid) {
|
||||
setElapsed(data.elapsed);
|
||||
setDuration(data.duration);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trackData.uuid !== currentTrackUuid.current) {
|
||||
currentTrackUuid.current = trackData.uuid;
|
||||
|
||||
uuidRef.current = data.uuid;
|
||||
lastStationData.current = data.uuid;
|
||||
setTrackTitle(data.song);
|
||||
setTrackArtist(data.artist);
|
||||
setTrackGenre(data.genre !== "N/A" ? data.genre : "");
|
||||
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
|
||||
setElapsed(data.elapsed);
|
||||
setDuration(data.duration);
|
||||
setTrackTitle(trackData.song);
|
||||
setTrackArtist(trackData.artist);
|
||||
setTrackGenre(trackData.genre || '');
|
||||
setTrackAlbum(trackData.album);
|
||||
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
|
||||
|
||||
baseTrackElapsed.current = trackData.elapsed;
|
||||
lastUpdateTimestamp.current = Date.now();
|
||||
setElapsedTime(trackData.elapsed);
|
||||
|
||||
setTrackDuration(trackData.duration);
|
||||
|
||||
setLyrics([]);
|
||||
setCurrentLyricIndex(0);
|
||||
setPageTitle(trackData.artist, trackData.song);
|
||||
|
||||
// Fetch lyrics as before
|
||||
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
a: trackData.artist,
|
||||
s: trackData.song,
|
||||
excluded_sources: [],
|
||||
extra: false,
|
||||
lrc: true,
|
||||
src: 'WEB-RADIO',
|
||||
}),
|
||||
});
|
||||
const lyricsData = await lyricsResponse.json();
|
||||
if (!lyricsData.err && Array.isArray(lyricsData.lrc)) {
|
||||
const parsedLyrics = lyricsData.lrc.map(({ timeTag, words }) => {
|
||||
const [mins, rest] = timeTag.split(':');
|
||||
const secs = parseFloat(rest);
|
||||
return { timestamp: Number(mins) * 60 + secs, line: words };
|
||||
});
|
||||
setLyrics(parsedLyrics);
|
||||
setCurrentLyricIndex(0);
|
||||
}
|
||||
} else {
|
||||
// Same track - do not update elapsed or elapsed base here
|
||||
setTrackDuration(trackData.duration);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch track data:", error);
|
||||
console.error('Error fetching metadata:', error);
|
||||
setTrackTitle('Offline');
|
||||
setLyrics([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTrackData();
|
||||
activeInterval = setInterval(fetchTrackData, 1000);
|
||||
|
||||
return () => {
|
||||
clearGlobalMetadataInterval();
|
||||
};
|
||||
fetchMetadataAndLyrics();
|
||||
const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
|
||||
return () => clearInterval(metadataInterval);
|
||||
}, [activeStation]);
|
||||
|
||||
const progress = (elapsedTime / trackDuration) * 100;
|
||||
const remaining = trackDuration - elapsedTime;
|
||||
|
||||
const progressColorClass =
|
||||
progress >= 90
|
||||
? "bg-red-500 dark:bg-red-400"
|
||||
: progress >= 75 || remaining <= 20
|
||||
? "bg-yellow-400 dark:bg-yellow-300"
|
||||
: "bg-blue-500 dark:bg-blue-400";
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
|
||||
{Object.entries(STATIONS).map(([key, { label }]) => (
|
||||
{Object.entries(STATIONS).map(([stationKey, { label }]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${
|
||||
activeStation === key
|
||||
? "bg-neutral-800 text-white dark:bg-white dark:text-black"
|
||||
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
||||
}`}
|
||||
onClick={() => setActiveStation(key)}
|
||||
aria-pressed={activeStation === key}
|
||||
key={stationKey}
|
||||
onClick={() => setActiveStation(stationKey)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
||||
? 'bg-neutral-800 text-white dark:bg-white dark:text-black'
|
||||
: 'bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white'
|
||||
}`}
|
||||
aria-pressed={activeStation === stationKey}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="c-containter">
|
||||
<div className="music-container mt-8">
|
||||
{user ? <span>Hello, {user.user}</span> : <span>Not logged in</span>}
|
||||
< div className="music-container mt-8">
|
||||
<section className="album-cover">
|
||||
<div className="music-player__album" title="Album">
|
||||
{trackAlbum}
|
||||
</div>
|
||||
<img src={coverArt} className="cover" alt="Cover Art" />
|
||||
</section>
|
||||
<section className="music-player">
|
||||
@@ -219,25 +285,53 @@ const togglePlayback = () => {
|
||||
<h1 className="music-player__title">{trackTitle}</h1>
|
||||
<h2 className="music-player__author">{trackArtist}</h2>
|
||||
{trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
|
||||
|
||||
<div className="music-time">
|
||||
<p className="music-time__current">{formatTime(elapsed)}</p>
|
||||
<p className="music-time__last">{formatTime(duration - elapsed)}</p>
|
||||
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
||||
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
||||
</div>
|
||||
<div className="music-bar" id="progress">
|
||||
<div id="length" style={{ width: `${progress}%` }}></div>
|
||||
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
|
||||
{lyrics.map((lyricObj, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`lrc-line ${index === currentLyricIndex ? 'active' : ''}`}
|
||||
>
|
||||
{lyricObj.line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="music-control">
|
||||
<div className="music-control__play" id="play">
|
||||
{!isPlaying ? (
|
||||
<Play onClick={togglePlayback} />
|
||||
) : (
|
||||
<Pause onClick={togglePlayback} />
|
||||
)}
|
||||
<div
|
||||
className="music-control__play"
|
||||
onClick={() => {
|
||||
const audio = audioElement.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().then(() => setIsPlaying(true));
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isPlaying}
|
||||
>
|
||||
{isPlaying ? <Pause /> : <Play />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<audio ref={audioElement} preload="none" />
|
||||
</div >
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ interface Props {
|
||||
|
||||
import { metaData } from "../config";
|
||||
import { SEO } from "astro-seo";
|
||||
import { getImagePath } from "astro-opengraph-images";
|
||||
import { JoyUIRootIsland } from "./Components"
|
||||
import { useHtmlThemeAttr } from "../hooks/useHtmlThemeAttr";
|
||||
import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher";
|
||||
@@ -15,12 +14,6 @@ import { usePrimeReactThemeSwitcher } from "../hooks/usePrimeReactThemeSwitcher"
|
||||
const { title, description = metaData.description, image } = Astro.props;
|
||||
|
||||
const { url, site } = Astro;
|
||||
const openGraphImageUrl = getImagePath({ url, site });
|
||||
|
||||
// If the image is not provided, use the default image
|
||||
const openGraphImage = image
|
||||
? new URL(image, url.href).href
|
||||
: openGraphImageUrl;
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +27,7 @@ const openGraphImage = image
|
||||
basic: {
|
||||
title: title || metaData.title,
|
||||
type: "website",
|
||||
image: openGraphImageUrl,
|
||||
image: "",
|
||||
url: url,
|
||||
},
|
||||
optional: {
|
||||
|
@@ -1,22 +1,19 @@
|
||||
---
|
||||
import { metaData, API_URL } from "../config";
|
||||
import { metaData, ENVIRONMENT } from "../config";
|
||||
import RandomMsg from "../components/RandomMsg";
|
||||
import { buildTime } from '../utils/buildTime.js';
|
||||
import { buildTime, buildNumber } from '../utils/buildTime.js';
|
||||
|
||||
const YEAR = new Date().getFullYear();
|
||||
var ENVIRONMENT = (Astro.url.hostname === "localhost") ? "Dev": "Prod";
|
||||
|
||||
|
||||
|
||||
---
|
||||
<div class="footer">
|
||||
<small class="block lg:mt-24 mt-16 text-[#1C1C1C] dark:text-[#D4D4D4] footer-text">
|
||||
<time>© {YEAR}</time>{" "}
|
||||
{metaData.owner}
|
||||
</a>
|
||||
</small>
|
||||
<RandomMsg client:only="react" />
|
||||
<div style="margin-top: 15px; bottom: 0%">
|
||||
<small>Built: {buildTime} UTC</small>
|
||||
<small>Build# {buildNumber}
|
||||
<br>
|
||||
Built: {buildTime} UTC</small>
|
||||
</div>
|
||||
<span style="font-size: 0.75rem; font-style: italic; margin-left: 100%">{ENVIRONMENT}</span>
|
||||
</div>
|
||||
|
178
src/components/Login.jsx
Normal file
178
src/components/Login.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if (passwordRef.current && password === "") {
|
||||
passwordRef.current.value = "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (!username) {
|
||||
setLoading(false);
|
||||
if (!toast.isActive("login-username-required-toast")) {
|
||||
return toast.error("Username and password are required",
|
||||
{
|
||||
toastId: "login-missing-data-toast",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!password) {
|
||||
setLoading(false);
|
||||
if (!toast.isActive("login-password-required-toast")) {
|
||||
return toast.error("Username and password are required",
|
||||
{
|
||||
toastId: "login-missing-data-toast",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
formData.append("grant_type", "password");
|
||||
formData.append("scope", "");
|
||||
formData.append("client_id", "");
|
||||
formData.append("client_secret", "");
|
||||
|
||||
const resp = await fetch(`${API_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
credentials: "include",
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
if (!toast.isActive("login-error-invalid-toast")) {
|
||||
toast.error("Invalid username or password", {
|
||||
toastId: "login-error-invalid-toast",
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!toast.isActive("login-error-failed-toast")) {
|
||||
toast.error(data.detail ? `Login failed: ${data.detail}` : "Login failed",
|
||||
{
|
||||
toastId: "login-error-failed-toast",
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.access_token) {
|
||||
if (!toast.isActive("login-success-toast")) {
|
||||
toast.success("Login successful!",
|
||||
{
|
||||
toastId: "login-success-toast",
|
||||
});
|
||||
}
|
||||
window.location.href = "/TRip"; // TODO: fix, hardcoded
|
||||
} else {
|
||||
if (!toast.isActive("login-error-no-token-toast")) {
|
||||
toast.error("Login failed: no access token received",
|
||||
{
|
||||
toastId: "login-error-no-token-toast",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!toast.isActive("login-error-network-toast")) {
|
||||
toast.error("Network error during login",
|
||||
{
|
||||
toastId: "login-error-network-toast",
|
||||
});
|
||||
}
|
||||
console.error("Login error:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center bg-gray-50 dark:bg-[#121212] px-4 pt-20 py-10">
|
||||
<div className="max-w-md w-full bg-white dark:bg-[#1E1E1E] rounded-2xl shadow-xl px-10 pb-6">
|
||||
<h2 className="flex flex-col items-center text-3xl font-semibold text-gray-900 dark:text-white mb-8 font-sans">
|
||||
<img className="logo-auth mb-4" src="/images/kode.png" alt="Logo" />
|
||||
Authentication Required
|
||||
</h2>
|
||||
|
||||
<form className="space-y-6 relative" onSubmit={handleSubmit} noValidate>
|
||||
{/* Username */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autoComplete="off"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
spellCheck="false"
|
||||
ref={passwordRef}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="peer block w-full px-4 pt-5 pb-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-white placeholder-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="absolute left-4 top-2 text-gray-500 dark:text-gray-400 text-sm transition-all
|
||||
peer-placeholder-shown:top-5 peer-placeholder-shown:text-gray-400 peer-placeholder-shown:text-base
|
||||
peer-focus:top-2 peer-focus:text-sm peer-focus:text-blue-500 dark:peer-focus:text-blue-400"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full py-3 bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 text-white rounded-lg font-semibold shadow-md transition-colors ${loading ? "opacity-60 cursor-not-allowed" : ""
|
||||
}`}
|
||||
>
|
||||
{loading ? "Signing In..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -6,57 +6,34 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { default as $ } from "jquery";
|
||||
import { toast } from 'react-toastify';
|
||||
import Alert from '@mui/joy/Alert';
|
||||
import Box from '@mui/joy/Box';
|
||||
import Button from "@mui/joy/Button";
|
||||
import Checkbox from "@mui/joy/Checkbox";
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import jQuery from "jquery";
|
||||
import { AutoComplete } from 'primereact/autocomplete';
|
||||
import { api as API_URL } from '../config';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
|
||||
|
||||
document.addEventListener('set-theme', (e) => {
|
||||
const box = document.querySelector("[class*='lyrics-card-']")
|
||||
let removedClass = "lyrics-card-dark";
|
||||
let newTheme = e.detail;
|
||||
if (newTheme !== "light") {
|
||||
removedClass = "lyrics-card-light";
|
||||
}
|
||||
$(box).removeClass(removedClass)
|
||||
$(box).addClass(`lyrics-card-${newTheme}`);
|
||||
});
|
||||
import { API_URL } from '../config';
|
||||
|
||||
export default function LyricSearch() {
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
return (
|
||||
<div className="lyric-search">
|
||||
<h2 className="title">
|
||||
<span>Lyric Search</span>
|
||||
</h2>
|
||||
<div className="card-text my-4">
|
||||
<label>Search:</label>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics} />
|
||||
<br />
|
||||
Exclude:<br />
|
||||
<div id="exclude-checkboxes">
|
||||
<UICheckbox id="excl-Genius" label="Genius" />
|
||||
<UICheckbox id="excl-LRCLib" label="LRCLib" />
|
||||
<UICheckbox id="excl-Cache" label="Cache" />
|
||||
</div>
|
||||
<div id="spinner" className="hidden">
|
||||
<CircularProgress
|
||||
variant="plain"
|
||||
color="primary"
|
||||
size="md"/></div>
|
||||
</div>
|
||||
<h2 className="title">
|
||||
<span>Lyric Search</span>
|
||||
</h2>
|
||||
<div className="card-text my-4">
|
||||
<label htmlFor="lyric-search-input">Search:</label>
|
||||
<LyricSearchInputField
|
||||
id="lyric-search-input"
|
||||
placeholder="Artist - Song"
|
||||
setShowLyrics={setShowLyrics} />
|
||||
<div id="spinner" className="hidden">
|
||||
<CircularProgress
|
||||
variant="plain"
|
||||
color="primary"
|
||||
size="md" /></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +48,20 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
const [excludedSources, setExcludedSources] = useState([]);
|
||||
const [lyricsResult, setLyricsResult] = useState(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
const [theme, setTheme] = useState(document.documentElement.getAttribute("data-theme") || "light");
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const newTheme = e.detail;
|
||||
setTheme(newTheme);
|
||||
};
|
||||
document.addEventListener('set-theme', handler);
|
||||
return () => {
|
||||
document.removeEventListener('set-theme', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Typeahead: fetch suggestions
|
||||
const fetchSuggestions = async (event) => {
|
||||
@@ -86,13 +77,15 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
|
||||
// Toggle exclusion state for checkboxes
|
||||
const toggleExclusion = (source) => {
|
||||
const lower = source.toLowerCase();
|
||||
setExcludedSources((prev) =>
|
||||
prev.includes(source)
|
||||
? prev.filter((s) => s !== source)
|
||||
: [...prev, source]
|
||||
prev.includes(lower)
|
||||
? prev.filter((s) => s !== lower)
|
||||
: [...prev, lower]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Show scrollable dropdown panel with mouse wheel handling
|
||||
const handlePanelShow = () => {
|
||||
setTimeout(() => {
|
||||
@@ -144,12 +137,7 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
setLyricsResult(null);
|
||||
setShowLyrics(false);
|
||||
|
||||
const toastId = toast.info("Searching...", {
|
||||
style: {
|
||||
color: "#000",
|
||||
backgroundColor: "rgba(217, 242, 255, 0.8)",
|
||||
},
|
||||
});
|
||||
const toastId = toast.info("Searching...");
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -176,19 +164,16 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
setShowLyrics(true);
|
||||
|
||||
toast.update(toastId, {
|
||||
render: `🦄 Found! (Took ${duration}s)`,
|
||||
type: "",
|
||||
style: { backgroundColor: "rgba(46, 186, 106, 1)" },
|
||||
autoClose: 2000,
|
||||
hideProgressBar: true,
|
||||
type: "success",
|
||||
render: `Found! (Took ${duration}s)`,
|
||||
className: "Toastify__toast--success",
|
||||
autoClose: 2500,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.update(toastId, {
|
||||
type: "error",
|
||||
render: `😕 ${error.message}`,
|
||||
type: "",
|
||||
style: { backgroundColor: "rgba(255, 0, 0, 0.5)" },
|
||||
autoClose: 5000,
|
||||
hideProgressBar: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -229,10 +214,18 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
size={40}
|
||||
aria-controls="lyric-search-input"
|
||||
/>
|
||||
<Button onClick={handleSearch} className="btn">
|
||||
Search
|
||||
</Button>
|
||||
<br />
|
||||
Exclude:<br />
|
||||
<div id="exclude-checkboxes">
|
||||
<UICheckbox id="excl-Genius" label="Genius" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-LRCLib" label="LRCLib" onToggle={toggleExclusion} />
|
||||
<UICheckbox id="excl-Cache" label="Cache" onToggle={toggleExclusion} />
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="mt-3">
|
||||
@@ -241,62 +234,64 @@ export function LyricSearchInputField({ id, placeholder, setShowLyrics }) {
|
||||
)}
|
||||
|
||||
{lyricsResult && (
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
||||
<div className="lyrics-content">
|
||||
<div style={{ textAlign: "center", fontWeight: "bold", marginBottom: "1rem" }}>
|
||||
{lyricsResult.artist} - {lyricsResult.song}
|
||||
<div className={`lyrics-card lyrics-card-${theme} mt-4 p-4 rounded-md shadow-md`}>
|
||||
<div className="lyrics-content">
|
||||
<div style={{ textAlign: "center", fontWeight: "bold", marginBottom: "1rem" }}>
|
||||
{lyricsResult.artist} - {lyricsResult.song}
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: lyricsResult.lyrics }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const UICheckbox = forwardRef(function UICheckbox(opts = {}, ref) {
|
||||
export const UICheckbox = forwardRef(function UICheckbox(props = {}, ref) {
|
||||
const [checked, setChecked] = useState(false);
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
let valid_exclusions = true;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setChecked: (val) => setChecked(val),
|
||||
checked,
|
||||
}));
|
||||
|
||||
const verifyExclusions = (e) => {
|
||||
let exclude_error = false;
|
||||
if (($("#exclude-checkboxes").find("input:checkbox").filter(":checked").length == 3)){
|
||||
$("#exclude-checkboxes").find("input:checkbox").each(function () {
|
||||
exclude_error = true;
|
||||
this.click();
|
||||
});
|
||||
if (exclude_error) {
|
||||
toast.error("All sources were excluded; exclusions have been reset.",
|
||||
{ style: { backgroundColor: "rgba(255, 0, 0, 0.5)", color: 'inherit' } },
|
||||
);
|
||||
}
|
||||
|
||||
const verifyExclusions = () => {
|
||||
const checkboxes = document.querySelectorAll("#exclude-checkboxes input[type=checkbox]");
|
||||
const checkedCount = [...checkboxes].filter(cb => cb.checked).length;
|
||||
|
||||
if (checkedCount === 3) {
|
||||
checkboxes.forEach(cb => cb.click());
|
||||
toast.error("All sources were excluded; exclusions have been reset.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Checkbox
|
||||
id={opts.id}
|
||||
key={opts.label}
|
||||
checked={checked}
|
||||
label={opts.label}
|
||||
style={{ color: "inherit" }}
|
||||
onChange={(e) => {
|
||||
setChecked(e.target.checked);
|
||||
verifyExclusions();
|
||||
}}
|
||||
/>
|
||||
|
||||
const handleChange = (e) => {
|
||||
const newChecked = e.target.checked;
|
||||
setChecked(newChecked);
|
||||
if (props.onToggle) {
|
||||
const source = props.label; // Use label as source identifier
|
||||
props.onToggle(source);
|
||||
}
|
||||
verifyExclusions();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
key={props.label}
|
||||
checked={checked}
|
||||
label={props.label}
|
||||
style={{ color: "inherit" }}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
export function LyricResultBox(opts={}) {
|
||||
export function LyricResultBox(opts = {}) {
|
||||
return (
|
||||
<div>
|
||||
<Box className={`lyrics-card lyrics-card-${theme}`} sx={{ p: 2 }}>
|
||||
|
135
src/components/Memes.jsx
Normal file
135
src/components/Memes.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Image } from 'primereact/image';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
||||
const BASE_IMAGE_URL = "https://codey.lol/meme";
|
||||
|
||||
const Memes = () => {
|
||||
const [images, setImages] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const observerRef = useRef();
|
||||
const theme = document.documentElement.getAttribute("data-theme")
|
||||
const loadImages = async (pageNum, attempt = 0) => {
|
||||
if (loading || !hasMore) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${MEME_API_URL}?page=${pageNum}`);
|
||||
|
||||
if (res.status === 429) {
|
||||
const backoff = Math.min(1000 * Math.pow(2, attempt), 10000); // max 10s
|
||||
console.warn(`Rate limited. Retrying in ${backoff}ms`);
|
||||
setTimeout(() => {
|
||||
loadImages(pageNum, attempt + 1);
|
||||
}, backoff);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
const newMemes = data?.memes || [];
|
||||
|
||||
if (newMemes.length === 0 || data.paging.current >= data.paging.of) {
|
||||
setHasMore(false);
|
||||
}
|
||||
|
||||
const imageObjects = newMemes.map(m => ({
|
||||
id: m.id,
|
||||
timestamp: new Date(m.timestamp * 1000)
|
||||
.toString().split(" ")
|
||||
.splice(0, 4).join(" "),
|
||||
url: `${BASE_IMAGE_URL}/${m.id}.png`,
|
||||
}));
|
||||
|
||||
setImages(prev => [...prev, ...imageObjects]);
|
||||
setPage(prev => prev + 1);
|
||||
} catch (e) {
|
||||
console.error("Failed to load memes", e);
|
||||
toast.error("Failed to load more memes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastImageRef = useCallback(node => {
|
||||
if (loading) return;
|
||||
if (observerRef.current) observerRef.current.disconnect();
|
||||
observerRef.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadImages(page);
|
||||
}
|
||||
});
|
||||
if (node) observerRef.current.observe(node);
|
||||
}, [loading, hasMore, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadImages(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid-container">
|
||||
{images.map((img, i) => {
|
||||
const isLast = i === images.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={img.id}
|
||||
className="grid-item"
|
||||
ref={isLast ? lastImageRef : null}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Image
|
||||
src={img.url}
|
||||
alt={`meme-${img.id}`}
|
||||
imageClassName="meme-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loading && (
|
||||
<div className="loading">
|
||||
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog for enlarged image */}
|
||||
<Dialog
|
||||
header={`Meme #${selectedImage?.id} - ${selectedImage?.timestamp}`}
|
||||
visible={!!selectedImage}
|
||||
onHide={() => setSelectedImage(null)}
|
||||
style={{ width: '90vw', maxWidth: '720px' }}
|
||||
className={`d-${theme}`}
|
||||
modal
|
||||
closable
|
||||
dismissableMask={true}
|
||||
>
|
||||
{selectedImage && (
|
||||
<img
|
||||
src={selectedImage.url}
|
||||
alt={`meme-${selectedImage.id}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '70vh', // restrict height to viewport height
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Memes;
|
@@ -1,28 +1,51 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { API_URL } from "../config";
|
||||
import { default as $ } from "jquery";
|
||||
|
||||
var randomMsg;
|
||||
|
||||
const getRandomMsg = () => {
|
||||
$.ajax({
|
||||
url: API_URL+'/randmsg',
|
||||
method: "POST",
|
||||
contentType: "application/json; charset=utf-8"
|
||||
}).done(function (data) {
|
||||
if (! data.msg) {
|
||||
return;
|
||||
} else {
|
||||
randomMsg = data.msg.replace("<br>", "\n");
|
||||
$('.random-msg').html(`<small><i>${randomMsg}</i></small>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
|
||||
export default function RandomMsg() {
|
||||
getRandomMsg();
|
||||
return (
|
||||
<div className="random-msg">
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [randomMsg, setRandomMsg] = useState("");
|
||||
|
||||
const getRandomMsg = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/randmsg`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.msg) {
|
||||
const formattedMsg = data.msg.replace(/<br\s*\/?>/gi, "\n");
|
||||
setRandomMsg(formattedMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch random message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getRandomMsg();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="random-msg-container">
|
||||
<div className="random-msg">
|
||||
{randomMsg && (
|
||||
<small>
|
||||
<i>{randomMsg}</i>
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
id="new-msg"
|
||||
aria-label="New footer message"
|
||||
type="button"
|
||||
className="flex items-center justify-center px-2 py-1 rounded-md hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-opacity new-msg-button"
|
||||
onClick={getRandomMsg}
|
||||
>
|
||||
<ReplayIcon fontSize="small" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
32
src/components/TRip/BreadcrumbNav.jsx
Normal file
32
src/components/TRip/BreadcrumbNav.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
|
||||
export default function BreadcrumbNav({ currentPage }) {
|
||||
const pages = [
|
||||
{ key: "request", label: "Request Media", href: "/TRip" },
|
||||
{ key: "management", label: "Manage Requests", href: "/TRip/requests" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" className="mb-6 flex gap-4 text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
{pages.map(({ key, label, href }, i) => {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<a
|
||||
href={href}
|
||||
className={`${currentPage === key
|
||||
? "!font-bold underline" // active: always underlined + bold
|
||||
: "hover:underline" // inactive: underline only on hover
|
||||
}`}
|
||||
aria-current={currentPage === key ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{i < pages.length - 1 && <span aria-hidden="true">/</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav >
|
||||
</div>
|
||||
);
|
||||
}
|
669
src/components/TRip/MediaRequestForm.jsx
Normal file
669
src/components/TRip/MediaRequestForm.jsx
Normal file
@@ -0,0 +1,669 @@
|
||||
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Button } from "@mui/joy";
|
||||
import { Accordion, AccordionTab } from "primereact/accordion";
|
||||
import { AutoComplete } from "primereact/autocomplete";
|
||||
import { authFetch } from "@/utils/authFetch";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL, ENVIRONMENT } from "@/config";
|
||||
|
||||
export default function MediaRequestForm() {
|
||||
const [type, setType] = useState("artist");
|
||||
const [selectedArtist, setSelectedArtist] = useState(null);
|
||||
const [artistInput, setArtistInput] = useState("");
|
||||
const [albumInput, setAlbumInput] = useState("");
|
||||
const [trackInput, setTrackInput] = useState("");
|
||||
const [quality, setQuality] = useState("FLAC"); // default FLAC
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [albums, setAlbums] = useState([]);
|
||||
const [tracksByAlbum, setTracksByAlbum] = useState({});
|
||||
const [selectedTracks, setSelectedTracks] = useState({});
|
||||
const [artistSuggestions, setArtistSuggestions] = useState([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [loadingAlbumId, setLoadingAlbumId] = useState(null);
|
||||
const [expandedAlbums, setExpandedAlbums] = useState([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
|
||||
const debounceTimeout = useRef(null);
|
||||
const autoCompleteRef = useRef(null);
|
||||
const metadataFetchToastId = useRef(null);
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays
|
||||
|
||||
|
||||
const Spinner = () => (
|
||||
<span
|
||||
className="inline-block ml-2 h-4 w-4 border-2 border-t-2 border-gray-300 border-t-blue-600 rounded-full animate-spin"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
);
|
||||
|
||||
// Fetch artist suggestions for autocomplete
|
||||
const searchArtists = (e) => {
|
||||
const query = e.query.trim();
|
||||
if (!query) {
|
||||
setArtistSuggestions([]);
|
||||
setSelectedArtist(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimeout.current) clearTimeout(debounceTimeout.current);
|
||||
|
||||
debounceTimeout.current = setTimeout(async () => {
|
||||
try {
|
||||
// Ensure at least 600ms between actual requests
|
||||
const now = Date.now();
|
||||
if (!searchArtists.lastCall) searchArtists.lastCall = 0;
|
||||
const elapsed = now - searchArtists.lastCall;
|
||||
const minDelay = 600; // ms
|
||||
if (elapsed < minDelay) await delay(minDelay - elapsed);
|
||||
|
||||
searchArtists.lastCall = Date.now();
|
||||
|
||||
const res = await authFetch(
|
||||
`${API_URL}/trip/get_artists_by_name?artist=${encodeURIComponent(query)}`
|
||||
);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
setArtistSuggestions(data);
|
||||
} catch (err) {
|
||||
toast.error("Failed to fetch artist suggestions.");
|
||||
setArtistSuggestions([]);
|
||||
}
|
||||
}, 500); // debounce 500ms
|
||||
};
|
||||
|
||||
|
||||
const truncate = (text, maxLen) =>
|
||||
maxLen <= 3
|
||||
? text.slice(0, maxLen)
|
||||
: text.length <= maxLen
|
||||
? text
|
||||
: text.slice(0, maxLen - 3) + '...';
|
||||
|
||||
const artistItemTemplate = (artist) => {
|
||||
if (!artist) return null;
|
||||
return <div>{truncate(artist.artist, 58)}</div>;
|
||||
};
|
||||
|
||||
|
||||
// Handle autocomplete input changes (typing/selecting)
|
||||
const handleArtistChange = (e) => {
|
||||
if (typeof e.value === "string") {
|
||||
setArtistInput(e.value);
|
||||
setSelectedArtist(null);
|
||||
} else if (e.value && typeof e.value === "object") {
|
||||
setSelectedArtist(e.value);
|
||||
setArtistInput(e.value.artist);
|
||||
} else {
|
||||
setArtistInput("");
|
||||
setSelectedArtist(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Search button click handler
|
||||
const handleSearch = async () => {
|
||||
toast.dismiss();
|
||||
setIsSearching(true);
|
||||
try {
|
||||
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
metadataFetchToastId.current = toast.info("Retrieving metadata...",
|
||||
{
|
||||
autoClose: false,
|
||||
progress: 0,
|
||||
closeOnClick: false,
|
||||
}
|
||||
);
|
||||
if (type === "artist") {
|
||||
if (!selectedArtist) {
|
||||
toast.error("Please select a valid artist from suggestions.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem(selectedArtist.artist);
|
||||
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
|
||||
);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
|
||||
data.sort((a, b) =>
|
||||
(b.release_date || "").localeCompare(a.release_date || "")
|
||||
);
|
||||
|
||||
setAlbums(data);
|
||||
setTracksByAlbum({});
|
||||
setExpandedAlbums([]);
|
||||
|
||||
// Set selectedTracks for all albums as null (means tracks loading/not loaded)
|
||||
setSelectedTracks(
|
||||
data.reduce((acc, album) => {
|
||||
acc[album.id] = null;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to fetch albums for artist.");
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
}
|
||||
} else if (type === "album") {
|
||||
if (!artistInput.trim() || !albumInput.trim()) {
|
||||
toast.error("Artist and Album are required.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setSelectedItem(`${artistInput} - ${albumInput}`);
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
} else if (type === "track") {
|
||||
if (!artistInput.trim() || !trackInput.trim()) {
|
||||
toast.error("Artist and Track are required.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
setSelectedItem(`${artistInput} - ${trackInput}`);
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const handleTrackClick = async (trackId, artist, title) => {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch track URL");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.stream_url) {
|
||||
// Use plain fetch for public resource
|
||||
const fileResponse = await fetch(data.stream_url, {
|
||||
method: "GET",
|
||||
mode: "cors", // ensure cross-origin is allowed
|
||||
credentials: "omit", // do NOT send cookies or auth
|
||||
});
|
||||
|
||||
if (!fileResponse.ok) {
|
||||
throw new Error(`Failed to fetch track file: ${fileResponse.status}`);
|
||||
}
|
||||
|
||||
const blob = await fileResponse.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
|
||||
const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_");
|
||||
const filename = `${sanitize(artist)} - ${sanitize(title)}.flac`;
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
} else {
|
||||
toast.error("No stream URL returned for this track.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to get track download URL.");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0);
|
||||
|
||||
const handleToggleAllAlbums = () => {
|
||||
const allSelected = albums.every(({ id }) => {
|
||||
const allTracks = tracksByAlbum[id] || [];
|
||||
return selectedTracks[id]?.length === allTracks.length && allTracks.length > 0;
|
||||
});
|
||||
|
||||
const newSelection = {};
|
||||
albums.forEach(({ id }) => {
|
||||
const allTracks = tracksByAlbum[id] || [];
|
||||
if (allSelected) {
|
||||
// Uncheck all
|
||||
newSelection[id] = [];
|
||||
} else {
|
||||
// Check all tracks in the album
|
||||
newSelection[id] = allTracks.map(track => String(track.id));
|
||||
}
|
||||
});
|
||||
setSelectedTracks(newSelection);
|
||||
};
|
||||
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!allTracksLoaded) return; // prevent clicking before data ready
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className={`text-sm hover:underline cursor-pointer ${!allTracksLoaded ? "text-gray-400 dark:text-gray-500 pointer-events-none" : "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
|
||||
|
||||
|
||||
// Sequentially fetch tracks for albums not loaded yet
|
||||
useEffect(() => {
|
||||
if (type !== "artist" || albums.length === 0) return;
|
||||
|
||||
let isCancelled = false;
|
||||
const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]);
|
||||
if (albumsToFetch.length === 0) return;
|
||||
|
||||
const fetchTracksSequentially = async () => {
|
||||
const minDelay = 650; // ms between API requests
|
||||
setIsFetching(true);
|
||||
|
||||
const totalAlbums = albumsToFetch.length;
|
||||
|
||||
for (let index = 0; index < totalAlbums; index++) {
|
||||
const album = albumsToFetch[index];
|
||||
|
||||
if (isCancelled) break;
|
||||
|
||||
setLoadingAlbumId(album.id);
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0;
|
||||
const elapsed = now - fetchTracksSequentially.lastCall;
|
||||
if (elapsed < minDelay) await delay(minDelay - elapsed);
|
||||
fetchTracksSequentially.lastCall = Date.now();
|
||||
|
||||
const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
|
||||
if (isCancelled) break;
|
||||
|
||||
setTracksByAlbum((prev) => ({ ...prev, [album.id]: data }));
|
||||
setSelectedTracks((prev) => ({
|
||||
...prev,
|
||||
[album.id]: data.map((t) => t.id),
|
||||
}));
|
||||
} catch (err) {
|
||||
toast.error(`Failed to fetch tracks for album ${album.album}.`);
|
||||
setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] }));
|
||||
setSelectedTracks((prev) => ({ ...prev, [album.id]: [] }));
|
||||
}
|
||||
|
||||
// Update progress toast
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
progress: (index + 1) / totalAlbums,
|
||||
render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`,
|
||||
});
|
||||
}
|
||||
|
||||
setLoadingAlbumId(null);
|
||||
setIsFetching(false);
|
||||
|
||||
// Finish the toast
|
||||
toast.update(metadataFetchToastId.current, {
|
||||
render: "Metadata retrieved!",
|
||||
type: "success",
|
||||
progress: 1,
|
||||
autoClose: 1500,
|
||||
});
|
||||
};
|
||||
|
||||
fetchTracksSequentially();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [albums, type]);
|
||||
|
||||
|
||||
|
||||
// Toggle individual track checkbox
|
||||
const toggleTrack = (albumId, trackId) => {
|
||||
setSelectedTracks((prev) => {
|
||||
const current = new Set(prev[albumId] || []);
|
||||
if (current.has(String(trackId))) current.delete(String(trackId));
|
||||
else current.add(String(trackId));
|
||||
return { ...prev, [albumId]: Array.from(current) };
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle album checkbox (select/deselect all tracks in album)
|
||||
const toggleAlbum = (albumId) => {
|
||||
const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || [];
|
||||
setSelectedTracks((prev) => {
|
||||
const current = prev[albumId] || [];
|
||||
const allSelected = current.length === allTracks.length;
|
||||
return {
|
||||
...prev,
|
||||
[albumId]: allSelected ? [] : [...allTracks],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Attach scroll fix for autocomplete panel
|
||||
const attachScrollFix = () => {
|
||||
setTimeout(() => {
|
||||
const panel = document.querySelector(".p-autocomplete-panel");
|
||||
const items = panel?.querySelector(".p-autocomplete-items");
|
||||
if (items) {
|
||||
items.style.maxHeight = "200px";
|
||||
items.style.overflowY = "auto";
|
||||
items.style.overscrollBehavior = "contain";
|
||||
const wheelHandler = (e) => {
|
||||
const delta = e.deltaY;
|
||||
const atTop = items.scrollTop === 0;
|
||||
const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight;
|
||||
if ((delta < 0 && atTop) || (delta > 0 && atBottom)) {
|
||||
e.preventDefault();
|
||||
} else {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
items.removeEventListener("wheel", wheelHandler);
|
||||
items.addEventListener("wheel", wheelHandler, { passive: false });
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Submit request handler with progress indicator
|
||||
const handleSubmitRequest = async () => {
|
||||
if (isFetching) {
|
||||
// tracks are not done being fetched
|
||||
return toast.error("Still fetching track metadata, please wait a moment.");
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const allSelectedIds = Object.values(selectedTracks)
|
||||
.filter(arr => Array.isArray(arr)) // skip null entries
|
||||
.flat();
|
||||
|
||||
const response = await authFetch(`${API_URL}/trip/bulk_fetch`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
track_ids: allSelectedIds,
|
||||
target: selectedArtist.artist,
|
||||
quality: quality,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
toast.success(`Request submitted! (${allSelectedIds.length} tracks)`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to submit request.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto my-10 p-6 rounded-xl shadow-md bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700">
|
||||
<style>{`
|
||||
/* Accordion tab backgrounds & text */
|
||||
.p-accordion-tab {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-tab {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Accordion header link */
|
||||
.p-accordion-header .p-accordion-header-link {
|
||||
background-color: #f9f9f9;
|
||||
color: #000000;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-header .p-accordion-header-link {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Accordion content panel */
|
||||
.p-accordion-content {
|
||||
background-color: #fafafa;
|
||||
color: #000000;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-content {
|
||||
background-color: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Track list UL/LI styling */
|
||||
.p-accordion-content ul {
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-content ul {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Track items */
|
||||
.p-accordion-content li {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-content li {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
/* Checkboxes inside track list */
|
||||
.p-accordion-content input[type="checkbox"] {
|
||||
accent-color: #1d4ed8; /* optional for consistent dark mode styling */
|
||||
}
|
||||
|
||||
/* Loading spinner (optional darker style) */
|
||||
[data-theme="dark"] .animate-spin {
|
||||
border-color: #555;
|
||||
border-top-color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Small text like audio quality, duration, version */
|
||||
.p-accordion-content span {
|
||||
color: #555;
|
||||
}
|
||||
[data-theme="dark"] .p-accordion-content span {
|
||||
color: #aaa;
|
||||
}
|
||||
`}</style>
|
||||
<BreadcrumbNav currentPage="request" />
|
||||
<h2 className="text-3xl font-semibold mt-0">New Request</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<label htmlFor="artistInput">Artist: </label>
|
||||
<AutoComplete
|
||||
id={artistInput}
|
||||
ref={autoCompleteRef}
|
||||
value={selectedArtist || artistInput}
|
||||
suggestions={artistSuggestions}
|
||||
field="artist"
|
||||
completeMethod={searchArtists}
|
||||
onChange={handleArtistChange}
|
||||
minLength={3}
|
||||
placeholder="Artist"
|
||||
dropdown
|
||||
className="w-full"
|
||||
inputClassName="w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 text-black dark:text-white dark:bg-neutral-800"
|
||||
onShow={attachScrollFix}
|
||||
itemTemplate={artistItemTemplate}
|
||||
/>
|
||||
|
||||
{(type === "album" || type === "track") && (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
|
||||
value={type === "album" ? albumInput : trackInput}
|
||||
onChange={(e) =>
|
||||
type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
|
||||
}
|
||||
placeholder={type === "album" ? "Album" : "Track"}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-4 justify-end mt-2">
|
||||
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
||||
Quality:
|
||||
</label>
|
||||
<select
|
||||
id="qualitySelect"
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(e.target.value)}
|
||||
className="border border-neutral-300 dark:border-neutral-600 rounded px-2 py-1 bg-white dark:bg-neutral-800 text-black dark:text-white"
|
||||
>
|
||||
<option value="FLAC">FLAC</option>
|
||||
<option value="Lossy">Lossy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSearch} disabled={isSearching}>
|
||||
{isSearching ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||
Searching...
|
||||
</span>
|
||||
) : (
|
||||
"Search"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{type === "artist" && albums.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // prevent page jump
|
||||
handleToggleAllAlbums();
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||
>
|
||||
Check / Uncheck All Albums
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<Accordion
|
||||
multiple
|
||||
className="mt-4"
|
||||
activeIndex={expandedAlbums}
|
||||
onTabChange={(e) => setExpandedAlbums(e.index)}
|
||||
>
|
||||
{albums.map(({ album, id, release_date }) => {
|
||||
const allTracks = tracksByAlbum[id] || [];
|
||||
const selected = selectedTracks[id];
|
||||
|
||||
// Album checkbox is checked if tracks not loaded (selected === null)
|
||||
// or all tracks loaded and all selected
|
||||
const allChecked =
|
||||
selected === null || (selected?.length === allTracks.length && allTracks.length > 0);
|
||||
const someChecked =
|
||||
selected !== null && selected.length > 0 && selected.length < allTracks.length;
|
||||
|
||||
return (
|
||||
<AccordionTab
|
||||
key={id}
|
||||
header={
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChecked;
|
||||
}}
|
||||
onChange={() => toggleAlbum(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="cursor-pointer"
|
||||
aria-label={`Select all tracks for album ${album}`}
|
||||
/>
|
||||
<span className="flex items-center" title={album}>
|
||||
{truncate(album, 32)}
|
||||
{loadingAlbumId === id && <Spinner />}
|
||||
</span>
|
||||
<small className="ml-2 text-neutral-500 dark:text-neutral-400">({release_date})</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
>
|
||||
{allTracks.length > 0 ? (
|
||||
<ul className="text-sm">
|
||||
{allTracks.map((track) => (
|
||||
<li key={track.id} className="py-1 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected?.includes(String(track.id))}
|
||||
onChange={() => toggleTrack(id, track.id)}
|
||||
className="cursor-pointer"
|
||||
aria-label={`Select track ${track.title} `}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTrackClick(track.id, selectedArtist.artist, track.title)}
|
||||
className="font-medium text-blue-600 hover:underline cursor-pointer bg-transparent border-none p-0"
|
||||
aria-label={`Download track ${track.title} `}
|
||||
>
|
||||
{truncate(track.title, 80)}
|
||||
</button>
|
||||
<span className="text-xs text-neutral-500">{quality}</span>
|
||||
{track.version && (
|
||||
<span className="text-xs text-neutral-400">({track.version})</span>
|
||||
)}
|
||||
{track.duration && (
|
||||
<span className="text-xs text-neutral-500 ml-auto tabular-nums">{track.duration}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-sm italic text-neutral-600 dark:text-neutral-400">
|
||||
{tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
|
||||
</div>
|
||||
)}
|
||||
</AccordionTab>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSubmitRequest} color="primary" className="mt-4" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||
Submitting...
|
||||
</span>
|
||||
) : (
|
||||
"Submit Request"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
445
src/components/TRip/RequestManagement.jsx
Normal file
445
src/components/TRip/RequestManagement.jsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { DataTable } from "primereact/datatable";
|
||||
import { Column } from "primereact/column";
|
||||
import { Dropdown } from "primereact/dropdown";
|
||||
import { Button } from "@mui/joy";
|
||||
import { Dialog } from "primereact/dialog";
|
||||
import { authFetch } from "@/utils/authFetch";
|
||||
import { confirmDialog, ConfirmDialog } from "primereact/confirmdialog";
|
||||
import BreadcrumbNav from "./BreadcrumbNav";
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||
const TAR_BASE_URL = "https://codey.lol/m/m2"; // configurable prefix
|
||||
|
||||
export default function RequestManagement() {
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [filterType, setFilterType] = useState(null);
|
||||
const [filterStatus, setFilterStatus] = useState(null);
|
||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||
const pollingRef = useRef(null);
|
||||
const pollingDetailRef = useRef(null);
|
||||
|
||||
|
||||
const tarballUrl = (absPath, quality) => {
|
||||
if (!absPath) return null;
|
||||
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
||||
return `${TAR_BASE_URL}/${quality}/${filename}`;
|
||||
};
|
||||
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/trip/jobs/list`);
|
||||
if (!res.ok) throw new Error("Failed to fetch jobs");
|
||||
const data = await res.json();
|
||||
setRequests(Array.isArray(data.jobs) ? data.jobs : []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!toast.isActive('fetch-fail-toast')) {
|
||||
toast.error("Failed to fetch jobs list", {
|
||||
toastId: 'fetch-fail-toast',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobDetail = async (jobId) => {
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/trip/job/${jobId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch job details");
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!toast.isActive('fetch-job-fail-toast')) {
|
||||
toast.error("Failed to fetch job details",
|
||||
{
|
||||
toastId: "fetch-job-fail-toast",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchJobs(); }, []);
|
||||
useEffect(() => {
|
||||
if (isDialogVisible && selectedRequest) {
|
||||
// Start polling
|
||||
const poll = async () => {
|
||||
const updated = await fetchJobDetail(selectedRequest.id);
|
||||
if (updated) setSelectedRequest(updated);
|
||||
};
|
||||
|
||||
pollingDetailRef.current = setInterval(poll, 1500);
|
||||
|
||||
return () => {
|
||||
if (pollingDetailRef.current) {
|
||||
clearInterval(pollingDetailRef.current);
|
||||
pollingDetailRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isDialogVisible, selectedRequest?.id]);
|
||||
useEffect(() => {
|
||||
const hasActive = requests.some((j) => ["Queued", "Started", "Compressing"].includes(j.status));
|
||||
if (hasActive && !pollingRef.current) pollingRef.current = setInterval(fetchJobs, 1500);
|
||||
else if (!hasActive && pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
return () => { if (pollingRef.current) clearInterval(pollingRef.current); pollingRef.current = null; };
|
||||
}, [requests]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = requests.filter((r) => {
|
||||
const typeMatch = !filterType || r.type === filterType;
|
||||
const statusMatch = filterStatus === "all" || filterStatus === null || r.status === filterStatus;
|
||||
return typeMatch && statusMatch;
|
||||
});
|
||||
setFilteredRequests(filtered);
|
||||
}, [filterType, filterStatus, requests]);
|
||||
|
||||
|
||||
const getStatusColorClass = (status) => {
|
||||
switch (status) {
|
||||
case "Queued": return "bg-yellow-700 text-white";
|
||||
case "Started": return "bg-blue-700 text-white";
|
||||
case "Compressing": return "bg-orange-700 text-white";
|
||||
case "Finished": return "bg-green-700 text-white";
|
||||
case "Failed": return "bg-red-700 text-white";
|
||||
default: return "bg-gray-700 text-white";
|
||||
}
|
||||
};
|
||||
|
||||
const getQualityColorClass = (quality) => {
|
||||
switch (quality) {
|
||||
case "FLAC": return "bg-green-700 text-white";
|
||||
case "Lossy": return "bg-yellow-700 text-white";
|
||||
default: return "bg-gray-700 text-white";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const statusBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getStatusColorClass(rowData.status)}`}>
|
||||
{rowData.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
const qualityBodyTemplate = (rowData) => (
|
||||
<span className={`inline-block px-3 py-1 rounded-full font-semibold text-sm ${getQualityColorClass(rowData.quality)}`}>
|
||||
{rowData.quality}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
const safeText = (val) => (val === 0 ? "0" : val || "—");
|
||||
const textWithEllipsis = (val, width = "12rem") => (
|
||||
<span className="truncate block" style={{ maxWidth: width }} title={val || ""}>{val || "—"}</span>
|
||||
);
|
||||
|
||||
const truncate = (text, maxLen) =>
|
||||
maxLen <= 3
|
||||
? text.slice(0, maxLen)
|
||||
: text.length <= maxLen
|
||||
? text
|
||||
: text.slice(0, maxLen - 3) + '...';
|
||||
|
||||
|
||||
const basename = (p) => (typeof p === "string" ? p.split("/").pop() : "");
|
||||
|
||||
const formatProgress = (p) => {
|
||||
if (p === null || p === undefined || p === "") return "—";
|
||||
const num = Number(p);
|
||||
if (Number.isNaN(num)) return "—";
|
||||
const pct = num > 1 ? Math.round(num) : num;
|
||||
return `${pct}%`;
|
||||
};
|
||||
|
||||
const confirmDelete = (requestId) => {
|
||||
confirmDialog({
|
||||
message: "Are you sure you want to delete this request?",
|
||||
header: "Confirm Delete",
|
||||
icon: "pi pi-exclamation-triangle",
|
||||
accept: () => deleteRequest(requestId),
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRequest = (requestId) => {
|
||||
setRequests((prev) => prev.filter((r) => r.id !== requestId));
|
||||
toast.success("Request deleted");
|
||||
};
|
||||
|
||||
const actionBodyTemplate = (rowData) => (
|
||||
<Button
|
||||
color="neutral"
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
sx={{
|
||||
color: "#e5e7eb",
|
||||
borderColor: "#6b7280",
|
||||
'&:hover': { backgroundColor: '#374151', borderColor: '#9ca3af' },
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(rowData.id); }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
|
||||
const handleRowClick = async (e) => {
|
||||
const detail = await fetchJobDetail(e.data.id);
|
||||
if (detail) { setSelectedRequest(detail); setIsDialogVisible(true); }
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-max my-10 p-6 rounded-xl shadow-md
|
||||
bg-white dark:bg-neutral-900
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
border border-neutral-200 dark:border-neutral-700
|
||||
sm:p-4 md:p-6">
|
||||
<style>{`
|
||||
/* Table and Dark Overrides */
|
||||
.p-datatable {
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
.p-datatable td span.truncate {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-thead > tr > th {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr {
|
||||
background-color: #1a1a1a !important;
|
||||
border-bottom: 1px solid #374151;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:nth-child(odd) {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-datatable-tbody > tr:hover {
|
||||
background-color: #333 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Paginator Dark Mode */
|
||||
[data-theme="dark"] .p-paginator {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-first,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-last {
|
||||
color: #e5e7eb !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-paginator-page:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-next:hover,
|
||||
[data-theme="dark"] .p-paginator .p-paginator-prev:hover {
|
||||
background-color: #374151 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
[data-theme="dark"] .p-paginator .p-highlight {
|
||||
background-color: #6b7280 !important;
|
||||
color: #fff !important;
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Dark mode for PrimeReact Dialog */
|
||||
[data-theme="dark"] .p-dialog {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-header {
|
||||
background-color: #121212 !important;
|
||||
color: #e5e7eb !important;
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-content {
|
||||
background-color: #1a1a1a !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .p-dialog .p-dialog-footer {
|
||||
background-color: #121212 !important;
|
||||
border-top: 1px solid #374151 !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<BreadcrumbNav currentPage="management" />
|
||||
<h2 className="text-3xl font-semibold mt-0">Media Request Management</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-6 mb-6">
|
||||
<Dropdown
|
||||
value={filterStatus}
|
||||
options={[{ label: "All Statuses", value: "all" }, ...STATUS_OPTIONS.map((s) => ({ label: s, value: s }))]}
|
||||
onChange={(e) => setFilterStatus(e.value)}
|
||||
placeholder="Filter by Status"
|
||||
className="min-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-max overflow-x-auto rounded-lg">
|
||||
<DataTable
|
||||
value={filteredRequests}
|
||||
paginator
|
||||
rows={10}
|
||||
removableSort
|
||||
sortMode="multiple"
|
||||
emptyMessage="No requests found."
|
||||
onRowClick={handleRowClick}
|
||||
>
|
||||
|
||||
<Column
|
||||
field="id"
|
||||
header="ID"
|
||||
style={{ width: "6rem" }}
|
||||
body={(row) => (
|
||||
<span title={row.id}>
|
||||
{row.id.split("-").slice(-1)[0]}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Column field="target" header="Target" sortable style={{ width: "12rem" }} body={(row) => textWithEllipsis(row.target, "10rem")} />
|
||||
<Column field="tracks" header="# Tracks" style={{ width: "8rem" }} body={(row) => row.tracks} />
|
||||
<Column field="status" header="Status" body={statusBodyTemplate} style={{ width: "10rem", textAlign: "center" }} sortable />
|
||||
<Column field="progress" header="Progress" body={(row) => formatProgress(row.progress)} style={{ width: "8rem", textAlign: "center" }} sortable />
|
||||
<Column
|
||||
field="quality"
|
||||
header="Quality"
|
||||
body={qualityBodyTemplate}
|
||||
style={{ width: "6rem", textAlign: "center" }}
|
||||
sortable />
|
||||
<Column
|
||||
field="tarball"
|
||||
header={
|
||||
<span className="flex items-center">
|
||||
<i className="pi pi-download mr-1" /> {/* download icon in header */}
|
||||
Tarball
|
||||
</span>
|
||||
}
|
||||
body={(row) => {
|
||||
const url = tarballUrl(row.tarball, row.quality || "FLAC");
|
||||
const encodedURL = encodeURI(url);
|
||||
if (!url) return "—";
|
||||
|
||||
const fileName = url.split("/").pop();
|
||||
|
||||
return (
|
||||
<a
|
||||
href={encodedURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-500 hover:underline"
|
||||
title={fileName}
|
||||
>
|
||||
{truncate(fileName, 16)}
|
||||
</a>
|
||||
);
|
||||
}}
|
||||
style={{ width: "10rem" }}
|
||||
/>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
|
||||
<Dialog
|
||||
header="Request Details"
|
||||
visible={isDialogVisible}
|
||||
style={{ width: "500px" }}
|
||||
onHide={() => setIsDialogVisible(false)}
|
||||
breakpoints={{ "960px": "95vw" }}
|
||||
modal
|
||||
dismissableMask
|
||||
className="dark:bg-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{selectedRequest ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
|
||||
{/* --- Metadata Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
|
||||
{selectedRequest.id && <p className="col-span-2 break-all"><strong>ID:</strong> {selectedRequest.id}</p>}
|
||||
{selectedRequest.target && <p><strong>Target:</strong> {selectedRequest.target}</p>}
|
||||
{selectedRequest.tracks && <p><strong># Tracks:</strong> {selectedRequest.tracks}</p>}
|
||||
{selectedRequest.quality && (
|
||||
<p>
|
||||
<strong>Quality:</strong>{" "}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getQualityColorClass(selectedRequest.quality)}`}>
|
||||
{selectedRequest.quality}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Status / Progress Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-2 gap-4">
|
||||
{selectedRequest.status && (
|
||||
<p>
|
||||
<strong>Status:</strong>{" "}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${getStatusColorClass(selectedRequest.status)}`}>
|
||||
{selectedRequest.status}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{selectedRequest.progress !== undefined && selectedRequest.progress !== null && (
|
||||
<p><strong>Progress:</strong> {formatProgress(selectedRequest.progress)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Timestamps Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 gap-2">
|
||||
{selectedRequest.enqueued_at && <p><strong>Enqueued:</strong> {new Date(selectedRequest.enqueued_at).toLocaleString()}</p>}
|
||||
{selectedRequest.started_at && <p><strong>Started:</strong> {new Date(selectedRequest.started_at).toLocaleString()}</p>}
|
||||
{selectedRequest.ended_at && <p><strong>Ended:</strong> {new Date(selectedRequest.ended_at).toLocaleString()}</p>}
|
||||
</div>
|
||||
|
||||
{/* --- Tarball Card --- */}
|
||||
{
|
||||
selectedRequest.tarball && (
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
|
||||
<p>
|
||||
<strong>Tarball:</strong>{" "}
|
||||
<a
|
||||
href={encodeURI(tarballUrl(selectedRequest.tarball, selectedRequest.quality))}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{tarballUrl(selectedRequest.tarball, selectedRequest.quality).split("/").pop()}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</div >
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
</Dialog >
|
||||
|
||||
|
||||
</div >
|
||||
);
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
|
@@ -1,20 +0,0 @@
|
||||
---
|
||||
import YT from "react-youtube";
|
||||
|
||||
interface Props {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
const { videoId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative w-full h-0 pb-[56.25%] my-6">
|
||||
<YT
|
||||
opts={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
videoId={videoId}
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
@@ -8,7 +8,10 @@ export const metaData = {
|
||||
"codey.lol",
|
||||
};
|
||||
|
||||
export const api = "https://api.codey.lol";
|
||||
export const API_URL = api;
|
||||
export const API_URL = "https://api.codey.lol";
|
||||
export const socialLinks = {
|
||||
};
|
||||
|
||||
export const MAJOR_VERSION = "0.1"
|
||||
export const RELEASE_FLAG = null;
|
||||
export const ENVIRONMENT = import.meta.env.DEV ? "Dev" : "Prod";
|
49
src/hooks/requireAuthHook.js
Normal file
49
src/hooks/requireAuthHook.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// requireAuthHook.js
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
export const requireAuthHook = async (Astro) => {
|
||||
try {
|
||||
const cookieHeader = Astro.request.headers.get("cookie") ?? "";
|
||||
let res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: cookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setCookieHeader = refreshRes.headers.get("set-cookie");
|
||||
let newCookieHeader = cookieHeader;
|
||||
|
||||
if (setCookieHeader) {
|
||||
const cookiesArray = setCookieHeader.split(/,(?=\s*\w+=)/);
|
||||
cookiesArray.forEach((c) => Astro.response.headers.append("set-cookie", c));
|
||||
|
||||
newCookieHeader = cookiesArray.map(c => c.split(";")[0]).join("; ");
|
||||
}
|
||||
|
||||
res = await fetch(`${API_URL}/auth/id`, {
|
||||
headers: { Cookie: newCookieHeader },
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const user = await res.json();
|
||||
return user;
|
||||
|
||||
} catch (err) {
|
||||
console.error("[SSR] requireAuthHook error:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
@@ -10,8 +10,10 @@ export function usePrimeReactThemeSwitcher(theme) {
|
||||
? "/themes/bootstrap4-dark-blue/theme.css"
|
||||
: "/themes/bootstrap4-light-blue/theme.css";
|
||||
|
||||
if (themeLink.href !== newTheme) {
|
||||
themeLink.href = newTheme;
|
||||
const newHref = window.location.origin + newTheme;
|
||||
|
||||
if (themeLink.href !== newHref) {
|
||||
themeLink.href = newHref;
|
||||
}
|
||||
}, [theme]);
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ import Themes from "astro-themes";
|
||||
import { ViewTransitions } from "astro:transitions";
|
||||
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import Navbar from "../components/Nav.astro";
|
||||
import Navbar from "./Nav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
|
||||
import "@fontsource/geist-sans/400.css";
|
||||
@@ -43,13 +43,13 @@ const { title, description, image } = Astro.props;
|
||||
class="antialiased flex flex-col items-center justify-center mx-auto mt-2 lg:mt-8 mb-20 lg:mb-40
|
||||
scrollbar-hide">
|
||||
<main
|
||||
class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-[640px] w-full">
|
||||
class="flex-auto min-w-0 mt-2 md:mt-6 flex flex-col px-6 sm:px-4 md:px-0 max-w-3xl w-full">
|
||||
<noscript>
|
||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
</div>
|
||||
</noscript>
|
||||
<Navbar />
|
||||
<Navbar />
|
||||
<slot />
|
||||
<Footer />
|
||||
</main>
|
||||
|
@@ -6,17 +6,17 @@ import ExitToApp from '@mui/icons-material/ExitToApp';
|
||||
const navItems = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Radio", href: "/radio" },
|
||||
{ blockSeparator: true },
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "TRip", href: "/TRip", auth: true },
|
||||
{ label: "Status", href: "https://status.boatson.boats", icon: ExitToApp },
|
||||
{ label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
||||
{ label: "Old Site", href: "https://old.codey.lol", icon: ExitToApp },
|
||||
// { label: "Git", href: "https://kode.boatson.boats", icon: ExitToApp },
|
||||
];
|
||||
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
<script is:inline>
|
||||
toggleTheme = () => {
|
||||
console.log("Toggle!")
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||
const newTheme = currentTheme === "dark" ? "light" : "dark";
|
||||
document.dispatchEvent(new CustomEvent("set-theme", { detail: newTheme }));
|
||||
@@ -30,20 +30,28 @@ const currentPath = Astro.url.pathname;
|
||||
|
||||
<ul class="flex flex-wrap items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{navItems.map((item, index) => {
|
||||
if (item.blockSeparator) {
|
||||
return (
|
||||
<li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
|
||||
‖
|
||||
</li>
|
||||
);
|
||||
}
|
||||
// if (item.blockSeparator) {
|
||||
// return (
|
||||
// <li class="text-neutral-500 dark:text-neutral-500 px-2 select-none" aria-hidden="true">
|
||||
// ‖
|
||||
// </li>
|
||||
// );
|
||||
// }
|
||||
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const normalize = (url) => url?.replace(/\/+$/, '') || '/';
|
||||
const isActive = !isExternal && normalize(item.href) === normalize(currentPath);
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(currentPath);
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/' // Home only matches exact /
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
|
||||
|
||||
const nextItem = navItems[index + 1];
|
||||
const shouldShowThinBar = nextItem && !nextItem.blockSeparator;
|
||||
const shouldShowThinBar = nextItem //&& !nextItem.blockSeparator;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +62,7 @@ const currentPath = Astro.url.pathname;
|
||||
hover:bg-neutral-200 dark:hover:bg-neutral-800
|
||||
${isActive ? "font-semibold underline underline-offset-4" : ""}`}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
{item.icon === ExitToApp && <ExitToApp className="w-4 h-4" client:load />}
|
21
src/pages/TRip/index.astro
Normal file
21
src/pages/TRip/index.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import MediaRequestForm from "@/components/TRip/MediaRequestForm"
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.MediaRequestForm" client:only="react">
|
||||
</Root>
|
||||
</section>
|
||||
</Base>
|
22
src/pages/TRip/requests.astro
Normal file
22
src/pages/TRip/requests.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import MediaRequestForm from "@/components/TRip/MediaRequestForm"
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
import { verifyToken } from "@/utils/jwt";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
import { ENVIRONMENT } from "@/config";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
|
||||
if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="qs2.RequestManagement" client:only="react">
|
||||
</Root>
|
||||
</section>
|
||||
</Base>
|
12
src/pages/login.astro
Normal file
12
src/pages/login.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import LoginPage from '@/components/Login.jsx';
|
||||
import Base from "@/layouts/Base.astro";
|
||||
import Root from "@/components/AppLayout.jsx";
|
||||
---
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="LoginPage" client:only="react" >
|
||||
</Root>
|
||||
</section>
|
||||
</Base>
|
13
src/pages/memes.astro
Normal file
13
src/pages/memes.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/MemeGrid.css";
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Memes" client:only="react">
|
||||
</Root>
|
||||
</section>
|
||||
</Base>
|
@@ -1,15 +1,13 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Root from "../components/AppLayout.jsx";
|
||||
import "@styles/player.css";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
const user = await requireAuthHook(Astro);
|
||||
---
|
||||
|
||||
<Base>
|
||||
<section>
|
||||
<div class="prose prose-neutral dark:prose-invert">
|
||||
<Root child="Player" client:only="react">
|
||||
<Root child="Player" user={user} client:only="react">
|
||||
</Root>
|
||||
<script is:inline src="/scripts/jquery/dist/jquery.js" />
|
||||
<script is:inline src="/scripts/howler/dist/howler.js" />
|
||||
</section>
|
||||
</Base>
|
||||
|
52
src/utils/authFetch.js
Normal file
52
src/utils/authFetch.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { API_URL } from "@/config";
|
||||
|
||||
|
||||
// Auth fetch wrapper
|
||||
export const authFetch = async (url, options = {}, retry = true) => {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
credentials: "include", // cookie goes automatically
|
||||
});
|
||||
|
||||
if (res.status === 401 && retry) {
|
||||
// attempt refresh
|
||||
try {
|
||||
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!refreshRes.ok) throw new Error("Refresh failed");
|
||||
|
||||
// Retry original request once after refresh
|
||||
return authFetch(url, options, false);
|
||||
} catch (err) {
|
||||
console.error("Refresh token failed:", err);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// Refresh token function (HttpOnly cookie flow)
|
||||
export async function refreshAccessToken(cookieHeader) {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: cookieHeader || "", // forward cookies from the request
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to refresh token");
|
||||
}
|
||||
|
||||
// assume backend responds with new tokens in JSON
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.error("Refresh token failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
@@ -1,3 +1,10 @@
|
||||
import { generate, validate, parse, format } from "build-number-generator";
|
||||
import { MAJOR_VERSION, RELEASE_FLAG } from "../config";
|
||||
export const buildTime = new Date().toLocaleString(undefined, {
|
||||
timeZone: "UTC",
|
||||
});
|
||||
});
|
||||
|
||||
export var buildNumber = generate(MAJOR_VERSION);
|
||||
if (RELEASE_FLAG && RELEASE_FLAG.length) {
|
||||
buildNumber = `${buildNumber} (${RELEASE_FLAG})`;
|
||||
}
|
41
src/utils/jwt.js
Normal file
41
src/utils/jwt.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const secretFilePath = path.join(
|
||||
os.homedir(),
|
||||
'.config',
|
||||
'api_jwt_keys.json'
|
||||
);
|
||||
|
||||
// Load and parse keys JSON once at startup
|
||||
const keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
|
||||
|
||||
export function verifyToken(token) {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
if (!decoded?.header?.kid) {
|
||||
throw new Error('No kid in token header');
|
||||
}
|
||||
|
||||
const kid = decoded.header.kid;
|
||||
const key = keyFileData.keys[kid];
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`Unknown kid: ${kid}`);
|
||||
}
|
||||
|
||||
// Verify using the correct key and HS256 algo
|
||||
const payload = jwt.verify(token, key, { algorithms: ['HS256'] });
|
||||
return payload;
|
||||
|
||||
} catch (error) {
|
||||
console.error('JWT verification failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user