pax_global_header 0000666 0000000 0000000 00000000064 15164470742 0014524 g ustar 00root root 0000000 0000000 52 comment=33200e60f5a977c8afae051152dcc2ea85aa0cc7
sptlrx-1.3.1/ 0000775 0000000 0000000 00000000000 15164470742 0013062 5 ustar 00root root 0000000 0000000 sptlrx-1.3.1/.github/ 0000775 0000000 0000000 00000000000 15164470742 0014422 5 ustar 00root root 0000000 0000000 sptlrx-1.3.1/.github/workflows/ 0000775 0000000 0000000 00000000000 15164470742 0016457 5 ustar 00root root 0000000 0000000 sptlrx-1.3.1/.github/workflows/release.yml 0000664 0000000 0000000 00000001001 15164470742 0020612 0 ustar 00root root 0000000 0000000 name: goreleaser
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.26.1'
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sptlrx-1.3.1/.gitignore 0000664 0000000 0000000 00000000504 15164470742 0015051 0 ustar 00root root 0000000 0000000 # Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
dist/
# Built executable
sptlrx
# IDE files
.vscode/ sptlrx-1.3.1/.goreleaser.yaml 0000664 0000000 0000000 00000000617 15164470742 0016160 0 ustar 00root root 0000000 0000000 version: 2
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- freebsd
goarch:
- "386"
- amd64
- arm64
- arm
ignore:
- goos: windows
goarch: arm
archives:
- files:
- README.md
- LICENSE
- man/sptlrx.5
checksum:
name_template: "checksums.txt"
sptlrx-1.3.1/LICENSE 0000664 0000000 0000000 00000002046 15164470742 0014071 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2022 Denis
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.
sptlrx-1.3.1/Makefile 0000664 0000000 0000000 00000000207 15164470742 0014521 0 ustar 00root root 0000000 0000000 .PHONY: build test man
build:
go build -ldflags '-w -s'
test:
go test ./...
man:
go-md2man -in man/sptlrx.5.md -out man/sptlrx.5
sptlrx-1.3.1/README.md 0000664 0000000 0000000 00000014350 15164470742 0014344 0 ustar 00root root 0000000 0000000
Synchronized lyrics in your terminal
## Features
- Compatible with Spotify, MPD, Mopidy, MPRIS and browsers.
- Works well with long lines & Unicode characters.
- Easy to customize.
- Allows piping to stdout.
- Single binary & cross-plaftorm.
## Installation
**Linux**
- Arch Linux ([@BachoSeven](https://github.com/BachoSeven))
```sh
yay -S sptlrx-bin
```
- Debian / Ubuntu ([@mdosch](https://github.com/mdosch))
```sh
sudo apt install sptlrx
```
- NixOS ([@MoritzBoehme](https://github.com/MoritzBoehme))
```sh
nix-env -iA nixos.sptlrx
# or if using nixpkgs
nix-env -iA nixpkgs.sptlrx
```
**Windows**, **MacOS** & **Other**
Download the binary from the [Releases](https://github.com/raitonoberu/sptlrx/releases/latest) page or [build it yourself](./building.md).
## Configuration
Config file will be created at the first launch. On Linux it's located in `~/.config/sptlrx/config.yaml`. Run `sptlrx -h` to see the full path.
Show config contents (with descriptions)
```yaml
### Global settings ###
# Player that will be used. Possible values: spotify, mpd, mopidy, mpris.
player: spotify
# Whether to ignore errors instead of showing them.
ignoreErrors: true
# Interval of the internal timer. Determines how often the terminal will be updated.
timerInterval: 200
# Interval for checking the position. Doesn't really affect the precision.
updateInterval: 2000
### Style settings ###
style:
# Horizontal alignment of lines. Possible values: left, center, right.
hAlignment: center
# Style of the lines before the current one.
before:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: true
italic: false
underline: false
strikethrough: false
blink: false
faint: false
# Style of the current line.
current:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: true
italic: false
underline: false
strikethrough: false
blink: false
faint: false
# Style of the lines after the current one.
after:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: false
italic: false
underline: false
strikethrough: false
blink: false
faint: true
### Pipe settings ###
pipe:
# Maximum line length. 0 - unlimited.
length: 0
# How to handle overflowing strings. Possible values: word, none, ellipsis.
overflow: word
### MPD settings ###
mpd:
# MPD server address with port.
address: 127.0.0.1:6600
# MPD server password (if any).
password: ""
### Mopidy settings ###
mopidy:
# Mopidy server address with port.
address: 127.0.0.1:6680
### MPRIS settings ###
mpris:
# Whitelist of MPRIS players. First available is used if empty.
players: []
### Browser extension settings ###
browser:
# Port on which the server will be started.
port: 8974
### Local lyrics source ###
local:
# Folder for scanning .lrc files. Example: "~/Music".
folder: ""
```
### Spotify
```yaml
# config.yaml
player: spotify
```
If you want to use Spotify as your player, you will need to log in first.
1. Go to [developer.spotify.com](https://developer.spotify.com/dashboard), create a new app, and set the redirect URI to `http://127.0.0.1:8888/callback`. Grab your Client ID and Client Secret.
2. Run `sptlrx login`. You can pass Client ID and Client Secret in one of three ways:
- As environmental variables: `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`
- As CLI parameters: `--client-id` and `--client-secret`
- Interactively: run `sptlrx login` without providing credentials and you will be prompted to enter them
3. Spotify login page will open. Log in and wait for the success message.
You only need to do this once. Your credentials will then be saved to `$XDG_STATE_HOME/sptlrx/spotify-auth.json`.
### MPD
```yaml
# config.yaml
player: mpd
mpd:
address: 127.0.0.1:6600
password: ""
```
MPD server will be used as a player.
### Mopidy
```yaml
# config.yaml
player: mopidy
mopidy:
address: 127.0.0.1:6680
```
Mopidy server will be used as a player.
### MPRIS
```yaml
# config.yaml
player: mpris
mpris:
players: []
```
Linux only. System player that supports MPRIS protocol will be used. You can also specify a whitelist of players to use, example: `players: [rhythmbox, spotifyd, ncspot]`. Run `playerctl -l` to get the names.
### Browser
```yaml
# config.yaml
player: browser
browser:
port: 8974
```
You need to install a [browser extension](https://wnp.keifufu.dev/extension/getting-started). If you don't change the default port, no further configuration is required. Otherwise, create a custom adapter in the extension settings. **You can only run one instance on one port.**
### Local
```yaml
# config.yaml
local:
folder: ""
```
If you want to use your local collection of `.lrc` files to display lyrics, specify the folder to scan. The application will use files with the most similar name. All other lyrics sources will be disabled.
## Information
### Source
Primary source is [lrclib.net](https://lrclib.net). It is also possible to use local `.lrc` files.
### Piping
Run `sptlrx pipe` to start printing the current lines to stdout. This can be used in various status bars and other applications.
### Flags
You can pass flags to override the style parameters defined in the config. Example:
```sh
sptlrx --current "bold,#FFDFD3,#957DAD" --before "104,faint,italic" --after "104,faint"
```
List of allowed styles: `bold`, `italic`, `underline`, `strikethrough`, `blink`, `faint`. The colors can be either in HEX format, or ANSI 0-255. The first color represents the foreground, the second represents the background.
Run `sptlrx --help` to see all the flags.
## License
**MIT License**, see [LICENSE](./LICENSE) for additional information.
sptlrx-1.3.1/building.md 0000664 0000000 0000000 00000000452 15164470742 0015202 0 ustar 00root root 0000000 0000000 ## Building sptlrx
Make sure you have [Go 1.18+](https://go.dev/) installed.
### Clone the repository
```sh
git clone https://github.com/raitonoberu/sptlrx
cd sptlrx
```
### Fetch dependencies
```sh
go get
```
### Build it
```sh
go build -ldflags '-w -s'
```
### Run it
```sh
./sptlrx
```
sptlrx-1.3.1/cmd/ 0000775 0000000 0000000 00000000000 15164470742 0013625 5 ustar 00root root 0000000 0000000 sptlrx-1.3.1/cmd/login.go 0000664 0000000 0000000 00000003765 15164470742 0015277 0 ustar 00root root 0000000 0000000 package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"github.com/pkg/browser"
"github.com/raitonoberu/sptlrx/services/spotify/auth"
"github.com/spf13/cobra"
)
var (
FlagPort int
FlagClientId string
FlagClientSecret string
)
var loginCmd = &cobra.Command{
Use: "login",
Short: "Login to Spotify",
RunE: func(cmd *cobra.Command, args []string) error {
if FlagClientId == "" {
FlagClientId = os.Getenv("SPOTIFY_CLIENT_ID")
}
if FlagClientSecret == "" {
FlagClientSecret = os.Getenv("SPOTIFY_CLIENT_SECRET")
}
if err := interactiveLogin(); err != nil {
return err
}
if FlagClientId == "" || FlagClientSecret == "" {
return errors.New("client_id and client_secret are required")
}
auth := auth.New(FlagClientId, FlagClientSecret)
url := auth.GetAuthUrl(FlagPort)
fmt.Println("Login URL:", url)
browser.OpenURL(url)
if err := auth.Login(cmd.Context(), FlagPort); err != nil {
return err
}
if err := auth.Write(); err != nil {
return err
}
fmt.Println("Success! You can use sptlrx now")
return nil
},
}
func interactiveLogin() error {
if FlagClientId == "" || FlagClientSecret == "" {
reader := bufio.NewReader(os.Stdin)
if FlagClientId == "" {
fmt.Print("Enter spotify client ID: ")
clientId, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read client id: %w", err)
}
FlagClientId = strings.TrimSpace(clientId)
}
if FlagClientSecret == "" {
fmt.Print("Enter spotify client secret: ")
clientSecret, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read client secret: %w", err)
}
FlagClientSecret = strings.TrimSpace(clientSecret)
}
fmt.Println()
}
return nil
}
func init() {
loginCmd.Flags().IntVar(&FlagPort, "port", 8888, "port to use for login callback")
loginCmd.Flags().StringVar(&FlagClientId, "client-id", "", "spotify client id")
loginCmd.Flags().StringVar(&FlagClientSecret, "client-secret", "", "spotify client secret")
}
sptlrx-1.3.1/cmd/pipe.go 0000664 0000000 0000000 00000003325 15164470742 0015114 0 ustar 00root root 0000000 0000000 package cmd
import (
"fmt"
"strings"
"github.com/raitonoberu/sptlrx/config"
"github.com/raitonoberu/sptlrx/lyrics"
"github.com/raitonoberu/sptlrx/pool"
"github.com/muesli/reflow/wordwrap"
"github.com/muesli/reflow/wrap"
"github.com/spf13/cobra"
)
var pipeCmd = &cobra.Command{
Use: "pipe",
Short: "Start printing the current lines to stdout",
RunE: func(cmd *cobra.Command, args []string) error {
conf, err := loadConfig(cmd)
if err != nil {
return fmt.Errorf("couldn't load config: %w", err)
}
player, err := loadPlayer(conf)
if err != nil {
return fmt.Errorf("couldn't load player: %w", err)
}
provider, err := loadProvider(conf)
if err != nil {
return fmt.Errorf("couldn't load provider: %w", err)
}
ch := make(chan pool.Update)
go pool.Listen(player, provider, conf, ch)
for update := range ch {
printUpdate(update, conf)
}
return nil
},
}
func printUpdate(update pool.Update, conf *config.Config) {
if update.Err != nil {
if !conf.IgnoreErrors {
fmt.Println(update.Err.Error())
}
return
}
if update.Lines == nil || !lyrics.Timesynced(update.Lines) {
fmt.Println("")
return
}
line := update.Lines[update.Index].Words
if conf.Pipe.Length == 0 {
fmt.Println(line)
return
}
switch conf.Pipe.Overflow {
case "none":
s := wrap.String(line, conf.Pipe.Length)
fmt.Println(strings.Split(s, "\n")[0])
case "word":
s := wordwrap.String(line, conf.Pipe.Length)
fmt.Println(strings.Split(s, "\n")[0])
case "ellipsis":
s := wrap.String(line, conf.Pipe.Length)
lines := strings.Split(s, "\n")
if len(lines) == 1 {
fmt.Println(lines[0])
return
}
s = wrap.String(lines[0], conf.Pipe.Length-3)
fmt.Println(strings.Split(s, "\n")[0] + "...")
}
}
sptlrx-1.3.1/cmd/root.go 0000664 0000000 0000000 00000010320 15164470742 0015133 0 ustar 00root root 0000000 0000000 package cmd
import (
"errors"
"fmt"
"os"
"strings"
"github.com/raitonoberu/sptlrx/config"
"github.com/raitonoberu/sptlrx/lyrics"
"github.com/raitonoberu/sptlrx/player"
"github.com/raitonoberu/sptlrx/pool"
"github.com/raitonoberu/sptlrx/services/local"
"github.com/raitonoberu/sptlrx/services/lrclib"
"github.com/raitonoberu/sptlrx/ui"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
const banner = `
_ _
___ _ __ | |_ | | _ __ __ __
/ __|| '_ \ | __|| || '__|\ \/ /
\__ \| |_) || |_ | || | > <
|___/| .__/ \__||_||_| /_/\_\
|_|
`
var (
FlagPlayer string
FlagConfig string
FlagStyleBefore string
FlagStyleCurrent string
FlagStyleAfter string
FlagHAlignment string
FlagVerbose bool
)
var rootCmd = &cobra.Command{
Use: "sptlrx",
Short: "Synchronized lyrics in your terminal",
Long: "A CLI app that shows time-synchronized lyrics in your terminal",
Version: "v1.3.1",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
conf, err := loadConfig(cmd)
if err != nil {
return fmt.Errorf("couldn't load config: %w", err)
}
player, err := loadPlayer(conf)
if err != nil {
return fmt.Errorf("couldn't load player: %w", err)
}
provider, err := loadProvider(conf)
if err != nil {
return fmt.Errorf("couldn't load provider: %w", err)
}
ch := make(chan pool.Update)
go pool.Listen(player, provider, conf, ch)
_, err = tea.NewProgram(
&ui.Model{
Channel: ch,
Config: conf,
},
tea.WithAltScreen(),
).Run()
return err
},
}
func loadConfig(cmd *cobra.Command) (*config.Config, error) {
if cmd.Flags().Changed("config") {
// custom config path
config.Path = FlagConfig
}
conf, err := config.Load()
if err != nil {
if cmd.Flags().Changed("config") || !errors.Is(err, os.ErrNotExist) {
return nil, err
}
// create new config
conf = config.New()
fmt.Print(banner + "\n")
fmt.Printf("Config file location: %s\n", config.Path)
config.Save(conf)
}
if FlagVerbose {
conf.IgnoreErrors = false
}
if cmd.Flags().Changed("player") {
conf.Player = FlagPlayer
}
if cmd.Flags().Changed("before") {
conf.Style.Before = parseStyleFlag(FlagStyleBefore)
}
if cmd.Flags().Changed("current") {
conf.Style.Current = parseStyleFlag(FlagStyleCurrent)
}
if cmd.Flags().Changed("after") {
conf.Style.After = parseStyleFlag(FlagStyleAfter)
}
if cmd.Flags().Changed("halign") {
conf.Style.HAlignment = FlagHAlignment
}
return conf, nil
}
func loadPlayer(conf *config.Config) (player.Player, error) {
player, err := config.GetPlayer(conf)
if err != nil {
return nil, err
}
return player, nil
}
func loadProvider(conf *config.Config) (lyrics.Provider, error) {
if conf.Local.Folder != "" {
return local.New(conf.Local.Folder)
}
return lrclib.New(), nil
}
func parseStyleFlag(value string) config.Style {
var style config.Style
for _, part := range strings.Split(value, ",") {
switch part {
case "bold":
style.Bold = true
case "italic":
style.Italic = true
case "underline":
style.Underline = true
case "strikethrough":
style.Strikethrough = true
case "blink":
style.Blink = true
case "faint":
style.Faint = true
default:
if style.Foreground == "" {
style.Foreground = part
} else if style.Background == "" {
style.Background = part
}
}
}
return style
}
func init() {
rootCmd.PersistentFlags().StringVarP(&FlagPlayer, "player", "p", "spotify", "what player to use")
rootCmd.PersistentFlags().StringVar(&FlagConfig, "config", config.Path, "path to config file")
rootCmd.Flags().StringVar(&FlagStyleBefore, "before", "bold", "style of the lines before the current one")
rootCmd.Flags().StringVar(&FlagStyleCurrent, "current", "bold", "style of the current line")
rootCmd.Flags().StringVar(&FlagStyleAfter, "after", "faint", "style of the lines after the current one")
rootCmd.Flags().StringVar(&FlagHAlignment, "halign", "center", "initial horizontal alignment (left/center/right)")
rootCmd.PersistentFlags().BoolVarP(&FlagVerbose, "verbose", "v", false, "force print errors")
rootCmd.AddCommand(pipeCmd)
rootCmd.AddCommand(loginCmd)
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
sptlrx-1.3.1/config/ 0000775 0000000 0000000 00000000000 15164470742 0014327 5 ustar 00root root 0000000 0000000 sptlrx-1.3.1/config/config.go 0000664 0000000 0000000 00000010026 15164470742 0016122 0 ustar 00root root 0000000 0000000 package config
import (
"fmt"
"os"
"path"
"strconv"
"strings"
"github.com/raitonoberu/sptlrx/player"
"github.com/raitonoberu/sptlrx/services/browser"
"github.com/raitonoberu/sptlrx/services/mopidy"
"github.com/raitonoberu/sptlrx/services/mpd"
"github.com/raitonoberu/sptlrx/services/mpris"
"github.com/raitonoberu/sptlrx/services/spotify"
gloss "github.com/charmbracelet/lipgloss"
"github.com/creasty/defaults"
"gopkg.in/yaml.v2"
)
var (
Directory string
Path string
)
func init() {
d, err := os.UserConfigDir()
if err != nil {
panic(err)
}
Directory = path.Join(d, "sptlrx")
Path = path.Join(Directory, "config.yaml")
}
type Config struct {
Player string `default:"spotify" yaml:"player"`
IgnoreErrors bool `default:"true" yaml:"ignoreErrors"`
TimerInterval int `default:"200" yaml:"timerInterval"`
UpdateInterval int `default:"2000" yaml:"updateInterval"`
Style struct {
HAlignment string `default:"center" yaml:"hAlignment"`
Before Style `default:"{\"bold\": true}" yaml:"before"`
Current Style `default:"{\"bold\": true}" yaml:"current"`
After Style `default:"{\"faint\": true}" yaml:"after"`
} `yaml:"style"`
Pipe struct {
Length int `yaml:"length"`
Overflow string `default:"word" yaml:"overflow"`
} `yaml:"pipe"`
Mpd struct {
Address string `default:"127.0.0.1:6600" yaml:"address"`
Password string `yaml:"password"`
} `yaml:"mpd"`
Mopidy struct {
Address string `default:"127.0.0.1:6680" yaml:"address"`
} `yaml:"mopidy"`
Mpris struct {
Players []string `default:"[]" yaml:"players"`
} `yaml:"mpris"`
Browser struct {
Port int `default:"8974" yaml:"port"`
} `yaml:"browser"`
Local struct {
Folder string `yaml:"folder"`
} `yaml:"local"`
}
func New() *Config {
config := &Config{}
defaults.Set(config)
return config
}
func Load() (*Config, error) {
file, err := os.Open(Path)
if err != nil {
return nil, err
}
defer file.Close()
config := &Config{}
err = yaml.NewDecoder(file).Decode(config)
return config, err
}
func Save(config *Config) error {
err := os.MkdirAll(Directory, os.ModePerm)
if err != nil {
return err
}
file, err := os.Create(Path)
if err != nil {
return err
}
defer file.Close()
return yaml.NewEncoder(file).Encode(config)
}
// https://stackoverflow.com/a/56080478
func (c *Config) UnmarshalYAML(f func(interface{}) error) error {
defaults.Set(c)
type plain Config
if err := f((*plain)(c)); err != nil {
return err
}
return nil
}
type Style struct {
Background string `yaml:"background"`
Foreground string `yaml:"foreground"`
Bold bool `yaml:"bold"`
Italic bool `yaml:"italic"`
Underline bool `yaml:"underline"`
Strikethrough bool `yaml:"strikethrough"`
Blink bool `yaml:"blink"`
Faint bool `yaml:"faint"`
}
func (s Style) Parse() gloss.Style {
var style gloss.Style
if s.Background != "" && validateColor(s.Background) {
style = style.Background(gloss.Color(s.Background))
style.ColorWhitespace(false)
}
if s.Foreground != "" && validateColor(s.Foreground) {
style = style.Foreground(gloss.Color(s.Foreground))
}
if s.Bold {
style = style.Bold(true)
}
if s.Italic {
style = style.Italic(true)
}
if s.Underline {
style = style.Underline(true)
}
if s.Strikethrough {
style = style.Strikethrough(true)
}
if s.Blink {
style = style.Blink(true)
}
if s.Faint {
style = style.Faint(true)
}
return style
}
func validateColor(color string) bool {
if _, err := strconv.Atoi(color); err == nil {
return true
}
if strings.HasPrefix(color, "#") {
return true
}
return false
}
// GetPlayer returns a player based on config values
func GetPlayer(conf *Config) (player.Player, error) {
switch conf.Player {
case "spotify":
return spotify.New()
case "mpd":
return mpd.New(conf.Mpd.Address, conf.Mpd.Password), nil
case "mopidy":
return mopidy.New(conf.Mopidy.Address), nil
case "mpris":
return mpris.New(conf.Mpris.Players)
case "browser":
return browser.New(conf.Browser.Port)
}
return nil, fmt.Errorf("unknown player: \"%s\"", conf.Player)
}
sptlrx-1.3.1/demo.gif 0000664 0000000 0000000 00000631532 15164470742 0014507 0 ustar 00root root 0000000 0000000 GIF89a X1
!!"$ #!$"%'#$'%)',2(.+// - ,!+! H"+"/$".$"1$"6%"3%#5%$8&$0&%6'$3'$5(%4(&2)&5*(8+)5,)9,*6.+<.,90-<1.?2/=30A42>52C63B85F86B;8G<9J<:H=;J=;P=;L?OB?MDAPDASECZEElFDXGDUGEYHDWHETHEVHFZIFWIG[IIxJFZJGVJGXJH[JH\KHYKIWMJ\NKZNL`OL]OMaOP|PL`PM\PM^PN`PNdQMcQN]QN_SP`URbUSgUSiVRfVSdVSVThVTjWTcWTeWTgYVg[V[Xg[Xj\Zh^Z^[k_\m`]l`]`^ta^oa_sb^tb_pb_b`vbarbatc_sc`oebsebwfcrfctfdxfdzgduge{hevhezie{ifwifyig}jgujh|jh~khyki}kilh|lizli{ljxlj~ljmimj{mkmkml}nj~nk|nlznlol}olonplpm~pnpnqn}rnrospurwtyvzw|y}z~{}ôƸȻ̽ !NETSCAPE2.0 ! , X
!!"$ #!$"%'#$'%)',2(.+// - ,!+! H"+"/$".$"1$"6%"3%#5%$8&$0&%6'$3'$5(%4(&2)&5*(8+)5,)9,*6.+<.,90-<1.?2/=30A42>52C63B85F86B;8G<9J<:H=;J=;P=;L?OB?MDAPDASECZEElFDXGDUGEYHDWHETHEVHFZIFWIG[IIxJFZJGVJGXJH[JH\KHYKIWMJ\NKZNL`OL]OMaOP|PL`PM\PM^PN`PNdQMcQN]QN_SP`URbUSgUSiVRfVSdVSVThVTjWTcWTeWTgYVg[V[Xg[Xj\Zh^Z^[k_\m`]l`]`^ta^oa_sb^tb_pb_b`vbarbatc_sc`oebsebwfcrfctfdxfdzgduge{hevhezie{ifwifyig}jgujh|jh~khyki}kilh|lizli{ljxlj~ljmimj{mkmkml}nj~nk|nlznlol}olonplpm~pnpnqn}rnrospurwtyvzw|y}z~{}ôƸȻ̽ ?H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@
JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻNȓ+_μУKNسkνËOӫ_Ͼ˟O_t (h`ς6`?(Vhfv ($h(&B :(ch8<@)$.3BC6PF)TViE( ;H&W)dih
v`)Dp&pΩgwpf