gui functions nearly done

This commit is contained in:
XenGi 2023-12-30 15:02:13 +01:00
parent 114d6ba4da
commit f65918db8b
Signed by: xengi
SSH key fingerprint: SHA256:FGp51kRvGOcWnTHiOI39ImwVO4A3fpvR30nPX3LpV7g
6 changed files with 297 additions and 81 deletions

View file

@ -1,3 +1,4 @@
mpd: mpd:
mkdir -p /tmp/sanic/{music,playlists} mkdir -p /tmp/sanic/{music,playlists}
touch /tmp/sanic/mpd_db
mpd --no-daemon ./mpd.conf mpd --no-daemon ./mpd.conf

64
mpd.go
View file

@ -3,7 +3,6 @@ package main
import ( import (
"github.com/fhs/gompd/v2/mpd" "github.com/fhs/gompd/v2/mpd"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@ -15,13 +14,13 @@ func updateDb(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
jobId, err := conn.Update("") jobId, err := conn.Update("")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, strconv.Itoa(jobId)) return c.String(http.StatusOK, strconv.Itoa(jobId))
@ -31,13 +30,13 @@ func previousTrack(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
err = conn.Previous() err = conn.Previous()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -47,13 +46,13 @@ func nextTrack(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
err = conn.Next() err = conn.Next()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -63,13 +62,13 @@ func stopPlayback(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
err = conn.Stop() err = conn.Stop()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -79,13 +78,24 @@ func resumePlayback(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
err = conn.Pause(false) status, err := conn.Status()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
}
if status["state"] == "stop" {
err := conn.Play(-1)
if err != nil {
c.Logger().Error(err)
}
} else {
err = conn.Pause(false)
if err != nil {
c.Logger().Error(err)
}
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -95,13 +105,13 @@ func pausePlayback(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
err = conn.Pause(true) err = conn.Pause(true)
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -111,13 +121,13 @@ func seek(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
seconds, err := strconv.Atoi(c.Param("seconds")) seconds, err := strconv.Atoi(c.Param("seconds"))
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
if seconds < 0 { if seconds < 0 {
@ -126,7 +136,7 @@ func seek(c echo.Context) error {
err = conn.SeekCur(time.Duration(seconds)*time.Second, false) err = conn.SeekCur(time.Duration(seconds)*time.Second, false)
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -136,13 +146,13 @@ func toggleRepeat(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
status, err := conn.Status() status, err := conn.Status()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
if status["repeat"] == "1" { if status["repeat"] == "1" {
err = conn.Repeat(false) err = conn.Repeat(false)
@ -150,7 +160,7 @@ func toggleRepeat(c echo.Context) error {
err = conn.Repeat(true) err = conn.Repeat(true)
} }
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -160,21 +170,21 @@ func toggleRandom(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
status, err := conn.Status() status, err := conn.Status()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
if status["toggleRandom"] == "1" { if status["random"] == "1" {
err = conn.Random(false) err = conn.Random(false)
} else { } else {
err = conn.Random(true) err = conn.Random(true)
} }
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")
@ -184,13 +194,13 @@ func setVolume(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
conn, err := mpd.Dial("tcp", "localhost:6600") conn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
defer conn.Close() defer conn.Close()
level, err := strconv.Atoi(c.Param("level")) level, err := strconv.Atoi(c.Param("level"))
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
if level > 100 || level < 0 { if level > 100 || level < 0 {
@ -199,7 +209,7 @@ func setVolume(c echo.Context) error {
err = conn.SetVolume(level) err = conn.SetVolume(level)
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
return c.String(http.StatusOK, "") return c.String(http.StatusOK, "")

View file

@ -107,7 +107,12 @@ func wsServe(c echo.Context) error {
// Connect to MPD server // Connect to MPD server
mpdConn, err := mpd.Dial("tcp", "localhost:6600") mpdConn, err := mpd.Dial("tcp", "localhost:6600")
if err != nil { if err != nil {
log.Fatalln(err) //log.Fatalln(err)
c.Logger().Error(err)
err = websocket.Message.Send(ws, fmt.Sprintf("{\"mpd_error\":\"%s\"}", err.Error()))
if err != nil {
c.Logger().Error(err)
}
} }
defer mpdConn.Close() defer mpdConn.Close()
@ -121,16 +126,23 @@ func wsServe(c echo.Context) error {
} else { } else {
log.Println(msg) log.Println(msg)
if strings.ToLower(msg) == "#status" { if strings.ToLower(msg) == "#status" {
// TODO: Get current MPD status and return it
status, err := mpdConn.Status() status, err := mpdConn.Status()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
jsonData, err := json.Marshal(status) currentsong, err := mpdConn.CurrentSong()
if err != nil { if err != nil {
log.Fatalln(err) c.Logger().Error(err)
} }
err = websocket.Message.Send(ws, fmt.Sprintf("{\"mpd_status\":%s}", string(jsonData))) jsonStatus, err := json.Marshal(status)
if err != nil {
c.Logger().Error(err)
}
jsonCurrentSong, err := json.Marshal(currentsong)
if err != nil {
c.Logger().Error(err)
}
err = websocket.Message.Send(ws, fmt.Sprintf("{\"mpd_status\":%s,\"mpd_current_song\":%s}", string(jsonStatus), string(jsonCurrentSong)))
if err != nil { if err != nil {
c.Logger().Error(err) c.Logger().Error(err)
} }

172
static/controls.js vendored
View file

@ -1,7 +1,9 @@
const API_URL = `${document.location.protocol}://${document.location.host}/api`; const API_URL = `${document.location.protocol}//${document.location.host}/api`;
const VOLUME_STEP = 5;
// Get control elements // Get control elements
const connection_state = document.getElementById("connection-state");
const control_update_db = document.getElementById("control-update-db"); const control_update_db = document.getElementById("control-update-db");
const control_previous = document.getElementById("control-previous"); const control_previous = document.getElementById("control-previous");
const control_play_pause = document.getElementById("control-play-pause"); const control_play_pause = document.getElementById("control-play-pause");
@ -13,18 +15,31 @@ const control_shuffle = document.getElementById("control-shuffle");
const control_xfade = document.getElementById("control-xfade"); const control_xfade = document.getElementById("control-xfade");
const control_xfade_minus = document.getElementById("control-xfade-minus"); const control_xfade_minus = document.getElementById("control-xfade-minus");
const control_xfade_plus = document.getElementById("control-xfade-plus"); const control_xfade_plus = document.getElementById("control-xfade-plus");
const control_volume = document.getElementById("control-volume");
const control_volume_up = document.getElementById("control-volume-up");
const control_volume_down = document.getElementById("control-volume-down");
const queue_table = document.querySelector("#queue tbody"); const queue_table = document.querySelector("#queue tbody");
const control_track = document.getElementById("control-track");
const control_time = document.getElementById("control-time");
// Add API calls to controls // Add API calls to controls
control_update_db.addEventListener("click", e => { control_update_db.addEventListener("click", e => {
fetch(`${API_URL}/update_db`); console.log("Issuing database update")
fetch(`${API_URL}/update_db`).then(async r => {
if (r.status === 200) {
const job_id = await r.text();
console.log(`Update started (Job ID: ${job_id})`)
} else {
console.error(`API returned ${r.status}: ${r.statusText}`)
}
});
}); });
control_previous.addEventListener("click", e => { control_previous.addEventListener("click", e => {
fetch(`${API_URL}/previous_track`); fetch(`${API_URL}/previous_track`);
}); });
control_play_pause.addEventListener("click", e => { control_play_pause.addEventListener("click", e => {
if (e.target.innerText === "&#x23F5;&#xFE0E;") { // Play if (e.target.innerHTML === "&#x23F5;&#xFE0E;") { // TODO: check is never true
fetch(`${API_URL}/pause`); fetch(`${API_URL}/pause`);
} else { // Pause } else { // Pause
fetch(`${API_URL}/play`); fetch(`${API_URL}/play`);
@ -40,10 +55,20 @@ control_progress.addEventListener("change", e => {
fetch(`${API_URL}/seek/${e.target.value}`) fetch(`${API_URL}/seek/${e.target.value}`)
}); });
control_repeat.addEventListener("click", e => { control_repeat.addEventListener("click", e => {
if (e.target.innerHTML === "&#x1F534; repeat") { // TODO: check is never true
e.target.innerHTML = "&#x1F518; repeat";
} else {
e.target.innerHTML = "&#x1F534; repeat";
}
fetch(`${API_URL}/repeat`); fetch(`${API_URL}/repeat`);
}); });
control_shuffle.addEventListener("click", e => { control_shuffle.addEventListener("click", e => {
fetch(`${API_URL}/shuffle`); if (e.target.innerHTML === "&#x1F534; shuffle") { // TODO: check is never true
e.target.innerHTML = "&#x1F518; shuffle";
} else {
e.target.innerHTML = "&#x1F534; shuffle";
}
fetch(`${API_URL}/random`);
}); });
control_xfade_minus.addEventListener("click", e => { control_xfade_minus.addEventListener("click", e => {
// TODO: not yet implemented // TODO: not yet implemented
@ -53,46 +78,129 @@ control_xfade_plus.addEventListener("click", e => {
// TODO: not yet implemented // TODO: not yet implemented
fetch(`${API_URL}/xfade`); fetch(`${API_URL}/xfade`);
}); });
control_volume_up.addEventListener("click", e => {
const v = Math.min(parseInt(control_volume.value) + VOLUME_STEP, 100);
fetch(`${API_URL}/volume/${v}`);
control_volume.value = v;
});
control_volume_down.addEventListener("click", e => {
const v = Math.max(parseInt(control_volume.value) - VOLUME_STEP, 0);
fetch(`${API_URL}/volume/${v}`);
control_volume.value = v;
});
control_volume.addEventListener("change", e => {
fetch(`${API_URL}/volume/${e.target.value}`);
});
// Create WebSocket connection. // Create WebSocket connection.
const socket = new WebSocket(`${document.location.protocol === "https" ? "wss" : "ws"}://${document.location.host}/ws`); const socket = new WebSocket(`${document.location.protocol === "https:" ? "wss" : "ws"}://${document.location.host}/ws`);
// Connection opened // Connection opened
socket.addEventListener("open", (e) => { socket.addEventListener("open", (e) => {
socket.send("Hello Server!"); socket.send("Hello Server!");
}); });
// Listen for messages // Listen for messages and update UI state
socket.addEventListener("message", (e) => { socket.addEventListener("message", (e) => {
console.log("Message from server"); // Print out mpd response
console.log(`DEBUG: ${e.data}`); // DEBUG
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if ("error" in msg.mpd_status) {
console.error(msg.mpd_status.error); if ("mpd_status" in msg) {
if (msg.mpd_status == null) {
connection_state.innerHTML = "&#x274C; Disconnected"; // ✅ Check Mark Button
} else {
// print error if present
if ("error" in msg.mpd_status) {
console.error(msg.mpd_status.error);
}
// update "Update DB" button
if ("updating_db" in msg.mpd_status) {
control_update_db.disabled = true;
} else {
if (control_update_db.disabled) {
console.log("Database update done.")
}
control_update_db.disabled = false;
}
// update play/pause button
if ("state" in msg.mpd_status && msg.mpd_status.state === "play") {
control_play_pause.innerHTML = "&#x23F8;&#xFE0E;"; // Pause
} else {
control_play_pause.innerHTML = "&#x23F5;&#xFE0E;"; // Play
}
// update playback time
if ("elapsed" in msg.mpd_status && "duration" in msg.mpd_status) {
const elapsed_hours = Math.floor(msg.mpd_status.elapsed / 3600);
const elapsed_minutes = Math.floor((msg.mpd_status.elapsed - elapsed_hours * 3600) / 60);
const elapsed_seconds = Math.floor(msg.mpd_status.elapsed - elapsed_hours * 3600 - elapsed_minutes * 60);
const duration_hours = Math.floor(msg.mpd_status.duration / 3600);
const duration_minutes = Math.floor((msg.mpd_status.duration - duration_hours * 3600) / 60);
const duration_seconds = Math.floor(msg.mpd_status.duration - duration_hours * 3600 - duration_minutes * 60);
control_time.value = `${elapsed_hours}:${elapsed_minutes.toString().padStart(2, '0')}:${elapsed_seconds.toString().padStart(2, '0')}/${duration_hours}:${duration_minutes.toString().padStart(2, '0')}:${duration_seconds.toString().padStart(2, '0')}`;
}
if ("elapsed" in msg.mpd_status) {
control_progress.value = msg.mpd_status.elapsed;
}
if ("duration" in msg.mpd_status) {
control_progress.max = msg.mpd_status.duration;
}
// update repeat state
if ("repeat" in msg.mpd_status) {
if (msg.mpd_status.repeat === "1") {
control_repeat.innerHTML = "&#x1F534; repeat"; // 🔴 Red Circle
} else {
control_repeat.innerHTML = "&#x1F518; repeat"; // 🔘 Radio Button
}
}
// update shuffle state
if ("random" in msg.mpd_status) {
if (msg.mpd_status.random === "1") {
control_shuffle.innerHTML = "&#x1F534; shuffle"; // 🔴 Red Circle
} else {
control_shuffle.innerHTML = "&#x1F518; shuffle"; // 🔘 Radio Button
}
}
// update crossfade state
if ("xfade" in msg.mpd_status) {
control_xfade.value = msg.mpd_status.xfade;
}
// update volume
if ("volume" in msg.mpd_status) {
control_volume.value = msg.mpd_status.volume;
}
}
} }
if ("updating_db" in msg.mpd_status) { // update song info
control_update_db.disable(); if ("mpd_current_song" in msg && msg.mpd_current_song != null) {
} else { if ("Artist" in msg.mpd_current_song && "Title" in msg.mpd_current_song) {
control_update_db.enable(); control_track.value = `${msg.mpd_current_song.Artist} - ${msg.mpd_current_song.Title}`
} else {
control_track.value = msg.mpd_current_song.file;
}
} }
if ("state" in msg.mpd_status && msg.mpd_status.state === "play") {
control_play_pause.innerText = "&#x23F8;&#xFE0E;"; // Pause if ("mpd_error" in msg) {
} else { console.error(`MPD Error: ${msg.mpd_error}`)
control_play_pause.innerText = "&#x23F5;&#xFE0E;"; // Play
}
if ("elapsed" in msg.mpd_status) {
control_progress.value = msg.mpd_status.elapsed;
}
if ("duration" in msg.mpd_status) {
control_progress.max = msg.mpd_status.duration;
}
if ("repeat" in msg.mpd_status) {
control_repeat.checked = msg.mpd_status.repeat;
}
if ("random" in msg.mpd_status) {
control_shuffle.checked = msg.mpd_status.random;
}
if ("xfade" in msg.mpd_status) {
control_xfade.value = msg.mpd_status.xfade;
} }
}); });
// Request MPD status every second
window.setInterval(() => {
if (socket.readyState === socket.OPEN) {
socket.send("#status");
connection_state.innerHTML = "&#x2705; Connected"; // ❌ Cross Mark
} else {
connection_state.innerHTML = "&#x274C; Disconnected"; // ✅ Check Mark Button
}
}, 1000);

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" xmlns="http://www.w3.org/1999/html">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Sanic</title> <title>Sanic</title>
@ -9,13 +9,14 @@
<body> <body>
<main> <main>
<div id="nav"> <div id="nav">
<div> <div id="control-admin">
<button id="connection-state">&#x274C; Disconnected</button> <!-- ❌ Cross Mark -->
<button>Login</button> <button>Login</button>
<button>Config</button> <button>Config</button>
<button id="control-update-db" disabled="disabled">Update DB</button> <button id="control-update-db" disabled="disabled">Update DB</button>
</div> </div>
<div> <div>
<div> <div class="spaced">
<button id="control-previous">&#x23EE;&#xFE0E;</button> <!-- ⏮️ Last Track Button --> <button id="control-previous">&#x23EE;&#xFE0E;</button> <!-- ⏮️ Last Track Button -->
<button id="control-stop">&#x23F9;&#xFE0E;</button> <!-- ⏹️ Stop Button --> <button id="control-stop">&#x23F9;&#xFE0E;</button> <!-- ⏹️ Stop Button -->
<button id="control-play-pause">&#x23F5;&#xFE0E;</button> <!-- ▶️ Play or ⏸️ Pause Button --> <button id="control-play-pause">&#x23F5;&#xFE0E;</button> <!-- ▶️ Play or ⏸️ Pause Button -->
@ -27,24 +28,38 @@
</div> </div>
</div> </div>
<div> <div>
<div> <div class="spaced">
<button id="control-repeat">&#x1F518; repeat</button>
<button id="control-shuffle">&#x1F518; shuffle</button>
</div>
<div class="spaced">
<label for="control-xfade">xfade</label>
<div> <div>
<div>
<label for="control-repeat">repeat</label>
<input type="checkbox" id="control-repeat" name="repeat" />
</div>
<div>
<label for="control-shuffle">shuffle</label>
<input type="checkbox" id="control-shuffle" name="shuffle" />
</div>
</div>
<div>
<label for="control-xfade">xfade</label>
<button id="control-xfade-minus">&#x2796;</button> <button id="control-xfade-minus">&#x2796;</button>
<input type="number" id="control-xfade" name="xfade" value="00" /> <input type="number" id="control-xfade" name="xfade" value="00" />
<button id="control-xfade-plus">&#x2795;</button> <button id="control-xfade-plus">&#x2795;</button>
</div> </div>
</div> </div>
<div class="spaced">
<button id="control-volume-down">&#x1F509;</button> <!-- 🔉 Speaker with sound wave -->
<input id="control-volume" name="volume" type="range" min="0" max="100" value="50" />
<button id="control-volume-up">&#x1F50A;</button> <!-- 🔊 Speaker with sound waves -->
</div>
</div>
<div>
<p>Now playing:</p>
<div>
<label for="control-track">Track:</label>
<input type="text" id="control-track" name="track" disabled="disabled" />
</div>
<div>
<label for="control-time">Time:</label>
<input type="text" id="control-time" name="time" value="00:00:00/00:00:00" disabled="disabled" />
</div>
</div>
<div>
<img id="sanic-logo" alt="sanic logo" src="/img/sanic-logo.webp" />
<div>Sanic &copy; 2023</div>
</div> </div>
</div> </div>
<div id="queue"> <div id="queue">

View file

@ -1,5 +1,5 @@
/* #################### */ /* #################### */
/* ####### main ####### */ /* #### structure ##### */
/* #################### */ /* #################### */
html, body { margin: 0; height: 100%; } html, body { margin: 0; height: 100%; }
@ -37,6 +37,21 @@ table {
width: 100%; width: 100%;
} }
#control-admin {
display: flex;
flex-direction: column;
}
#sanic-logo {
max-width: 80%;
max-height: 80%;
}
.spaced {
display: flex;
justify-content: space-between;
}
/* #################### */ /* #################### */
/* ###### debug ####### */ /* ###### debug ####### */
/* #################### */ /* #################### */
@ -62,3 +77,58 @@ div {
#control-xfade[type=number] { #control-xfade[type=number] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
#control-xfade {
width: 2em;
}
#control-previous,
#control-play-pause,
#control-stop,
#control-next {
width: 2.5em;
height: 2.5em;
text-align: center;
}
/*
#control-track {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
animation: scroll-left 20s linear infinite;
-moz-animation: scroll-left 2s linear infinite;
-webkit-animation: scroll-left 2s linear infinite;
}
@keyframes scroll-left {
0% {
transform: translateX(100%);
-moz-transform: translateX(100%);
-webkit-transform: translateX(100%);
}
100% {
transform: translateX(-100%);
-moz-transform: translateX(-100%);
-webkit-transform: translateX(-100%);
}
}
@-moz-keyframes scroll-left {
0% {
-moz-transform: translateX(100%);
}
100% {
-moz-transform: translateX(-100%);
}
}
@-webkit-keyframes scroll-left {
0% {
-webkit-transform: translateX(100%);
}
100% {
-webkit-transform: translateX(-100%);
}
}
*/