diff --git a/src/backend/build.sh b/src/backend/build.sh
index 6451934..09a8476 100644
--- a/src/backend/build.sh
+++ b/src/backend/build.sh
@@ -12,6 +12,8 @@ else
echo "Project directory already exists, skipping mkdir."
fi
+sudo rm -rf "$BUILD_DIRECTORY"
+
# Create local backend folder if missing
# Tree should look like this
# ├── librebeats_api/build (output directory)
@@ -37,9 +39,6 @@ fi
# # # Switch to your project directory
cd "$BUILD_DIRECTORY"
-# # # Pull the latest images
-docker compose pull
-
# # # To generate and apply all secrets at once you can run: https://supabase.com/docs/guides/self-hosting/docker#quick-setup-experimental
if [ "$GENERATE_KEYS" = true ]; then
echo "Generating new keys."
diff --git a/src/backend/copy.sh b/src/backend/copy.sh
index d99af3f..9624ca3 100644
--- a/src/backend/copy.sh
+++ b/src/backend/copy.sh
@@ -8,5 +8,7 @@ cp -rf $_WORKING_DIR/supabase/* "$BUILD_DIRECTORY"
# # # Switch to your project directory
cd "$BUILD_DIRECTORY/service/migration" || exit 1
-docker compose build migrations
-docker compose up migrations -d --force-recreate
\ No newline at end of file
+docker compose build audio
+docker compose up audio -d
+
+# --force-recreate
\ No newline at end of file
diff --git a/src/backend/supabase/docker-compose.yml b/src/backend/supabase/docker-compose.yml
index 2665d41..fa5b8bd 100644
--- a/src/backend/supabase/docker-compose.yml
+++ b/src/backend/supabase/docker-compose.yml
@@ -400,10 +400,29 @@ services:
db:
condition: service_healthy
environment:
- SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
- SUPABASE_URL: http://kong:8000
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres
- JWT_SECRET: ${JWT_SECRET}
+
+ # Ytdlp wrapper, Upload service, and logger for librebeats audio processing
+ audio:
+ container_name: supabase-audio
+ build:
+ context: ./service/audio
+ dockerfile: Dockerfile
+ restart: "no"
+ depends_on:
+ migrations:
+ condition: service_completed_successfully
+ db:
+ condition: service_healthy
+ storage:
+ condition: service_healthy
+ environment:
+ POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/postgres
+ QUEUE_NAME: audiopipe-input
+ STORAGE_URL: http://kong:8000/storage/v1
+ STORAGE_KEY: ${SERVICE_ROLE_KEY}
+ AUDIO_BUCKET_ID: audio-files
+ IMAGE_BUCKET_ID: image-files
# Comment out everything below this point if you are using an external Postgres database
db:
diff --git a/src/backend/supabase/service/audio/Dockerfile b/src/backend/supabase/service/audio/Dockerfile
new file mode 100644
index 0000000..336768e
--- /dev/null
+++ b/src/backend/supabase/service/audio/Dockerfile
@@ -0,0 +1,27 @@
+# Stage 1: Builder (stays the same)
+FROM golang:1.26-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum* ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o main
+
+# Stage 2: Final image with Alpine
+FROM alpine:latest
+WORKDIR /app
+
+# Install required system packages
+RUN apk add --no-cache ffmpeg
+
+# Download and install the OFFICIAL musl-compatible yt-dlp binary
+RUN wget https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_musllinux -O /usr/bin/yt-dlp \
+ && chmod +x /usr/bin/yt-dlp
+
+# Install deno (using Alpine's package or musl build)
+RUN apk add --no-cache deno
+
+# Copy the static binary and config
+COPY --from=builder /app/main .
+COPY cookies.txt /app/cookies.txt
+
+CMD ["./main"]
\ No newline at end of file
diff --git a/src/backend/supabase/service/audio/database.go b/src/backend/supabase/service/audio/database.go
new file mode 100644
index 0000000..8f28bf4
--- /dev/null
+++ b/src/backend/supabase/service/audio/database.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type ILibreDb interface {
+ NewRawAudioEntry(source string, audioLocation string, thumbnailLocation string, durration int) (RawBeat, error)
+ NewBeatEntry(rawBeat *RawBeat, title string, arties string, tags string, streamingUrl string, thumbnailUrl string) error
+}
+
+type LibreDb struct {
+ ILibreDb
+ ConnectionString string
+}
+
+func NewLibreDb() LibreDb {
+
+ var _ ILibreDb = (*LibreDb)(nil)
+
+ connectionString := os.Getenv("POSTGRES_BACKEND_URL")
+
+ if connectionString == "" {
+ panic("POSTGRES_BACKEND_URL environment variable is not set")
+ }
+ return LibreDb{
+ ConnectionString: connectionString,
+ }
+}
+
+func (db *LibreDb) NewRawAudioEntry(source string, audioLocation string, thumbnailLocation string, durration int) (RawBeat, error) {
+ connection, err := pgx.Connect(context.Background(), db.ConnectionString)
+
+ if err != nil {
+ return RawBeat{}, err
+ }
+
+ statement, err := connection.Begin(context.Background())
+
+ if err != nil {
+ return RawBeat{}, err
+ }
+
+ lastinsertedId := -1
+
+ err = statement.QueryRow(context.Background(), "INSERT INTO Librebeats.RawBeat (Source, AudioLocation, ThumbnailLocation, Durration) VALUES($1, $2, $3, $4) RETURNING Id", source, audioLocation, thumbnailLocation, durration).
+ Scan(&lastinsertedId)
+
+ if err != nil {
+ return RawBeat{}, err
+ }
+
+ err = statement.Commit(context.Background())
+
+ if err != nil {
+ return RawBeat{}, err
+ }
+
+ return RawBeat{
+ Id: lastinsertedId,
+ Source: &source,
+ AudioLocation: &audioLocation,
+ ThumbnailLocation: &thumbnailLocation,
+ DownloadCount: 0,
+ CreatedAtUtc: time.Now(),
+ }, nil
+}
+
+func (db *LibreDb) NewBeatEntry(rawBeat *RawBeat, title string, arties string, tags string, streamingUrl string, thumbnailUrl string) error {
+ connection, err := pgx.Connect(context.Background(), db.ConnectionString)
+
+ if err != nil {
+ return err
+ }
+
+ statement, err := connection.Begin(context.Background())
+
+ if err != nil {
+ return err
+ }
+
+ _, err = statement.Exec(context.Background(), "INSERT INTO Librebeats.Beat (RawBeatId, Title, Artist, Tags, StreamingUrl, ThumbnailUrl) VALUES($1, $2, $3, $4, $5, $6) RETURNING Id", rawBeat.Id, title, arties, tags, streamingUrl, thumbnailUrl)
+
+ if err != nil {
+ return err
+ }
+
+ err = statement.Commit(context.Background())
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/src/backend/supabase/service/audio/go.mod b/src/backend/supabase/service/audio/go.mod
new file mode 100644
index 0000000..a0cfc06
--- /dev/null
+++ b/src/backend/supabase/service/audio/go.mod
@@ -0,0 +1,25 @@
+module audio
+
+go 1.25.5
+
+require github.com/google/uuid v1.6.0
+
+require (
+ github.com/georgysavva/scany v1.2.3 // indirect
+ github.com/jackc/chunkreader/v2 v2.0.1 // indirect
+ github.com/jackc/pgconn v1.8.0 // indirect
+ github.com/jackc/pgio v1.0.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgproto3/v2 v2.0.6 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgtype v1.6.2 // indirect
+ github.com/jackc/pgx/v4 v4.10.1 // indirect
+ github.com/jackc/pgx/v5 v5.8.0 // indirect
+ github.com/jackc/puddle v1.1.3 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/supabase-community/storage-go v0.8.1 // indirect
+ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
+ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+)
diff --git a/src/backend/supabase/service/audio/go.sum b/src/backend/supabase/service/audio/go.sum
new file mode 100644
index 0000000..428385f
--- /dev/null
+++ b/src/backend/supabase/service/audio/go.sum
@@ -0,0 +1,194 @@
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
+github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/georgysavva/scany v1.2.3 h1:yaEtl1B2i3qjCIsmLchSrcw2MxktvK+N0oi7uzYyqWk=
+github.com/georgysavva/scany v1.2.3/go.mod h1:vGBpL5XRLOocMFFa55pj0P04DrL3I7qKVRL49K6Eu5o=
+github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
+github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
+github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
+github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
+github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
+github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
+github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
+github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk=
+github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
+github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
+github.com/jackc/pgconn v1.8.0 h1:FmjZ0rOyXTr1wfWs45i4a9vjnjWUAGpMuQLD9OSs+lw=
+github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
+github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
+github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
+github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
+github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.0.6 h1:b1105ZGEMFe7aCvrT1Cca3VoVb4ZFMaFJLJcg/3zD+8=
+github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
+github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
+github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
+github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
+github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
+github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
+github.com/jackc/pgtype v1.6.2 h1:b3pDeuhbbzBYcg5kwNmNDun4pFUD/0AAr1kLXZLeNt8=
+github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
+github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
+github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
+github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
+github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
+github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
+github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
+github.com/jackc/pgx/v4 v4.10.1 h1:/6Q3ye4myIj6AaplUm+eRcz4OhK9HAvFf4ePsG40LJY=
+github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
+github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
+github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
+github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94=
+github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
+github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
+github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/supabase-community/postgrest-go v0.0.12 h1:4xJmimJra904t6Rj+umPyu1qm6ih7rhd7fvgqAblajc=
+github.com/supabase-community/postgrest-go v0.0.12/go.mod h1:cw6LfzMyK42AOSBA1bQ/HZ381trIJyuui2GWhraW7Cc=
+github.com/supabase-community/storage-go v0.8.1 h1:EwD0vr+ADBIjBWH8G69AxWuvdFhifv64cfE/sjRky6I=
+github.com/supabase-community/storage-go v0.8.1/go.mod h1:oBKcJf5rcUXy3Uj9eS5wR6mvpwbmvkjOtAA+4tGcdvQ=
+github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
+go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
+golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
+gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
+gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
diff --git a/src/backend/supabase/service/audio/log.go b/src/backend/supabase/service/audio/log.go
new file mode 100644
index 0000000..95953c4
--- /dev/null
+++ b/src/backend/supabase/service/audio/log.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type IAudioOutputLogger interface {
+ CreateNewLog(title string) (AudioOutput, error)
+ UpdateLog(log *AudioOutput) error
+}
+
+type AudioOutputLogger struct {
+ IAudioOutputLogger
+ ConnectionString string
+}
+
+func NewYtdlpLogger() AudioOutputLogger {
+ // Will throw an error if its missing a method implementation from interface
+ // will throw a compile time error
+ var _ IAudioOutputLogger = (*AudioOutputLogger)(nil)
+
+ connectionString := os.Getenv("POSTGRES_BACKEND_URL")
+
+ if connectionString == "" {
+ panic("POSTGRES_BACKEND_URL environment variable is not set")
+ }
+
+ return AudioOutputLogger{
+ ConnectionString: connectionString,
+ }
+}
+
+func (l *AudioOutputLogger) CreateNewLog(title string) (AudioOutput, error) {
+ connection, err := pgx.Connect(context.Background(), l.ConnectionString)
+
+ if err != nil {
+ return AudioOutput{}, err
+ }
+
+ defer connection.Close(context.Background())
+
+ var lastinsertedId = 1
+
+ trans, err := connection.Begin(context.Background())
+
+ scanError := trans.QueryRow(context.Background(), "INSERT INTO Librebeats.AudioOutputLog (Title, ProgressState) VALUES ($1, $2) RETURNING id", title, Created).Scan(&lastinsertedId)
+
+ if scanError != nil {
+ return AudioOutput{}, err
+ }
+
+ trans.Commit(context.Background())
+
+ return AudioOutput{
+ Id: lastinsertedId,
+ ProgressState: int(Created),
+ Title: &title,
+ }, nil
+}
+
+func (l *AudioOutputLogger) UpdateLog(log *AudioOutput) error {
+ connection, err := pgx.Connect(context.Background(), l.ConnectionString)
+
+ if err != nil {
+ return err
+ }
+ defer connection.Close(context.Background())
+
+ trans, err := connection.Begin(context.Background())
+
+ _, transError := trans.Exec(context.Background(), "UPDATE Librebeats.AudioOutputLog SET Title = $1, Output = $2, ErrorOutput = $3, ProgressState = $4, FinishedAtUtc = $5 WHERE id = $6", log.Title, log.Output, log.ErrorOutput, log.ProgressState, log.FinishedAtUtc, log.Id)
+
+ if transError != nil {
+ fmt.Println(transError.Error())
+ }
+
+ trans.Commit(context.Background())
+
+ return transError
+}
diff --git a/src/backend/supabase/service/audio/main.go b/src/backend/supabase/service/audio/main.go
new file mode 100644
index 0000000..fb24950
--- /dev/null
+++ b/src/backend/supabase/service/audio/main.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+const (
+ SleepTimeInSeconds = 5
+ StorageLocation = "/app/sessions"
+)
+
+var logger AudioOutputLogger = NewYtdlpLogger()
+
+func main() {
+ // db
+ var db = NewLibreDb()
+
+ // Create queue listener
+ var queueListener = *createQueueListener()
+
+ // Storage
+ var storage = NewStorageService()
+
+ storage.EnsureBucketsExists()
+
+ for true {
+ audioQueueMessage, _ := listenForMessage(&queueListener)
+
+ if audioQueueMessage == nil {
+ continue
+ }
+
+ fmt.Printf("Received message from queue\n Message id: %d\n Message: %s\n", audioQueueMessage.Id, audioQueueMessage.Message)
+
+ messageBody := map[string]interface{}{}
+
+ err := json.Unmarshal(audioQueueMessage.Message, &messageBody)
+
+ if err != nil {
+ ErrorLog(fmt.Sprintf("Failed to unmarshal message body for message id: %d", audioQueueMessage.Id), string(audioQueueMessage.Message), fmt.Sprintf("Error: %s", err.Error()))
+ continue
+ }
+
+ // check if url is a playlist or single video
+ sourceUrl := string(messageBody["url"].(string))
+ isPlaylist := strings.Contains(sourceUrl, "playlist?")
+
+ outputLocation := fmt.Sprintf("%s/run_%d", StorageLocation, audioQueueMessage.Id)
+ idsFile := fmt.Sprintf("%s/ids.txt", outputLocation)
+ namesFile := fmt.Sprintf("%s/names.txt", outputLocation)
+ durationFile := fmt.Sprintf("%s/duration.txt", outputLocation)
+ playlistTitleFile := fmt.Sprintf("%s/playlist_title.txt", outputLocation)
+ playlistIdFile := fmt.Sprintf("%s/playlist_id.txt", outputLocation)
+ tags := fmt.Sprintf("%s/tags.txt", outputLocation)
+ logOutput := fmt.Sprintf("%s/output.log", outputLocation)
+ logOutputError := fmt.Sprintf("%s/error.log", outputLocation)
+
+ filesToDelete := []string{
+ idsFile,
+ namesFile,
+ durationFile,
+ playlistTitleFile,
+ playlistIdFile,
+ logOutput,
+ tags,
+ logOutputError,
+ outputLocation,
+ }
+
+ if !tryCreateDirectory(StorageLocation) ||
+ !tryCreateDirectory(outputLocation) {
+ continue
+ }
+
+ // split off between playlist and single download
+ if !isPlaylist {
+ // Single
+ _, err := FlatSingleDownload(outputLocation, idsFile, namesFile, durationFile, sourceUrl, tags, logOutput, logOutputError, "opus")
+
+ if err != nil {
+ errorLog, _ := readFile(logOutputError)
+ ErrorLog("FlatSingleDownload had an error", fmt.Sprintf("Failed to download url: %s", string(messageBody["url"].(string))), errorLog)
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ if !fileExists(idsFile) ||
+ !fileExists(namesFile) ||
+ !fileExists(durationFile) {
+ // Failed to creat needed files, check errors
+ errorLog, _ := readFile(logOutputError)
+ ErrorLog("Error Ytdlp did not create needed files", fmt.Sprintf("Failed to download url: %s", string(messageBody["url"].(string))), errorLog)
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ id, err := readFile(idsFile)
+ name, err := readFile(namesFile)
+ duration, err := readFile(durationFile)
+ audioFilePath := fmt.Sprintf("%s/%s.opus", outputLocation, id)
+ imageFilePath := fmt.Sprintf("%s/%s.jpg", outputLocation, id)
+
+ // Upload to storage
+ audioUploadResponse, err := storage.UploadAudioFile(audioFilePath, fmt.Sprintf("%s.opus", id))
+
+ if err != nil {
+ ErrorLog(fmt.Sprintf("Failed to upload audio file %s for message id: %d", audioFilePath, audioQueueMessage.Id),
+ string(audioQueueMessage.Message),
+ fmt.Sprintf("Error: %s", err.Error()))
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ imageUploadResponse, err := storage.UploadImageFile(imageFilePath, fmt.Sprintf("%s.jpeg", id))
+
+ if err != nil {
+ ErrorLog(fmt.Sprintf("Failed to upload image file %s file for message id: %d", imageFilePath,
+ audioQueueMessage.Id),
+ imageUploadResponse.Error,
+ fmt.Sprintf("Error: %s", err.Error()))
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ audioStorageLocation := audioUploadResponse.Key
+ imageStorageLocation := imageUploadResponse.Key
+
+ // update database with entry....
+ _dur, err := strconv.Atoi(duration)
+ rawAudio, err := db.NewRawAudioEntry(sourceUrl, audioStorageLocation, imageStorageLocation, _dur)
+
+ if err != nil {
+ ErrorLog("Failed to create new RawAudio entry", sourceUrl, err.Error())
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ audioPublicUrl := storage.GetAudioPublicUrl(audioStorageLocation)
+ thumbnailPublicUrl := storage.GetImagePublicUrl(imageStorageLocation)
+
+ err = db.NewBeatEntry(&rawAudio, name, name, "", audioPublicUrl.SignedURL, thumbnailPublicUrl.SignedURL)
+
+ if err != nil {
+ ErrorLog("Failed to create a new Beat entry", sourceUrl, err.Error())
+ cleanUopFiles(filesToDelete)
+ continue
+ }
+
+ } else {
+ // Playlist
+
+ // Get playlist Id
+ }
+
+ // Clean up files
+ cleanUopFiles(filesToDelete)
+ }
+}
diff --git a/src/backend/supabase/service/audio/models.go b/src/backend/supabase/service/audio/models.go
new file mode 100644
index 0000000..18ff1f7
--- /dev/null
+++ b/src/backend/supabase/service/audio/models.go
@@ -0,0 +1,55 @@
+package main
+
+import (
+ "encoding/json"
+ "time"
+)
+
+type ProgressState int
+
+const (
+ Created ProgressState = iota
+ InProgress ProgressState = iota
+ Completed ProgressState = iota
+ Downloading ProgressState = iota
+ Failed ProgressState = iota
+)
+
+type AudioPipeQueueMessage struct {
+ Id int64 `json:"msg_id"`
+ Message json.RawMessage `json:"message"`
+}
+
+type AudioProcessingMessage struct {
+ Url string `json:"url"`
+}
+
+type Beat struct {
+ Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"`
+ RawBeatId int `json:"raw_beat_id" db:"raw_beat_id" gorm:"not null;index"` // foreign key to Audio/RawBeat
+ Title *string `json:"title" db:"title" gorm:"not null"`
+ Artist *string `json:"artist" db:"artist" gorm:"not null"`
+ Tags *string `json:"tags" db:"tags" gorm:"not null"`
+ StreamingURL *string `json:"streaming_url" db:"streaming_url" gorm:"not null"`
+ ThumbnailURL *string `json:"thumbnail_url" db:"thumbnail_url" gorm:"not null"`
+}
+
+type RawBeat struct {
+ Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"`
+ Source *string `json:"source" db:"source" gorm:"not null"`
+ AudioLocation *string `json:"audio_location" db:"audio_location" gorm:"not null"`
+ ThumbnailLocation *string `json:"thumbnail_location" db:"thumbnail_location" gorm:"not null"`
+ DownloadCount int `json:"download_count" db:"download_count" gorm:"not null;default:0"`
+ CreatedAtUtc time.Time `json:"created_at_utc" db:"created_at_utc" gorm:"not null;default:now()"`
+ Duration int `json:"duration" db:"duration" gorm:"not null;default 0"`
+}
+
+type AudioOutput struct {
+ Id int `json:"id" db:"id" gorm:"primaryKey;autoIncrement"`
+ Title *string `json:"title" db:"title" gorm:"not null"`
+ ProgressState int `json:"progress_state" db:"progress_state" gorm:"not null"`
+ Output *string `json:"output,omitempty" db:"output" gorm:"default:null"` // nullable
+ ErrorOutput *string `json:"error_output,omitempty" db:"error_output" gorm:"default:null"` // nullable
+ StartedAtUtc *time.Time `json:"started_at_utc" db:"started_at_utc" gorm:"not null;default:now()"`
+ FinishedAtUtc time.Time `json:"finished_at_utc,omitempty" db:"finished_at_utc" gorm:"default:null"` // nullable
+}
diff --git a/src/backend/supabase/service/audio/queue.go b/src/backend/supabase/service/audio/queue.go
new file mode 100644
index 0000000..d28927d
--- /dev/null
+++ b/src/backend/supabase/service/audio/queue.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type IQueueListener interface {
+ Pop() (map[string]interface{}, error)
+}
+
+type QueueListener struct {
+ IQueueListener
+ ConnectionString string
+ QueueName string
+}
+
+func (ql *QueueListener) Pop() (*AudioPipeQueueMessage, error) {
+
+ conn, err := pgx.Connect(context.Background(), ql.ConnectionString)
+
+ if err != nil {
+ return nil, err
+ }
+
+ defer conn.Close(context.Background())
+
+ var audioQueueMessage AudioPipeQueueMessage
+
+ transaction, err := conn.Begin(context.Background())
+
+ if err != nil {
+ return nil, err
+ }
+
+ err = transaction.QueryRow(context.Background(), fmt.Sprintf("SELECT msg_id, message FROM pgmq.pop('%s')", ql.QueueName)).Scan(&audioQueueMessage.Id, &audioQueueMessage.Message)
+
+ if err != nil {
+ return nil, err
+ }
+
+ if err := transaction.Commit(context.Background()); err != nil {
+ return nil, err
+ }
+
+ return &audioQueueMessage, nil
+}
diff --git a/src/backend/supabase/service/audio/sourceHelper.go b/src/backend/supabase/service/audio/sourceHelper.go
new file mode 100644
index 0000000..ccab8a9
--- /dev/null
+++ b/src/backend/supabase/service/audio/sourceHelper.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+)
+
+var cookiesPath = "/app/cookies.txt"
+
+func FlatPlaylistDownload(
+ idsFileName string,
+ namesFileName string,
+ durationFileName string,
+ playlistTitleFileName string,
+ playlistIdFileName string,
+ url string,
+ logOutput string,
+ logOutputError string,
+) (bool, error) {
+ Stdout, err := os.Create(logOutput)
+
+ if err != nil {
+ fmt.Println(err.Error())
+ return false, err
+ }
+
+ Stderr, err := os.Create(logOutputError)
+
+ if err != nil {
+ fmt.Println(err.Error())
+ return false, err
+ }
+
+ proc, _err := os.StartProcess(
+ "/usr/bin/yt-dlp",
+ []string{
+ "yt-dlp",
+ "--force-ipv4",
+ "--no-keep-video",
+ "--skip-download",
+ "--flat-playlist",
+ "--write-thumbnail",
+ "--print-to-file", "%(id)s", idsFileName,
+ "--print-to-file", "%(title)s", namesFileName,
+ "--print-to-file", "%(duration)s", durationFileName,
+ "--print-to-file", "%(playlist_id)s", playlistIdFileName,
+ "--print-to-file", "%(playlist_title)s", playlistTitleFileName,
+ "--ignore-errors",
+ "--extractor-args=youtube:player_js_variant=tv",
+ fmt.Sprintf("--cookies=%s", cookiesPath),
+ "--js-runtimes=deno:/usr/bin",
+ "--remote-components=ejs:npm",
+ url,
+ },
+ &os.ProcAttr{
+ Files: []*os.File{
+ os.Stdin, /// :))))))))))))))))))))))))))))))))
+ Stdout,
+ Stderr,
+ },
+ },
+ )
+ if _err != nil {
+ log.Fatal(_err)
+ }
+
+ state, err := proc.Wait()
+
+ if err != nil {
+ return false, err
+ }
+
+ return state.Success(), nil
+}
+
+func FlatSingleDownload(
+ //archiveFileName string,
+ outputLocation string,
+ idsFileName string,
+ namesFileName string,
+ durationFileName string,
+ url string,
+ tagsFileName string,
+ logOutput string,
+ logOutputError string,
+ fileExtension string,
+) (bool, error) {
+
+ Stdout, err := os.OpenFile(logOutput, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+
+ if err != nil {
+ fmt.Println(err.Error())
+ return false, err
+ }
+
+ Stderr, err := os.OpenFile(logOutputError, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+
+ if err != nil {
+ fmt.Println(err.Error())
+ return false, err
+ }
+
+ proc, _err := os.StartProcess(
+ "/usr/bin/yt-dlp",
+ []string{
+ "yt-dlp",
+ "--force-ipv4",
+ "--write-thumbnail",
+ "--extract-audio",
+ "--audio-quality=0",
+ fmt.Sprintf("--audio-format=%s", fileExtension),
+ "--convert-thumbnails=jpg",
+ "--force-ipv4",
+ "--downloader=aria2c",
+ "--no-keep-video",
+ "--downloader-args=aria2c:-x 16 -s 16 -j 16",
+ "--print-to-file", "%(id)s", idsFileName,
+ "--print-to-file", "%(title)s", namesFileName,
+ "--print-to-file", "%(duration)s", durationFileName,
+ "--print-to-file", "%(tags)s", tagsFileName,
+ "--output", outputLocation + "/%(id)s.%(ext)s",
+ "--concurrent-fragments=20",
+ "--ignore-errors",
+ // fmt.Sprintf("--download-archive=%s", archiveFileName), // not needed for now
+ "--extractor-args=youtube:player_js_variant=tv",
+ fmt.Sprintf("--cookies=%s", cookiesPath),
+ "--js-runtimes=deno:/usr/bin/",
+ "--remote-components=ejs:npm",
+ url,
+ },
+ &os.ProcAttr{
+ Files: []*os.File{
+ os.Stdin, /// :))))))))))))))))))))))))))))))))
+ Stdout,
+ Stderr,
+ },
+ },
+ )
+
+ if _err != nil {
+ return false, _err
+ }
+
+ state, err := proc.Wait()
+
+ if err != nil {
+ return false, err
+ }
+
+ return state.Success(), nil
+}
diff --git a/src/backend/supabase/service/audio/storage.go b/src/backend/supabase/service/audio/storage.go
new file mode 100644
index 0000000..292210d
--- /dev/null
+++ b/src/backend/supabase/service/audio/storage.go
@@ -0,0 +1,155 @@
+package main
+
+import (
+ "os"
+
+ storage "github.com/supabase-community/storage-go"
+)
+
+type IStorageService interface {
+ EnsureBucketsExists()
+ GetAudioPublicUrl(filename string) storage.SignedUrlResponse
+ UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error)
+ GetImagePublicUrl(filename string) storage.SignedUrlResponse
+ UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error)
+}
+
+type StorageService struct {
+ IStorageService
+ client *storage.Client
+ audioBucketId string
+ imageBucketId string
+}
+
+func NewStorageService() *StorageService {
+ // Will throw an error if its missing a method implementation from interface
+ var _ IStorageService = (*StorageService)(nil)
+
+ storageUrl := os.Getenv("STORAGE_URL")
+
+ if storageUrl == "" {
+ panic("STORAGE_URL environment variable is not set")
+ }
+
+ storageKey := os.Getenv("STORAGE_KEY")
+
+ if storageKey == "" {
+ panic("STORAGE_KEY environment variable is not set")
+ }
+
+ audioBucketId := os.Getenv("AUDIO_BUCKET_ID")
+
+ if audioBucketId == "" {
+ panic("AUDIO_BUCKET_ID environment variable is not set")
+ }
+
+ imageBucketId := os.Getenv("IMAGE_BUCKET_ID")
+
+ if imageBucketId == "" {
+ panic("IMAGE_BUCKET_ID environment variable is not set")
+ }
+
+ storageClient := storage.NewClient(storageUrl, storageKey, nil)
+
+ return &StorageService{
+ client: storageClient,
+ audioBucketId: audioBucketId,
+ imageBucketId: imageBucketId,
+ }
+}
+
+func (s *StorageService) GetAudioPublicUrl(filePath string) storage.SignedUrlResponse {
+ return getPublicUrl(s, s.audioBucketId, filePath)
+}
+
+func (s *StorageService) GetImagePublicUrl(filePath string) storage.SignedUrlResponse {
+ return getPublicUrl(s, s.imageBucketId, filePath)
+}
+
+func getPublicUrl(storageService *StorageService, bucketId string, filePath string) storage.SignedUrlResponse {
+ options := storage.UrlOptions{
+ Download: true,
+ }
+ return storageService.client.GetPublicUrl(bucketId, filePath, options)
+}
+
+func (s *StorageService) EnsureBucketsExists() {
+
+ // This will fail incase bucket already existss after the first run
+ // move these into the database insetad????
+ _, err := s.client.GetBucket(s.audioBucketId)
+
+ if err != nil {
+ _, err = s.client.CreateBucket(s.audioBucketId, storage.BucketOptions{
+ Public: true,
+ AllowedMimeTypes: []string{
+ "audio/ogg",
+ },
+ })
+
+ if err != nil {
+ ErrorLog("Failed to create audio bucket", "EnsureBucketsExists", err.Error())
+ }
+ }
+
+ _, err = s.client.GetBucket(s.imageBucketId)
+
+ if err != nil {
+ _, err = s.client.CreateBucket(s.imageBucketId, storage.BucketOptions{
+ Public: true,
+ AllowedMimeTypes: []string{
+ "image/jpeg",
+ },
+ })
+
+ if err != nil {
+ ErrorLog("Failed to create audio bucket", "EnsureBucketsExists", err.Error())
+ }
+ }
+}
+
+func (s *StorageService) UploadAudioFile(filePath string, fileName string) (storage.FileUploadResponse, error) {
+ // Open the file you want to upload
+ file, err := os.Open(filePath)
+ if err != nil {
+ return storage.FileUploadResponse{}, err
+ }
+ defer file.Close()
+
+ // Upload the file to the specified bucket and path
+ contentType := "audio/ogg"
+ upsert := true
+ options := &storage.FileOptions{
+ ContentType: &contentType,
+ Upsert: &upsert,
+ }
+ response, err := s.client.UploadFile(s.audioBucketId, fileName, file, *options)
+
+ if err != nil {
+ return storage.FileUploadResponse{}, err
+ }
+ return response, nil
+}
+
+func (s *StorageService) UploadImageFile(filePath string, fileName string) (storage.FileUploadResponse, error) {
+ // Open the file you want to upload
+ file, err := os.Open(filePath)
+ if err != nil {
+ return storage.FileUploadResponse{}, err
+ }
+ defer file.Close()
+
+ // Upload the file to the specified bucket and path
+ contentType := "image/jpeg"
+ upsert := true
+ options := &storage.FileOptions{
+ ContentType: &contentType,
+ Upsert: &upsert,
+ }
+ response, err := s.client.UploadFile(s.imageBucketId, fileName, file, *options)
+
+ if err != nil {
+ return storage.FileUploadResponse{}, err
+ }
+ return response, nil
+}
diff --git a/src/backend/supabase/service/audio/util.go b/src/backend/supabase/service/audio/util.go
new file mode 100644
index 0000000..4c47170
--- /dev/null
+++ b/src/backend/supabase/service/audio/util.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+func tryCreateDirectory(path string) bool {
+ err := ensureDir(path)
+
+ if err != nil {
+ ErrorLog("Failed to create directory", "tryCreateDirectory", err.Error())
+ return false
+ }
+
+ return true
+}
+
+func ensureDir(dirName string) error {
+ err := os.Mkdir(dirName, os.ModeDir)
+ if err == nil {
+ return nil
+ }
+ if os.IsExist(err) {
+ // check that the existing path is a directory
+ info, err := os.Stat(dirName)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return errors.New("path exists but is not a directory")
+ }
+ return nil
+ }
+ return err
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ // No error, file exists
+ return err == nil
+}
+
+// readLines reads a whole file into memory
+// and returns a slice of its lines.
+func readLines(path string) ([]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ var lines []string
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ lines = append(lines, scanner.Text())
+ }
+ return lines, scanner.Err()
+}
+
+func readFile(path string) (string, error) {
+ bytes, err := os.ReadFile(path)
+
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(bytes)), nil
+}
+
+func pathExists(path string) bool {
+ info, err := os.Stat(path)
+ if os.IsNotExist(err) {
+ return false
+ }
+ return err == nil && (info.IsDir())
+}
+
+func cleanUopFiles(files []string) {
+ for _, file := range files {
+ err := os.RemoveAll(file)
+ if err != nil {
+ fmt.Printf("Failed to delete file: %s\n", file)
+ } else {
+ fmt.Printf("Deleted: %s\n", file)
+ }
+ }
+}
+
+func listenForMessage(queue *QueueListener) (*AudioPipeQueueMessage, error) {
+ audioQueueMessage, err := queue.Pop()
+
+ if err != nil || audioQueueMessage == nil {
+ //ErrorLog(err)
+ sleep()
+ return nil, err
+ }
+
+ return audioQueueMessage, nil
+}
+
+func createQueueListener() *QueueListener {
+ connectionString := os.Getenv("POSTGRES_BACKEND_URL")
+ queueName := os.Getenv("QUEUE_NAME")
+
+ if connectionString == "" {
+ panic("POSTGRES_BACKEND_URL environment variable is not set")
+ }
+
+ if queueName == "" {
+ panic("QUEUE_NAME environment variable is not set")
+ }
+
+ return &QueueListener{
+ ConnectionString: connectionString,
+ QueueName: queueName,
+ }
+}
+
+func sleep() {
+ fmt.Printf("Sleeping for %d seconds...\n", SleepTimeInSeconds)
+ time.Sleep(SleepTimeInSeconds * time.Second)
+}
+
+func ErrorLog(title string, outputlog string, errorOutput string) {
+ log, _ := logger.CreateNewLog(fmt.Sprintf("Error: %s", title))
+ log.Output = &outputlog
+ log.ErrorOutput = &errorOutput
+ log.ProgressState = int(Failed)
+ log.FinishedAtUtc = time.Now()
+ logger.UpdateLog(&log)
+ fmt.Println(title)
+}
diff --git a/src/backend/supabase/service/migration/Dockerfile b/src/backend/supabase/service/migration/Dockerfile
index 9660f95..3aa0bd5 100644
--- a/src/backend/supabase/service/migration/Dockerfile
+++ b/src/backend/supabase/service/migration/Dockerfile
@@ -1,32 +1,36 @@
-# Stage 1: Build the Go app
-FROM golang:1.25-alpine AS builder
+# Stage 1: Build the Go application
+# Use a specific Go version for reproducible builds (latest stable as of writing)
+FROM golang:1.26-alpine AS builder
-# Set environment variables for Go build
-ENV GO111MODULE=on \
- CGO_ENABLED=0 \
+# Set environment variables for static linking and module support
+ENV CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
WORKDIR /app
-# Install Go dependencies
+# Copy go.mod and go.sum first to leverage Docker caching
COPY go.mod go.sum ./
RUN go mod download
-# Copy the source code and build
+# Copy the rest of the source code
+# Use .dockerignore to exclude unnecessary files (e.g., .git, node_modules, etc.)
COPY . .
-RUN go build -o main .
-# Stage 2: Final image
-FROM alpine:3.19
+# Build the binary with optimizations: strip debug info and disable symbol table
+RUN go build -ldflags="-s -w" -o main .
-# Copy the built Go binary from the builder stage
+# Stage 2: Create the final lightweight image
+# Use a minimal Alpine base with a specific version for security and stability
+FROM alpine:latest
+
+# Copy the compiled binary from the builder stage
COPY --from=builder /app/main /app/main
-# Set working directory and permissions
+# Set working directory for the application
WORKDIR /app
-# COPY sql scripts
+# Copy any required scripts, preserving executable permissions
COPY scripts/* ./scripts/
# Run the binary
diff --git a/src/backend/supabase/service/migration/main.go b/src/backend/supabase/service/migration/main.go
index 662b3eb..38a11ce 100644
--- a/src/backend/supabase/service/migration/main.go
+++ b/src/backend/supabase/service/migration/main.go
@@ -14,7 +14,8 @@ func main() {
err := migration.Run()
if err != nil {
- panic(err)
+ fmt.Println("Error applying migrations: " + err.Error())
+ return
}
fmt.Println("Migrations applied successfully")
diff --git a/src/backend/supabase/service/migration/migration.go b/src/backend/supabase/service/migration/migration.go
index 6c73a5e..0645be1 100644
--- a/src/backend/supabase/service/migration/migration.go
+++ b/src/backend/supabase/service/migration/migration.go
@@ -36,7 +36,13 @@ func NewMigrationInstance() IMaigration {
func (m *Migration) Run() error {
- lastAppliedMigrationId := m._LastAppliedMigrationId()
+ lastAppliedMigrationId, err := m._LastAppliedMigrationId()
+
+ if err != nil {
+ return err
+ }
+
+ fmt.Println("Last applied migration id:", lastAppliedMigrationId)
tx, err := m._connection.Begin(context.Background())
@@ -59,8 +65,10 @@ func (m *Migration) Run() error {
for _, dirEntry := range dirs {
+ fmt.Println("Processing migration file:", dirEntry.Name())
+
if dirEntry.IsDir() {
- fmt.Println("Already applied skipping folder:", dirEntry.Name())
+ fmt.Println("Skipping directory:", dirEntry.Name())
continue
}
@@ -74,6 +82,7 @@ func (m *Migration) Run() error {
}
if migrationFileId <= lastAppliedMigrationId {
+ fmt.Println("Skipping already applied migration:", dirEntry.Name())
continue
}
@@ -90,7 +99,7 @@ func (m *Migration) Run() error {
return err
}
- _, err = tx.Exec(context.Background(), "INSERT INTO librebeats.migrations (id, file_name, content, run_on) VALUES ($1, $2, $3, NOW())", migrationFileId, dirEntry.Name(), string(sqlScript))
+ _, err = tx.Exec(context.Background(), "INSERT INTO Librebeats.Migrations (id, fileName, content, runOn) VALUES ($1, $2, $3, NOW())", migrationFileId, dirEntry.Name(), string(sqlScript))
if err != nil {
defer tx.Rollback(context.Background())
@@ -109,15 +118,22 @@ func (m *Migration) Run() error {
return nil
}
-func (m *Migration) _LastAppliedMigrationId() int {
-
- var lastAppliedMigrationId int
+func (m *Migration) _LastAppliedMigrationId() (int, error) {
+ var lastAppliedMigrationId int = -1
- err := m._connection.QueryRow(context.Background(), "SELECT id FROM librebeats.migrations ORDER BY run_on DESC LIMIT 1").Scan(&lastAppliedMigrationId)
+ err := m._connection.QueryRow(context.Background(), "SELECT Id FROM Librebeats.Migrations ORDER BY runon DESC LIMIT 1").Scan(&lastAppliedMigrationId)
if err != nil {
- return -1
+ errorMessage := err.Error()
+ // If the error is because the migrations table does not exist, it means that no migrations have been applied yet, so we can return -1 without an error
+ if strings.Contains(errorMessage, "ERROR: relation \"librebeats.migrations\" does not exist (SQLSTATE 42P01)") {
+ fmt.Println("Migrations table does not exist, assuming no migrations have been applied yet.")
+ return -1, nil
+ }
+
+ fmt.Println("Error fetching last applied migrationId:", errorMessage)
+ return -1, err
}
- return lastAppliedMigrationId
+ return lastAppliedMigrationId, nil
}
diff --git a/src/backend/supabase/service/migration/scripts/0 initial.sql b/src/backend/supabase/service/migration/scripts/0 initial.sql
index 73daa89..eee7234 100644
--- a/src/backend/supabase/service/migration/scripts/0 initial.sql
+++ b/src/backend/supabase/service/migration/scripts/0 initial.sql
@@ -1,90 +1,104 @@
-- 1. Schema Setup
-CREATE SCHEMA IF NOT EXISTS pgmq_public;
-CREATE SCHEMA IF NOT EXISTS librebeats;
-
-
--- -- Source https://github.com/orgs/supabase/discussions/41729#discussion-9312472
--- 2. WRAPPER FUNCTIONS
--- Note the addition of SECURITY DEFINER and explicit search_path
-CREATE OR REPLACE FUNCTION pgmq_public.pop(queue_name text)
-RETURNS SETOF pgmq.message_record LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$
-BEGIN
- RETURN QUERY SELECT * FROM pgmq.pop(queue_name := queue_name);
-END; $$;
-COMMENT ON FUNCTION pgmq_public.pop(queue_name text) IS
-'Retrieves and locks the next message from the specified queue.';
-
-
-CREATE OR REPLACE FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer DEFAULT 0)
-RETURNS SETOF bigint LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$
-BEGIN
- RETURN QUERY SELECT * FROM pgmq.send(queue_name := queue_name, msg := message, delay := sleep_seconds);
-END; $$;
-COMMENT ON FUNCTION pgmq_public.send(queue_name text, message jsonb, sleep_seconds integer) IS
-'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.';
-
-CREATE OR REPLACE FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer)
-RETURNS SETOF pgmq.message_record LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$
-BEGIN
- RETURN QUERY SELECT * FROM pgmq.read(queue_name := queue_name, vt := sleep_seconds, qty := n);
-END; $$;
-COMMENT ON FUNCTION pgmq_public.read(queue_name text, sleep_seconds integer, n integer) IS
-'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).';
-
-CREATE OR REPLACE FUNCTION pgmq_public.archive(queue_name text, message_id bigint)
-RETURNS boolean LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$
-BEGIN
- RETURN pgmq.archive(queue_name := queue_name, msg_id := message_id);
-END; $$;
-COMMENT ON FUNCTION pgmq_public.archive(queue_name text, message_id bigint) IS
-'Archives a message by moving it from the queue to a permanent archive.';
-
-CREATE OR REPLACE FUNCTION pgmq_public.delete(queue_name text, message_id bigint)
-RETURNS boolean LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = pgmq, pgmq_public AS $$
-BEGIN
- RETURN pgmq.delete(queue_name := queue_name, msg_id := message_id);
-END; $$;
-COMMENT ON FUNCTION pgmq_public.delete(queue_name text, message_id bigint) IS
-'Deletes a message from the specified queue.';
-
-CREATE OR REPLACE FUNCTION pgmq_public.create_queue(queue_name text)
-RETURNS void
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = pgmq, pgmq_public
-AS $$
-BEGIN
- -- This calls the extension's internal create function
- PERFORM pgmq.create(queue_name := queue_name);
-END;
-$$;
-COMMENT ON FUNCTION pgmq_public.create_queue(queue_name text) IS
-'Creates a new message queue with the specified name.';
-
--- Grant access to the service role
-GRANT EXECUTE ON FUNCTION pgmq_public.create_queue(text) TO service_role;
-
--- 3. Permission Cleanup
--- Grant EXECUTE to service_role only
-GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA pgmq_public TO service_role;
+CREATE EXTENSION IF NOT EXISTS pgmq;
+CREATE SCHEMA IF NOT EXISTS Librebeats;
-- 4. Internal Table
-CREATE TABLE IF NOT EXISTS librebeats.migrations (
- id serial PRIMARY KEY,
- file_name text NOT NULL,
- content text NOT NULL,
- run_on timestamptz NOT NULL DEFAULT now()
+CREATE TABLE IF NOT EXISTS Librebeats.Migrations(
+ Id SERIAL PRIMARY KEY,
+ FileName TEXT NOT NULL,
+ Content TEXT NOT NULL,
+ RunOn TIMESTAMP NOT NULL DEFAULT now()
);
-- Enable RLS: With no policies, it is accessible ONLY by service_role
-ALTER TABLE librebeats.migrations ENABLE ROW LEVEL SECURITY;
+ALTER TABLE Librebeats.Migrations ENABLE ROW LEVEL SECURITY;
-- 5. Final Grants
-GRANT USAGE ON SCHEMA librebeats, pgmq_public, pgmq TO service_role;
-GRANT ALL ON ALL TABLES IN SCHEMA librebeats TO service_role;
-GRANT ALL ON ALL SEQUENCES IN SCHEMA librebeats TO service_role;
+-- GRANT USAGE ON SCHEMA librebeats, pgmq_public, pgmq TO service_role;
+GRANT ALL ON ALL TABLES IN SCHEMA Librebeats TO service_role;
+GRANT ALL ON ALL SEQUENCES IN SCHEMA Librebeats TO service_role;
+
+-- Create the audio processing queue
+SELECT * FROM pgmq.create('audiopipe-input');
+
+CREATE TABLE IF NOT EXISTS Librebeats.RawBeat (
+ Id SERIAL PRIMARY KEY,
+ Source TEXT NOT NULL,
+ AudioLocation TEXT NOT NULL,
+ ThumbnailLocation TEXT NOT NULL,
+ DownloadCount INT NOT NULL DEFAULT 0,--?????????????????????
+ CreatedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ Durration INTEGER NOT NULL,
+ CONSTRAINT unique_source UNIQUE (Source),
+ CONSTRAINT unique_audio_location UNIQUE (AudioLocation),
+ CONSTRAINT unique_thumbnail_location UNIQUE (ThumbnailLocation)
+
+);
+
+ALTER TABLE Librebeats.RawBeat ENABLE ROW LEVEL SECURITY;
+
+CREATE TABLE IF NOT EXISTS Librebeats.Beat (
+ Id SERIAL PRIMARY KEY,
+ RawBeatId SERIAL NOT NULL REFERENCES Librebeats.RawBeat(Id) ON DELETE CASCADE,
+ Title TEXT NOT NULL,
+ Artist TEXT NOT NULL,
+ Tags TEXT NOT NULL,
+ StreamingUrl TEXT NOT NULL,
+ ThumbnailUrl TEXT NOT NULL,
+ CONSTRAINT unique_streaming_url UNIQUE (StreamingUrl),
+ CONSTRAINT unique_thumbnail_url UNIQUE (ThumbnailUrl)
+);
+
+ALTER TABLE Librebeats.Beat ENABLE ROW LEVEL SECURITY;
+
+-- ONLY authenticated users can access songs
+CREATE POLICY "Authenticated users can access all songs" ON Librebeats.Beat
+ FOR SELECT
+ TO authenticated
+ USING (true);
+
+
+CREATE TABLE IF NOT EXISTS Librebeats.AudioOutputLog (
+ Id SERIAL PRIMARY KEY,
+ Title TEXT NOT NULL,
+ ProgressState INT NOT NULL,
+ Output TEXT NULL,
+ ErrorOutput TEXT,
+ StartedAtUtc TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ FinishedAtUtc TIMESTAMP WITH TIME ZONE
+);
+
+ALTER TABLE Librebeats.AudioOutputLog ENABLE ROW LEVEL SECURITY;
+
+-- Only service role can access the AudioOutputLog table
+CREATE POLICY "Authenticated users can access AudioOutputLog" ON Librebeats.AudioOutputLog
+ FOR SELECT
+ TO authenticated
+ USING (true);
+
+
+-- Index for foreign key performance
+CREATE INDEX IF NOT EXISTS idx_beat_rawbeat_id ON Librebeats.Beat(RawBeatId);
+CREATE INDEX IF NOT EXISTS idx_beat_title ON Librebeats.Beat(Title);
+CREATE INDEX IF NOT EXISTS idx_beat_artist ON Librebeats.Beat(Artist);
+CREATE INDEX IF NOT EXISTS idx_beat_tags ON Librebeats.Beat(Tags);
+
+-- Ensure future tables inherit grants
+ALTER DEFAULT PRIVILEGES IN SCHEMA Librebeats GRANT ALL ON TABLES TO service_role;
+
+CREATE TABLE IF NOT EXISTS Librebeats.BeatMix (
+ Id SERIAL PRIMARY KEY,
+ Title TEXT NOT NULL,
+ ThumbnailUrl TEXT NOT NULL,
+ CreatedOn TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ CONSTRAINT unique_beatmix_title UNIQUE(Title)
+);
+
+CREATE INDEX IF NOT EXISTS idx_beatmix_title ON Librebeats.BeatMix(Title);
+
+CREATE TABLE IF NOT EXTENSION Librebeats.BeatMixBeat (
+ BeatId SERIAL NOT NULL REFERENCES Librebeats.Beat(Id) ON DELETE CASCADE,
+ BeatMixId SERIAL NOT NULL REFERENCES Librebeats.BeatMix(Id) ON DELETE CASCADE,
+ PRIMARY KEY (BeatMixId, BeatMixBeatId)
+);
+
diff --git a/src/frontend/android/app/src/main/AndroidManifest.xml b/src/frontend/android/app/src/main/AndroidManifest.xml
index 410865c..9fe60b2 100644
--- a/src/frontend/android/app/src/main/AndroidManifest.xml
+++ b/src/frontend/android/app/src/main/AndroidManifest.xml
@@ -1,10 +1,18 @@
-
+
+
+
+
+
+
+
+
-
+
+
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme"/>
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
\ No newline at end of file
diff --git a/src/frontend/lib/data/models.dart b/src/frontend/lib/data/models.dart
new file mode 100644
index 0000000..bad5be2
--- /dev/null
+++ b/src/frontend/lib/data/models.dart
@@ -0,0 +1,187 @@
+// ─── Song ──────────────────────────────────────────────────────────────────
+class Song {
+ final String id;
+ final String title;
+ final String artist;
+ final String album;
+ final String? albumArtUrl;
+ final Duration duration;
+ final String? streamUrl;
+ final String? serverId; // null = local
+ final DateTime? lastPlayed;
+
+ const Song({
+ required this.id,
+ required this.title,
+ required this.artist,
+ required this.album,
+ this.albumArtUrl,
+ required this.duration,
+ this.streamUrl,
+ this.serverId,
+ this.lastPlayed,
+ });
+
+ String get durationString {
+ final m = duration.inMinutes;
+ final s = duration.inSeconds % 60;
+ return '$m:${s.toString().padLeft(2, '0')}';
+ }
+
+ factory Song.fromJson(Map json) => Song(
+ id: json['id']?.toString() ?? '',
+ title: json['title'] ?? 'Unknown',
+ artist: json['artist'] ?? 'Unknown Artist',
+ album: json['album'] ?? '',
+ albumArtUrl: json['coverArt'],
+ duration: Duration(seconds: json['duration'] ?? 0),
+ streamUrl: json['streamUrl'],
+ serverId: json['serverId'],
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'title': title,
+ 'artist': artist,
+ 'album': album,
+ 'albumArtUrl': albumArtUrl,
+ 'duration': duration.inSeconds,
+ 'streamUrl': streamUrl,
+ 'serverId': serverId,
+ };
+}
+
+// ─── Playlist ──────────────────────────────────────────────────────────────
+class Playlist {
+ final String id;
+ String name;
+ String? description;
+ String? coverArtUrl;
+ List songs;
+ final bool isServer;
+ final String? serverId;
+ DateTime? lastPlayed;
+ final DateTime createdAt;
+
+ Playlist({
+ required this.id,
+ required this.name,
+ this.description,
+ this.coverArtUrl,
+ List? songs,
+ this.isServer = false,
+ this.serverId,
+ this.lastPlayed,
+ DateTime? createdAt,
+ }) : songs = songs ?? [],
+ createdAt = createdAt ?? DateTime.now();
+
+ int get songCount => songs.length;
+
+ factory Playlist.fromJson(Map json) => Playlist(
+ id: json['id']?.toString() ?? '',
+ name: json['name'] ?? 'Untitled',
+ description: json['description'],
+ coverArtUrl: json['coverArt'],
+ isServer: json['isServer'] ?? false,
+ serverId: json['serverId'],
+ lastPlayed: json['lastPlayed'] != null
+ ? DateTime.tryParse(json['lastPlayed'])
+ : null,
+ createdAt: json['createdAt'] != null
+ ? DateTime.tryParse(json['createdAt']) ?? DateTime.now()
+ : DateTime.now(),
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'name': name,
+ 'description': description,
+ 'coverArtUrl': coverArtUrl,
+ 'isServer': isServer,
+ 'serverId': serverId,
+ 'lastPlayed': lastPlayed?.toIso8601String(),
+ 'createdAt': createdAt.toIso8601String(),
+ };
+}
+
+// ─── Server ────────────────────────────────────────────────────────────────
+enum ServerType { librebeats }
+
+enum ServerStatus { unknown, online, offline, error }
+
+class MusicServer {
+ final String id;
+ String name;
+ String url;
+ String? username;
+ String? password;
+ ServerType type;
+ ServerStatus status;
+ int? songCount;
+ DateTime? lastSynced;
+
+ MusicServer({
+ required this.id,
+ required this.name,
+ required this.url,
+ this.username,
+ this.password,
+ this.type = ServerType.librebeats,
+ this.status = ServerStatus.unknown,
+ this.songCount,
+ this.lastSynced,
+ });
+
+ String get typeLabel {
+ switch (type) {
+ case ServerType.librebeats:
+ return 'LibreBeats';
+ }
+ }
+
+ factory MusicServer.fromJson(Map json) => MusicServer(
+ id: json['id'] ?? '',
+ name: json['name'] ?? '',
+ url: json['url'] ?? '',
+ username: json['username'],
+ type: ServerType.values.firstWhere(
+ (t) => t.name == json['type'],
+ orElse: () => ServerType.librebeats,
+ ),
+ );
+
+ Map toJson() => {
+ 'id': id,
+ 'name': name,
+ 'url': url,
+ 'username': username,
+ 'type': type.name,
+ };
+}
+
+// ─── Search Result ─────────────────────────────────────────────────────────
+enum SearchResultType { song, album, artist, playlist }
+
+class SearchResult {
+ final SearchResultType type;
+ final String id;
+ final String title;
+ final String subtitle;
+ final String? imageUrl;
+ final dynamic data; // Original object
+
+ const SearchResult({
+ required this.type,
+ required this.id,
+ required this.title,
+ required this.subtitle,
+ this.imageUrl,
+ this.data,
+ });
+}
+
+// ─── Player State ──────────────────────────────────────────────────────────
+enum RepeatMode { none, one, all }
+
+enum ShuffleMode { off, on }
\ No newline at end of file
diff --git a/src/frontend/lib/main.dart b/src/frontend/lib/main.dart
index 81046a0..05ab67e 100644
--- a/src/frontend/lib/main.dart
+++ b/src/frontend/lib/main.dart
@@ -1,74 +1,136 @@
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:just_audio_background/just_audio_background.dart';
+import 'package:provider/provider.dart';
+import 'providers/player_provider.dart';
+import 'providers/library_provider.dart';
+import 'screens/home_screen.dart';
+import 'screens/search_screen.dart';
+import 'screens/library_screen.dart';
+import 'screens/settings_screen.dart';
+import 'widgets/player_widgets.dart';
+import 'theme/app_theme.dart';
-void main() {
- runApp(const MyApp());
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ SystemChrome.setPreferredOrientations([
+ DeviceOrientation.portraitUp,
+ DeviceOrientation.portraitDown,
+ ]);
+ SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.light,
+ systemNavigationBarColor: Color(0xFF121212),
+ systemNavigationBarIconBrightness: Brightness.light,
+ ));
+ await JustAudioBackground.init(
+ androidNotificationChannelId: 'librebeats.audio',
+ androidNotificationChannelName: 'Audio playback',
+ androidNotificationOngoing: true,
+ );
+ runApp(const LibreBeatsApp());
}
-class MyApp extends StatelessWidget {
- const MyApp({super.key});
+class LibreBeatsApp extends StatelessWidget {
+ const LibreBeatsApp({super.key});
@override
Widget build(BuildContext context) {
- return MaterialApp(
- title: 'LibreBeats',
- theme: ThemeData(
- colorScheme: ColorScheme.dark(),
-
+ return MultiProvider(
+ providers: [
+ ChangeNotifierProvider(create: (_) => LibraryProvider()),
+ ChangeNotifierProvider(create: (_) => PlayerProvider()),
+ ],
+ child: MaterialApp(
+ title: 'LibreBeats',
+ debugShowCheckedModeBanner: false,
+ theme: LibreBeatsTheme.theme,
+ home: const MainShell(),
),
- home: const MyHomePage(title: 'LibreBeats'),
);
}
}
-class MyHomePage extends StatefulWidget {
-
- const MyHomePage({super.key, required this.title});
-
- final String title;
+class MainShell extends StatefulWidget {
+ const MainShell({super.key});
@override
- State createState() => _MyHomePageState();
+ State createState() => _MainShellState();
}
-class _MyHomePageState extends State {
+class _MainShellState extends State {
+ int _currentIndex = 0;
+
+ final _screens = const [
+ HomeScreen(),
+ SearchScreen(),
+ LibraryScreen(),
+ SettingsScreen(),
+ ];
@override
Widget build(BuildContext context) {
- // This method is rerun every time setState is called, for instance as done
- // by the _incrementCounter method above.
- //
- // The Flutter framework has been optimized to make rerunning build methods
- // fast, so that you can just rebuild anything that needs updating rather
- // than having to individually change instances of widgets.
-
return Scaffold(
- appBar: AppBar(
- backgroundColor: const Color.fromARGB(17, 0, 0, 0),
- title: Center(child: Text(widget.title)),
+ backgroundColor: LibreBeatsTheme.background,
+ body: IndexedStack(
+ index: _currentIndex,
+ children: _screens,
),
- body: Container(
- width: double.infinity,
- height: double.infinity,
- color: Colors.black,
- child: Center(child: Text('Hello LibreBeats!', style: TextStyle(color: Colors.white, fontSize: 24))),
- ),
- bottomNavigationBar: BottomAppBar(
- // elevation
- elevation: 0,
- // transparent
- color: const Color.fromARGB(17, 0, 0, 0),
- child: SizedBox(
- height: 50,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ bottomNavigationBar: Consumer(
+ builder: (context, player, _) {
+ return Column(
+ mainAxisSize: MainAxisSize.min,
children: [
- IconButton(onPressed: () => {}, icon: Icon(Icons.search)),
- IconButton(onPressed: () => {}, icon: Icon(Icons.home)),
- IconButton(onPressed: () => {}, icon: Icon(Icons.settings)),
+ // Mini player sits above nav bar
+ // Mini player sits above nav bar
+ if (player.miniPlayerVisible)
+ MiniPlayer(
+ onTap: () => FullPlayerSheet.show(context),
+ ),
+
+ // Navigation bar
+ Container(
+ decoration: const BoxDecoration(
+ color: Color(0xFF121212),
+ border: Border(
+ top: BorderSide(color: LibreBeatsTheme.border, width: 0.5),
+ ),
+ ),
+ child: NavigationBar(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ selectedIndex: _currentIndex,
+ onDestinationSelected: (i) => setState(() => _currentIndex = i),
+ indicatorColor: LibreBeatsTheme.accentDim,
+ labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
+ destinations: const [
+ NavigationDestination(
+ icon: Icon(Icons.home_outlined, color: LibreBeatsTheme.textDim),
+ selectedIcon: Icon(Icons.home_rounded, color: LibreBeatsTheme.textPrimary),
+ label: 'Home',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.search_outlined, color: LibreBeatsTheme.textDim),
+ selectedIcon: Icon(Icons.search_rounded, color: LibreBeatsTheme.textPrimary),
+ label: 'Search',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.library_music_outlined, color: LibreBeatsTheme.textDim),
+ selectedIcon: Icon(Icons.library_music_rounded, color: LibreBeatsTheme.textPrimary),
+ label: 'Library',
+ ),
+ NavigationDestination(
+ icon: Icon(Icons.settings_outlined, color: LibreBeatsTheme.textDim),
+ selectedIcon: Icon(Icons.settings_rounded, color: LibreBeatsTheme.textPrimary),
+ label: 'Settings',
+ ),
+ ],
+ ),
+ ),
],
- ),
- ),
- )
+ );
+ },
+ ),
);
}
-}
+}
\ No newline at end of file
diff --git a/src/frontend/lib/providers/library_provider.dart b/src/frontend/lib/providers/library_provider.dart
new file mode 100644
index 0000000..cbd0ba3
--- /dev/null
+++ b/src/frontend/lib/providers/library_provider.dart
@@ -0,0 +1,203 @@
+// TODO Implement this library.import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:uuid/uuid.dart';
+const _uuid = Uuid();
+
+class LibraryProvider extends ChangeNotifier {
+ // ── State ──────────────────────────────────────────────────────────────
+ List _playlists = [];
+ List _servers = [];
+ Song? _lastPlayedSong;
+ Playlist? _lastPlayedPlaylist;
+ int _localStorageUsedMb = 0;
+
+ bool _isLoading = false;
+ String? _error;
+
+ // Mock data on init
+ LibraryProvider() {
+ _loadMockData();
+ }
+
+ void _loadMockData() {
+ final songs = [
+ Song(id: 's1', title: 'Midnight Static', artist: 'Neon Void', album: 'Frequencies', duration: const Duration(minutes: 3, seconds: 42)),
+ Song(id: 's2', title: 'Hollow Frequencies', artist: 'Circuit Breaker', album: 'Phase II', duration: const Duration(minutes: 4, seconds: 15)),
+ Song(id: 's3', title: 'Solar Drift', artist: 'Pale Mirror', album: 'Dusk', duration: const Duration(minutes: 5, seconds: 1)),
+ Song(id: 's4', title: 'Subterranean', artist: 'The Hollow', album: 'Depths', duration: const Duration(minutes: 3, seconds: 28)),
+ Song(id: 's5', title: 'Glass Architecture', artist: 'Neon Void', album: 'Prism', duration: const Duration(minutes: 4, seconds: 55)),
+ Song(id: 's6', title: 'Rewired', artist: 'Circuit Breaker', album: 'Phase II', duration: const Duration(minutes: 3, seconds: 11)),
+ Song(id: 's7', title: 'Cascade Protocol', artist: 'The Hollow', album: 'Depths', duration: const Duration(minutes: 4, seconds: 33)),
+ ];
+
+ _playlists = [
+ Playlist(id: 'p1', name: 'Late Night Drives', songs: songs.sublist(0, 3), lastPlayed: DateTime.now().subtract(const Duration(hours: 1))),
+ Playlist(id: 'p2', name: 'Focus Mode', songs: songs.sublist(2, 6), lastPlayed: DateTime.now().subtract(const Duration(hours: 3))),
+ Playlist(id: 'p3', name: 'Indie Picks', songs: songs.sublist(1, 5), isServer: true, serverId: 'srv1', lastPlayed: DateTime.now().subtract(const Duration(hours: 5))),
+ Playlist(id: 'p4', name: 'Workout Anthems', songs: songs.sublist(3, 7), lastPlayed: DateTime.now().subtract(const Duration(days: 1))),
+ Playlist(id: 'p5', name: 'Chill Waves', songs: songs.sublist(0, 4), isServer: true, serverId: 'srv2', lastPlayed: DateTime.now().subtract(const Duration(days: 2))),
+ ];
+
+ _servers = [
+ MusicServer(id: 'srv1', name: 'Navidrome Home', url: 'http://192.168.1.10:4533', username: 'admin', type: ServerType.navidrome, status: ServerStatus.online, songCount: 1204),
+ MusicServer(id: 'srv2', name: 'Jellyfin Media', url: 'http://media.local:8096', username: 'user', type: ServerType.jellyfin, status: ServerStatus.online, songCount: 3891),
+ ];
+
+ _lastPlayedSong = songs.first;
+ _lastPlayedPlaylist = _playlists.first;
+ _localStorageUsedMb = 247;
+ }
+
+ // ── Getters ────────────────────────────────────────────────────────────
+ List get playlists => _playlists;
+ List get userPlaylists => _playlists.where((p) => !p.isServer).toList();
+ List get serverPlaylists => _playlists.where((p) => p.isServer).toList();
+ List get playlistsByLastPlayed {
+ final sorted = [..._playlists];
+ sorted.sort((a, b) {
+ if (a.lastPlayed == null && b.lastPlayed == null) return 0;
+ if (a.lastPlayed == null) return 1;
+ if (b.lastPlayed == null) return -1;
+ return b.lastPlayed!.compareTo(a.lastPlayed!);
+ });
+ return sorted;
+ }
+
+ List get servers => _servers;
+ Song? get lastPlayedSong => _lastPlayedSong;
+ Playlist? get lastPlayedPlaylist => _lastPlayedPlaylist;
+ int get localStorageUsedMb => _localStorageUsedMb;
+ bool get isLoading => _isLoading;
+ String? get error => _error;
+
+ // Suggestions: pull from server playlists + random mix
+ List get suggestions {
+ final all = _playlists.expand((p) => p.songs).toList();
+ all.shuffle();
+ return all.take(8).toList();
+ }
+
+ // ── Playlist CRUD ──────────────────────────────────────────────────────
+ void addPlaylist(String name, {String? description}) {
+ _playlists.add(Playlist(
+ id: _uuid.v4(),
+ name: name,
+ description: description,
+ ));
+ notifyListeners();
+ }
+
+ void removePlaylist(String id) {
+ _playlists.removeWhere((p) => p.id == id);
+ notifyListeners();
+ }
+
+ void updatePlaylist(String id, {String? name, String? description}) {
+ final idx = _playlists.indexWhere((p) => p.id == id);
+ if (idx == -1) return;
+ if (name != null) _playlists[idx].name = name;
+ if (description != null) _playlists[idx].description = description;
+ notifyListeners();
+ }
+
+ void addSongToPlaylist(String playlistId, Song song) {
+ final idx = _playlists.indexWhere((p) => p.id == playlistId);
+ if (idx == -1) return;
+ if (!_playlists[idx].songs.any((s) => s.id == song.id)) {
+ _playlists[idx].songs.add(song);
+ notifyListeners();
+ }
+ }
+
+ void removeSongFromPlaylist(String playlistId, String songId) {
+ final idx = _playlists.indexWhere((p) => p.id == playlistId);
+ if (idx == -1) return;
+ _playlists[idx].songs.removeWhere((s) => s.id == songId);
+ notifyListeners();
+ }
+
+ void markPlaylistPlayed(String playlistId) {
+ final idx = _playlists.indexWhere((p) => p.id == playlistId);
+ if (idx != -1) {
+ _playlists[idx].lastPlayed = DateTime.now();
+ notifyListeners();
+ }
+ }
+
+ // ── Server Management ──────────────────────────────────────────────────
+ Future addServer(MusicServer server) async {
+ _servers.add(server);
+ notifyListeners();
+ await _checkServerStatus(server.id);
+ }
+
+ void removeServer(String id) {
+ _servers.removeWhere((s) => s.id == id);
+ _playlists.removeWhere((p) => p.serverId == id);
+ notifyListeners();
+ }
+
+ Future _checkServerStatus(String serverId) async {
+ final idx = _servers.indexWhere((s) => s.id == serverId);
+ if (idx == -1) return;
+ // Simulate check
+ await Future.delayed(const Duration(seconds: 1));
+ _servers[idx].status = ServerStatus.online;
+ notifyListeners();
+ }
+
+ Future checkAllServers() async {
+ for (final s in _servers) {
+ await _checkServerStatus(s.id);
+ }
+ }
+
+ // ── Local Storage ──────────────────────────────────────────────────────
+ Future clearLocalStorage() async {
+ _isLoading = true;
+ notifyListeners();
+ await Future.delayed(const Duration(seconds: 1));
+ _localStorageUsedMb = 0;
+ _isLoading = false;
+ notifyListeners();
+ }
+
+ // ── Search ─────────────────────────────────────────────────────────────
+ Future> search(String query) async {
+ if (query.trim().isEmpty) return [];
+ await Future.delayed(const Duration(milliseconds: 400));
+
+ final q = query.toLowerCase();
+ final results = [];
+
+ final allSongs = _playlists.expand((p) => p.songs).toSet().toList();
+ for (final song in allSongs) {
+ if (song.title.toLowerCase().contains(q) ||
+ song.artist.toLowerCase().contains(q) ||
+ song.album.toLowerCase().contains(q)) {
+ results.add(SearchResult(
+ type: SearchResultType.song,
+ id: song.id,
+ title: song.title,
+ subtitle: '${song.artist} • ${song.album}',
+ data: song,
+ ));
+ }
+ }
+
+ for (final pl in _playlists) {
+ if (pl.name.toLowerCase().contains(q)) {
+ results.add(SearchResult(
+ type: SearchResultType.playlist,
+ id: pl.id,
+ title: pl.name,
+ subtitle: '${pl.songCount} songs',
+ data: pl,
+ ));
+ }
+ }
+
+ return results;
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/providers/player_provider.dart b/src/frontend/lib/providers/player_provider.dart
new file mode 100644
index 0000000..3dbb277
--- /dev/null
+++ b/src/frontend/lib/providers/player_provider.dart
@@ -0,0 +1,156 @@
+// TODO Implement this library.import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+
+class PlayerProvider extends ChangeNotifier {
+ Song? _currentSong;
+ Playlist? _currentPlaylist;
+ List _queue = [];
+ int _queueIndex = 0;
+
+ bool _isPlaying = false;
+ bool _isLoading = false;
+ Duration _position = Duration.zero;
+ Duration _duration = Duration.zero;
+ double _volume = 1.0;
+ RepeatMode _repeat = RepeatMode.none;
+ ShuffleMode _shuffle = ShuffleMode.off;
+
+ bool _miniPlayerVisible = false;
+ bool _fullPlayerVisible = false;
+
+ // Getters
+ Song? get currentSong => _currentSong;
+ Playlist? get currentPlaylist => _currentPlaylist;
+ List get queue => _queue;
+ bool get isPlaying => _isPlaying;
+ bool get isLoading => _isLoading;
+ Duration get position => _position;
+ Duration get duration => _duration;
+ double get volume => _volume;
+ RepeatMode get repeat => _repeat;
+ ShuffleMode get shuffle => _shuffle;
+ bool get miniPlayerVisible => _miniPlayerVisible;
+ bool get fullPlayerVisible => _fullPlayerVisible;
+ double get progress => _duration.inSeconds > 0
+ ? _position.inSeconds / _duration.inSeconds
+ : 0.0;
+
+ // Play a song
+ Future playSong(Song song, {Playlist? playlist, List? queue}) async {
+ _currentSong = song;
+ _currentPlaylist = playlist;
+ if (queue != null) {
+ _queue = queue;
+ _queueIndex = queue.indexWhere((s) => s.id == song.id);
+ } else {
+ _queue = [song];
+ _queueIndex = 0;
+ }
+ _isPlaying = true;
+ _isLoading = true;
+ _miniPlayerVisible = true;
+ _position = Duration.zero;
+ _duration = song.duration;
+ notifyListeners();
+
+ // Simulate loading
+ await Future.delayed(const Duration(milliseconds: 500));
+ _isLoading = false;
+ notifyListeners();
+
+ // Simulate playback progress
+ _simulatePlayback();
+ }
+
+ void _simulatePlayback() async {
+ while (_isPlaying && _currentSong != null) {
+ await Future.delayed(const Duration(seconds: 1));
+ if (_isPlaying) {
+ _position += const Duration(seconds: 1);
+ if (_position >= _duration) {
+ await skipNext();
+ return;
+ }
+ notifyListeners();
+ }
+ }
+ }
+
+ void togglePlayPause() {
+ _isPlaying = !_isPlaying;
+ if (_isPlaying) _simulatePlayback();
+ notifyListeners();
+ }
+
+ Future skipNext() async {
+ if (_queue.isEmpty) return;
+ if (_repeat == RepeatMode.one) {
+ await playSong(_currentSong!, playlist: _currentPlaylist, queue: _queue);
+ return;
+ }
+ int next = _queueIndex + 1;
+ if (next >= _queue.length) {
+ if (_repeat == RepeatMode.all) {
+ next = 0;
+ } else {
+ _isPlaying = false;
+ notifyListeners();
+ return;
+ }
+ }
+ _queueIndex = next;
+ await playSong(_queue[next], playlist: _currentPlaylist, queue: _queue);
+ }
+
+ Future skipPrev() async {
+ if (_position.inSeconds > 3) {
+ _position = Duration.zero;
+ notifyListeners();
+ return;
+ }
+ if (_queue.isEmpty) return;
+ int prev = _queueIndex - 1;
+ if (prev < 0) prev = _queue.length - 1;
+ _queueIndex = prev;
+ await playSong(_queue[prev], playlist: _currentPlaylist, queue: _queue);
+ }
+
+ void seek(double ratio) {
+ _position = Duration(seconds: (_duration.inSeconds * ratio).round());
+ notifyListeners();
+ }
+
+ void setVolume(double v) {
+ _volume = v.clamp(0.0, 1.0);
+ notifyListeners();
+ }
+
+ void toggleRepeat() {
+ _repeat = RepeatMode.values[(_repeat.index + 1) % RepeatMode.values.length];
+ notifyListeners();
+ }
+
+ void toggleShuffle() {
+ _shuffle = _shuffle == ShuffleMode.off ? ShuffleMode.on : ShuffleMode.off;
+ notifyListeners();
+ }
+
+ void showFullPlayer() {
+ _fullPlayerVisible = true;
+ notifyListeners();
+ }
+
+ void hideFullPlayer() {
+ _fullPlayerVisible = false;
+ notifyListeners();
+ }
+
+ void stopAndHide() {
+ _isPlaying = false;
+ _miniPlayerVisible = false;
+ _fullPlayerVisible = false;
+ _currentSong = null;
+ notifyListeners();
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/screens/home_screen.dart b/src/frontend/lib/screens/home_screen.dart
new file mode 100644
index 0000000..4ec372b
--- /dev/null
+++ b/src/frontend/lib/screens/home_screen.dart
@@ -0,0 +1,439 @@
+// TODO Implement this library.import 'package:flutter/material.dart';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/library_provider.dart';
+import '../providers/player_provider.dart';
+import '../widgets/shared_widgets.dart';
+import '../theme/app_theme.dart';
+
+class HomeScreen extends StatelessWidget {
+ const HomeScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer2(
+ builder: (context, library, player, _) {
+ return CustomScrollView(
+ slivers: [
+ // App Bar
+ SliverAppBar(
+ floating: true,
+ pinned: false,
+ backgroundColor: LibreBeatsTheme.background,
+ title: Row(
+ children: [
+ Container(
+ width: 28,
+ height: 28,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.accent,
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: const Icon(Icons.music_note_rounded, color: Colors.white, size: 16),
+ ),
+ const SizedBox(width: 10),
+ const Text('LibreBeats',
+ style: TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 20,
+ fontWeight: FontWeight.w800,
+ letterSpacing: -0.5)),
+ ],
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.refresh, color: LibreBeatsTheme.textSecondary),
+ onPressed: () async {
+ await library.loadMyMusicData();
+ },
+ ),
+ const SizedBox(width: 8),
+ ],
+ ),
+
+ // Quick Filter Chips
+ // SliverToBoxAdapter(
+ // child: SingleChildScrollView(
+ // scrollDirection: Axis.horizontal,
+ // padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
+ // child: Row(
+ // children: [
+ // _FilterChip(label: 'All', selected: true),
+ // _FilterChip(label: 'Music'),
+ // _FilterChip(label: 'Podcasts'),
+ // _FilterChip(label: 'Mixes'),
+ // ],
+ // ),
+ // ),
+ // ),
+
+ // Last Played Banner
+ if (library.lastPlayedSong != null)
+ SliverToBoxAdapter(
+ child: _LastPlayedBanner(
+ song: library.lastPlayedSong!,
+ playlist: library.lastPlayedPlaylist,
+ onTap: () => player.playSong(
+ library.lastPlayedSong!,
+ playlist: library.lastPlayedPlaylist,
+ ),
+ ),
+ ),
+
+ // Recently Played Playlists (grid)
+ if (library.playlistsByLastPlayed.isNotEmpty)
+ SliverToBoxAdapter(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SectionHeader(title: 'Recently Played'),
+ _RecentGrid(library: library, player: player),
+ ],
+ ),
+ ),
+
+ // Your Playlists
+ SliverToBoxAdapter(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SectionHeader(title: 'Your Playlists', actionLabel: 'See all'),
+ SizedBox(
+ height: 190,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ itemCount: library.userPlaylists.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 14),
+ itemBuilder: (_, i) {
+ final pl = library.userPlaylists[i];
+ return PlaylistCard(
+ playlist: pl,
+ onTap: () {},
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Server Playlists
+ if (library.serverPlaylists.isNotEmpty)
+ SliverToBoxAdapter(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SectionHeader(title: 'From Your Servers'),
+ SizedBox(
+ height: 190,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ itemCount: library.serverPlaylists.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 14),
+ itemBuilder: (_, i) {
+ final pl = library.serverPlaylists[i];
+ return PlaylistCard(playlist: pl, onTap: () {});
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Suggestions (from server mix)
+ // SliverToBoxAdapter(
+ // child: Column(
+ // crossAxisAlignment: CrossAxisAlignment.start,
+ // children: [
+ // const SectionHeader(
+ // title: 'Suggested',
+ // actionLabel: 'Refresh',
+ // ),
+ // _SuggestionNote(),
+ // ...library.suggestions.take(1).map((song) => SongTile(
+ // song: song,
+ // onTap: () => player.playSong(song),
+ // onMore: () => _showSongMenu(context, song, library),
+ // )),
+ // const SizedBox(height: 100),
+ // ],
+ // ),
+ // ),
+ ],
+ );
+ },
+ );
+ }
+
+ void _showSongMenu(BuildContext context, song, LibraryProvider library) {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
+ builder: (_) => Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.playlist_add, color: LibreBeatsTheme.textSecondary),
+ title: const Text('Add to playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () {
+ Navigator.pop(context);
+ _showAddToPlaylist(context, song, library);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary),
+ title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ void _showAddToPlaylist(BuildContext context, song, LibraryProvider library) {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
+ builder: (_) => Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Add to playlist',
+ style: TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 16,
+ fontWeight: FontWeight.w700)),
+ const SizedBox(height: 12),
+ ...library.userPlaylists.map((pl) => ListTile(
+ leading: const Icon(Icons.queue_music, color: LibreBeatsTheme.textSecondary),
+ title: Text(pl.name, style: const TextStyle(color: LibreBeatsTheme.textPrimary)),
+ subtitle: Text('${pl.songCount} songs', style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 11)),
+ onTap: () {
+ library.addSongToPlaylist(pl.id, song);
+ Navigator.pop(context);
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Added to ${pl.name}'),
+ backgroundColor: LibreBeatsTheme.surface,
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ },
+ )),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _FilterChip extends StatelessWidget {
+ final String label;
+ final bool selected;
+ const _FilterChip({required this.label, this.selected = false});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ margin: const EdgeInsets.only(right: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
+ decoration: BoxDecoration(
+ color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary,
+ fontSize: 13,
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
+ ),
+ ),
+ );
+ }
+}
+
+class _LastPlayedBanner extends StatelessWidget {
+ final song;
+ final playlist;
+ final VoidCallback onTap;
+ const _LastPlayedBanner({required this.song, this.playlist, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ LibreBeatsTheme.accent.withOpacity(0.25),
+ LibreBeatsTheme.accent.withOpacity(0.05),
+ ],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: LibreBeatsTheme.accent.withOpacity(0.3)),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 52,
+ height: 52,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.accent.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Center(child: Text('🎵', style: TextStyle(fontSize: 26))),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('LAST PLAYED',
+ style: TextStyle(
+ color: LibreBeatsTheme.accent,
+ fontSize: 10,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.8)),
+ const SizedBox(height: 2),
+ Text(song.title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 15,
+ fontWeight: FontWeight.w700),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ Text(song.artist,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12)),
+ ],
+ ),
+ ),
+ Container(
+ width: 40,
+ height: 40,
+ decoration: const BoxDecoration(
+ color: LibreBeatsTheme.accent,
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 22),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _RecentGrid extends StatelessWidget {
+ final LibraryProvider library;
+ final PlayerProvider player;
+ const _RecentGrid({required this.library, required this.player});
+
+ @override
+ Widget build(BuildContext context) {
+ final recent = library.playlistsByLastPlayed.take(4).toList();
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ childAspectRatio: 3.2,
+ mainAxisSpacing: 8,
+ crossAxisSpacing: 8,
+ ),
+ itemCount: recent.length,
+ itemBuilder: (_, i) {
+ final pl = recent[i];
+ return GestureDetector(
+ onTap: () {
+ if (pl.songs.isNotEmpty) {
+ player.playSong(pl.songs.first, playlist: pl, queue: pl.songs);
+ }
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 48,
+ decoration: BoxDecoration(
+ color: pl.isServer
+ ? const Color(0xFF1A2A3A)
+ : const Color(0xFF2A1A1A),
+ borderRadius: const BorderRadius.horizontal(left: Radius.circular(8)),
+ ),
+ child: Center(
+ child: Text(pl.isServer ? '📡' : '🎵',
+ style: const TextStyle(fontSize: 20)),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(pl.name,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 12,
+ fontWeight: FontWeight.w600),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _SuggestionNote extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
+ child: Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: LibreBeatsTheme.border),
+ ),
+ child: const Row(
+ children: [
+ Icon(Icons.info_outline, color: LibreBeatsTheme.textSecondary, size: 15),
+ SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Suggestions are pulled from your connected server libraries and local playlists.',
+ style: TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 11),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/screens/library_screen.dart b/src/frontend/lib/screens/library_screen.dart
new file mode 100644
index 0000000..b6d1678
--- /dev/null
+++ b/src/frontend/lib/screens/library_screen.dart
@@ -0,0 +1,327 @@
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:provider/provider.dart';
+import '../providers/library_provider.dart';
+import '../widgets/shared_widgets.dart';
+import '../theme/app_theme.dart';
+import 'playlist_detail_screen.dart';
+
+class LibraryScreen extends StatelessWidget {
+ const LibraryScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, library, _) {
+ final playlists = library.playlistsByLastPlayed;
+ return CustomScrollView(
+ slivers: [
+ SliverAppBar(
+ floating: true,
+ pinned: true,
+ backgroundColor: LibreBeatsTheme.background,
+ title: const Text('Library'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.add, color: LibreBeatsTheme.textSecondary),
+ onPressed: () => _showCreateDialog(context, library),
+ ),
+ IconButton(
+ icon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary),
+ onPressed: () {},
+ ),
+ ],
+ ),
+
+ // Sort tabs
+ SliverToBoxAdapter(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
+ child: Row(
+ children: [
+ _SortChip(label: 'Recently played', selected: true),
+ _SortChip(label: 'A–Z'),
+ _SortChip(label: 'My playlists'),
+ _SortChip(label: 'Server'),
+ ],
+ ),
+ ),
+ ),
+
+ // Playlist list
+ SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, i) {
+ final pl = playlists[i];
+ return _PlaylistTile(
+ playlist: pl,
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => PlaylistDetailScreen(playlist: pl),
+ ),
+ ),
+ onDelete: () => _confirmDelete(context, library, pl),
+ onEdit: () => _showEditDialog(context, library, pl),
+ );
+ },
+ childCount: playlists.length,
+ ),
+ ),
+
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
+ ],
+ );
+ },
+ );
+ }
+
+ void _showCreateDialog(BuildContext context, LibraryProvider library) {
+ final ctrl = TextEditingController();
+ showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: const Text('New Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ content: TextField(
+ controller: ctrl,
+ autofocus: true,
+ style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: const InputDecoration(hintText: 'Playlist name'),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ ),
+ TextButton(
+ onPressed: () {
+ if (ctrl.text.isNotEmpty) {
+ library.addPlaylist(ctrl.text);
+ Navigator.pop(context);
+ }
+ },
+ child: const Text('Create', style: TextStyle(color: LibreBeatsTheme.accent)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showEditDialog(BuildContext context, LibraryProvider library, Playlist pl) {
+ final ctrl = TextEditingController(text: pl.name);
+ showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: const Text('Edit Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ content: TextField(
+ controller: ctrl,
+ autofocus: true,
+ style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: const InputDecoration(hintText: 'Playlist name'),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ ),
+ TextButton(
+ onPressed: () {
+ library.updatePlaylist(pl.id, name: ctrl.text);
+ Navigator.pop(context);
+ },
+ child: const Text('Save', style: TextStyle(color: LibreBeatsTheme.accent)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _confirmDelete(BuildContext context, LibraryProvider library, Playlist pl) {
+ if (pl.isServer) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Cannot delete server playlists'),
+ backgroundColor: LibreBeatsTheme.surface,
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ return;
+ }
+ showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: const Text('Delete Playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ content: Text('Remove "${pl.name}"? This cannot be undone.',
+ style: const TextStyle(color: LibreBeatsTheme.textSecondary)),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ ),
+ TextButton(
+ onPressed: () {
+ library.removePlaylist(pl.id);
+ Navigator.pop(context);
+ },
+ child: const Text('Delete', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _PlaylistTile extends StatelessWidget {
+ final Playlist playlist;
+ final VoidCallback onTap;
+ final VoidCallback onDelete;
+ final VoidCallback onEdit;
+
+ const _PlaylistTile({
+ required this.playlist,
+ required this.onTap,
+ required this.onDelete,
+ required this.onEdit,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ // Cover
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ gradient: LinearGradient(
+ colors: playlist.isServer
+ ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)]
+ : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ child: Center(
+ child: Text(playlist.isServer ? '📡' : '🎵',
+ style: const TextStyle(fontSize: 26)),
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(playlist.name,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w600),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ),
+ if (playlist.isServer)
+ ChipTag(label: 'SERVER', color: const Color(0xFF1DB954)),
+ ],
+ ),
+ const SizedBox(height: 3),
+ Text(
+ '${playlist.songCount} songs${playlist.lastPlayed != null ? " • " + _timeAgo(playlist.lastPlayed!) : ""}',
+ style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.more_vert, size: 18),
+ color: LibreBeatsTheme.textSecondary,
+ splashRadius: 20,
+ onPressed: () => _showMenu(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ void _showMenu(BuildContext context) {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
+ builder: (_) => Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (!playlist.isServer) ...[
+ ListTile(
+ leading: const Icon(Icons.edit, color: LibreBeatsTheme.textSecondary, size: 20),
+ title: const Text('Edit', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () { Navigator.pop(context); onEdit(); },
+ ),
+ ListTile(
+ leading: const Icon(Icons.delete_outline, color: Colors.red, size: 20),
+ title: const Text('Delete', style: TextStyle(color: Colors.red)),
+ onTap: () { Navigator.pop(context); onDelete(); },
+ ),
+ ],
+ ListTile(
+ leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20),
+ title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _timeAgo(DateTime dt) {
+ final diff = DateTime.now().difference(dt);
+ if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
+ if (diff.inHours < 24) return '${diff.inHours}h ago';
+ return '${diff.inDays}d ago';
+ }
+}
+
+class _SortChip extends StatelessWidget {
+ final String label;
+ final bool selected;
+ const _SortChip({required this.label, this.selected = false});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ margin: const EdgeInsets.only(right: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: selected ? LibreBeatsTheme.textPrimary : LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ color: selected ? LibreBeatsTheme.background : LibreBeatsTheme.textSecondary,
+ fontSize: 12,
+ fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/screens/playlist_detail_screen.dart b/src/frontend/lib/screens/playlist_detail_screen.dart
new file mode 100644
index 0000000..454a8b8
--- /dev/null
+++ b/src/frontend/lib/screens/playlist_detail_screen.dart
@@ -0,0 +1,190 @@
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:provider/provider.dart';
+import '../providers/library_provider.dart';
+import '../providers/player_provider.dart';
+import '../widgets/shared_widgets.dart';
+import '../theme/app_theme.dart';
+
+class PlaylistDetailScreen extends StatelessWidget {
+ final Playlist playlist;
+ const PlaylistDetailScreen({super.key, required this.playlist});
+
+ @override
+ Widget build(BuildContext context) {
+ final player = context.read();
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ SliverAppBar(
+ expandedHeight: 280,
+ pinned: true,
+ backgroundColor: LibreBeatsTheme.background,
+ flexibleSpace: FlexibleSpaceBar(
+ background: Stack(
+ fit: StackFit.expand,
+ children: [
+ Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: playlist.isServer
+ ? [const Color(0xFF1A2A3A), LibreBeatsTheme.background]
+ : [const Color(0xFF3A1A1A), LibreBeatsTheme.background],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ ),
+ ),
+ Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const SizedBox(height: 60),
+ Container(
+ width: 140,
+ height: 140,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(12),
+ color: LibreBeatsTheme.surface,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.5),
+ blurRadius: 30,
+ offset: const Offset(0, 10))
+ ],
+ ),
+ child: Center(
+ child: Text(playlist.isServer ? '📡' : '🎵',
+ style: const TextStyle(fontSize: 60)),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Text(playlist.name,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 20,
+ fontWeight: FontWeight.w800)),
+ Text('${playlist.songCount} songs',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 13)),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Play controls
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: AccentButton(
+ label: 'Play',
+ icon: Icons.play_arrow_rounded,
+ onTap: playlist.songs.isEmpty
+ ? null
+ : () => player.playSong(
+ playlist.songs.first,
+ playlist: playlist,
+ queue: playlist.songs,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: AccentButton(
+ label: 'Shuffle',
+ icon: Icons.shuffle_rounded,
+ outlined: true,
+ onTap: playlist.songs.isEmpty
+ ? null
+ : () {
+ final shuffled = [...playlist.songs]..shuffle();
+ player.playSong(shuffled.first,
+ playlist: playlist, queue: shuffled);
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Song list
+ if (playlist.songs.isEmpty)
+ const SliverToBoxAdapter(
+ child: Padding(
+ padding: EdgeInsets.all(40),
+ child: Column(
+ children: [
+ Icon(Icons.queue_music, color: LibreBeatsTheme.textDim, size: 48),
+ SizedBox(height: 12),
+ Text('No songs yet',
+ style: TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 15)),
+ SizedBox(height: 6),
+ Text('Search for songs and add them here.',
+ style: TextStyle(color: LibreBeatsTheme.textDim, fontSize: 13)),
+ ],
+ ),
+ ),
+ ),
+
+ SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, i) {
+ final song = playlist.songs[i];
+ return SongTile(
+ song: song,
+ onTap: () => player.playSong(song, playlist: playlist, queue: playlist.songs),
+ onMore: () => _showSongOptions(context, song),
+ );
+ },
+ childCount: playlist.songs.length,
+ ),
+ ),
+
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
+ ],
+ ),
+ );
+ }
+
+ void _showSongOptions(BuildContext context, Song song) {
+ final library = context.read();
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
+ builder: (_) => Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (!playlist.isServer)
+ ListTile(
+ leading: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 20),
+ title: const Text('Remove from playlist', style: TextStyle(color: Colors.red)),
+ onTap: () {
+ library.removeSongFromPlaylist(playlist.id, song.id);
+ Navigator.pop(context);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.playlist_add, color: LibreBeatsTheme.textSecondary, size: 20),
+ title: const Text('Add to another playlist', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () => Navigator.pop(context),
+ ),
+ ListTile(
+ leading: const Icon(Icons.share, color: LibreBeatsTheme.textSecondary, size: 20),
+ title: const Text('Share', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ onTap: () => Navigator.pop(context),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/screens/search_screen.dart b/src/frontend/lib/screens/search_screen.dart
new file mode 100644
index 0000000..6d69e64
--- /dev/null
+++ b/src/frontend/lib/screens/search_screen.dart
@@ -0,0 +1,275 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:provider/provider.dart';
+import '../providers/library_provider.dart';
+import '../providers/player_provider.dart';
+import '../widgets/shared_widgets.dart';
+import '../theme/app_theme.dart';
+
+class SearchScreen extends StatefulWidget {
+ const SearchScreen({super.key});
+
+ @override
+ State createState() => _SearchScreenState();
+}
+
+class _SearchScreenState extends State {
+ final _controller = TextEditingController();
+ List _results = [];
+ bool _loading = false;
+ Timer? _debounce;
+ String _lastQuery = '';
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ _debounce?.cancel();
+ super.dispose();
+ }
+
+ void _onChanged(String q) {
+ _debounce?.cancel();
+ if (q.isEmpty) {
+ setState(() { _results = []; _loading = false; });
+ return;
+ }
+ setState(() => _loading = true);
+ _debounce = Timer(const Duration(milliseconds: 350), () async {
+ final library = context.read();
+ final res = await library.search(q);
+ if (mounted) setState(() { _results = res; _loading = false; _lastQuery = q; });
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomScrollView(
+ slivers: [
+ SliverAppBar(
+ floating: true,
+ pinned: true,
+ backgroundColor: LibreBeatsTheme.background,
+ title: const Text('Search'),
+ bottom: PreferredSize(
+ preferredSize: const Size.fromHeight(60),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+ child: TextField(
+ controller: _controller,
+ onChanged: _onChanged,
+ autofocus: false,
+ style: const TextStyle(color: LibreBeatsTheme.textPrimary, fontSize: 15),
+ decoration: InputDecoration(
+ hintText: 'Songs, artists, albums...',
+ prefixIcon: const Icon(Icons.search, color: LibreBeatsTheme.textSecondary, size: 20),
+ suffixIcon: _controller.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.close, size: 18, color: LibreBeatsTheme.textSecondary),
+ onPressed: () {
+ _controller.clear();
+ _onChanged('');
+ },
+ )
+ : null,
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ if (_controller.text.isEmpty)
+ const SliverToBoxAdapter(child: _BrowseCategories()),
+
+ if (_loading)
+ const SliverToBoxAdapter(
+ child: Padding(
+ padding: EdgeInsets.all(40),
+ child: Center(child: CircularProgressIndicator(color: LibreBeatsTheme.accent, strokeWidth: 2)),
+ ),
+ ),
+
+ if (!_loading && _controller.text.isNotEmpty && _results.isEmpty)
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.all(40),
+ child: Column(
+ children: [
+ const Icon(Icons.search_off, color: LibreBeatsTheme.textDim, size: 48),
+ const SizedBox(height: 12),
+ Text('No results for "$_lastQuery"',
+ style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 15)),
+ ],
+ ),
+ ),
+ ),
+
+ if (!_loading && _results.isNotEmpty) ...[
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Text('${_results.length} results for "$_lastQuery"',
+ style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 13)),
+ ),
+ ),
+ SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, i) => _ResultTile(result: _results[i]),
+ childCount: _results.length,
+ ),
+ ),
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
+ ],
+ ],
+ );
+ }
+}
+
+class _ResultTile extends StatelessWidget {
+ final SearchResult result;
+ const _ResultTile({required this.result});
+
+ @override
+ Widget build(BuildContext context) {
+ final player = context.read();
+ final icon = result.type == SearchResultType.song
+ ? Icons.music_note_rounded
+ : result.type == SearchResultType.playlist
+ ? Icons.queue_music_rounded
+ : result.type == SearchResultType.artist
+ ? Icons.person_rounded
+ : Icons.album_rounded;
+
+ return InkWell(
+ onTap: () {
+ if (result.type == SearchResultType.song && result.data is Song) {
+ player.playSong(result.data as Song);
+ }
+ // TODO: navigate to playlist/album/artist
+ },
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ Container(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(
+ result.type == SearchResultType.artist ? 24 : 8),
+ ),
+ child: Icon(icon, color: LibreBeatsTheme.textSecondary, size: 22),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(result.title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w600),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ Row(
+ children: [
+ ChipTag(
+ label: result.type.name.toUpperCase(),
+ color: result.type == SearchResultType.song
+ ? LibreBeatsTheme.accent
+ : result.type == SearchResultType.playlist
+ ? const Color(0xFF1DB954)
+ : LibreBeatsTheme.textSecondary,
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(result.subtitle,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ if (result.type == SearchResultType.song)
+ IconButton(
+ icon: const Icon(Icons.more_vert, size: 18),
+ color: LibreBeatsTheme.textSecondary,
+ onPressed: () {},
+ splashRadius: 20,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _BrowseCategories extends StatelessWidget {
+ const _BrowseCategories();
+
+ static const _cats = [
+ ('Trending', '🔥', Color(0xFF8B1A1A)),
+ ('Electronic', '⚡', Color(0xFF1A3A5A)),
+ ('Indie', '🎸', Color(0xFF3A2A1A)),
+ ('Focus', '🎯', Color(0xFF1A3A2A)),
+ ('Hip-Hop', '🎤', Color(0xFF2A1A3A)),
+ ('Ambient', '🌊', Color(0xFF1A2A3A)),
+ ('Rock', '🤘', Color(0xFF3A1A1A)),
+ ('Jazz', '🎷', Color(0xFF2A3A1A)),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SectionHeader(title: 'Browse by genre'),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ childAspectRatio: 2.0,
+ mainAxisSpacing: 10,
+ crossAxisSpacing: 10,
+ ),
+ itemCount: _cats.length,
+ itemBuilder: (_, i) {
+ final (name, emoji, color) = _cats[i];
+ return GestureDetector(
+ onTap: () {},
+ child: Container(
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ padding: const EdgeInsets.all(14),
+ child: Row(
+ children: [
+ Text(emoji, style: const TextStyle(fontSize: 24)),
+ const SizedBox(width: 10),
+ Text(name,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 14,
+ fontWeight: FontWeight.w700)),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 100),
+ ],
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/screens/settings_screen.dart b/src/frontend/lib/screens/settings_screen.dart
new file mode 100644
index 0000000..38d965f
--- /dev/null
+++ b/src/frontend/lib/screens/settings_screen.dart
@@ -0,0 +1,585 @@
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:provider/provider.dart';
+import 'package:uuid/uuid.dart';
+import '../providers/library_provider.dart';
+import '../widgets/shared_widgets.dart';
+import '../theme/app_theme.dart';
+
+const _uuid = Uuid();
+
+class SettingsScreen extends StatelessWidget {
+ const SettingsScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, library, _) {
+ return CustomScrollView(
+ slivers: [
+ const SliverAppBar(
+ floating: true,
+ pinned: true,
+ backgroundColor: LibreBeatsTheme.background,
+ title: Text('Settings'),
+ ),
+
+ // ── Servers ──────────────────────────────────────────────────
+ const SliverToBoxAdapter(child: SectionHeader(title: 'Music Servers')),
+
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ ...library.servers.map((s) => _ServerCard(
+ server: s,
+ onRemove: () => _confirmRemoveServer(context, library, s),
+ onTest: () async {
+ await library.checkAllServers();
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('${s.name}: ${s.status.name}'),
+ backgroundColor: LibreBeatsTheme.surface,
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ },
+ )),
+ const SizedBox(height: 10),
+ _AddButton(
+ label: 'Add Server',
+ icon: Icons.add,
+ onTap: () => _showAddServerSheet(context, library),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // ── Local Storage ─────────────────────────────────────────────
+ const SliverToBoxAdapter(child: SectionHeader(title: 'Local Storage')),
+
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ _StorageCard(library: library),
+ ],
+ ),
+ ),
+ ),
+
+ // ── App ───────────────────────────────────────────────────────
+ const SliverToBoxAdapter(child: SectionHeader(title: 'App')),
+
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Column(
+ children: [
+ _SettingsTile(
+ icon: Icons.info_outline,
+ title: 'About LibreBeats',
+ subtitle: 'Version 1.0.0',
+ onTap: () => _showAbout(context),
+ ),
+ _SettingsTile(
+ icon: Icons.code,
+ title: 'Open Source Licenses',
+ onTap: () => showLicensePage(context: context),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
+ ],
+ );
+ },
+ );
+ }
+
+ void _confirmRemoveServer(BuildContext context, LibraryProvider library, MusicServer s) {
+ showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: const Text('Remove Server', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ content: Text('Remove "${s.name}"? Server playlists will also be removed.',
+ style: const TextStyle(color: LibreBeatsTheme.textSecondary)),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel', style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ ),
+ TextButton(
+ onPressed: () {
+ library.removeServer(s.id);
+ Navigator.pop(context);
+ },
+ child: const Text('Remove', style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showAddServerSheet(BuildContext context, LibraryProvider library) {
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
+ builder: (_) => _AddServerForm(library: library),
+ );
+ }
+
+ void _showAbout(BuildContext context) {
+ showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: Row(
+ children: [
+ Container(
+ width: 32,
+ height: 32,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.accent,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Icon(Icons.music_note_rounded, color: Colors.white, size: 18),
+ ),
+ const SizedBox(width: 10),
+ const Text('LibreBeats', style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ ],
+ ),
+ content: const Text(
+ 'A free, open-source music player for Subsonic-compatible servers and local libraries.\n\nVersion 1.0.0',
+ style: TextStyle(color: LibreBeatsTheme.textSecondary, height: 1.5),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Close', style: TextStyle(color: LibreBeatsTheme.accent)),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ── Server Card ────────────────────────────────────────────────────────────
+class _ServerCard extends StatelessWidget {
+ final MusicServer server;
+ final VoidCallback onRemove;
+ final VoidCallback onTest;
+
+ const _ServerCard({required this.server, required this.onRemove, required this.onTest});
+
+ @override
+ Widget build(BuildContext context) {
+ final isOnline = server.status == ServerStatus.online;
+ return Container(
+ margin: const EdgeInsets.only(bottom: 10),
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: LibreBeatsTheme.border),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ color: const Color(0xFF1A2A3A),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Icon(Icons.dns_rounded, color: Color(0xFF4A9FD4), size: 20),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(server.name,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w700)),
+ Text(server.url,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 11),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ],
+ ),
+ ),
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: isOnline ? LibreBeatsTheme.online : LibreBeatsTheme.offline,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ isOnline ? 'Online' : 'Offline',
+ style: TextStyle(
+ color: isOnline ? LibreBeatsTheme.online : LibreBeatsTheme.offline,
+ fontSize: 11,
+ fontWeight: FontWeight.w600),
+ ),
+ ],
+ ),
+ if (server.songCount != null) ...[
+ const SizedBox(height: 10),
+ Row(
+ children: [
+ ChipTag(label: server.typeLabel, color: const Color(0xFF4A9FD4)),
+ const SizedBox(width: 8),
+ Text('${server.songCount} songs',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 11)),
+ ],
+ ),
+ ],
+ const SizedBox(height: 10),
+ const Divider(height: 1),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Expanded(
+ child: TextButton.icon(
+ onPressed: onTest,
+ icon: const Icon(Icons.wifi_find_rounded, size: 15),
+ label: const Text('Test', style: TextStyle(fontSize: 12)),
+ style: TextButton.styleFrom(
+ foregroundColor: LibreBeatsTheme.textSecondary,
+ padding: const EdgeInsets.symmetric(vertical: 6),
+ ),
+ ),
+ ),
+ Expanded(
+ child: TextButton.icon(
+ onPressed: onRemove,
+ icon: const Icon(Icons.delete_outline, size: 15),
+ label: const Text('Remove', style: TextStyle(fontSize: 12)),
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.red,
+ padding: const EdgeInsets.symmetric(vertical: 6),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ── Storage Card ───────────────────────────────────────────────────────────
+class _StorageCard extends StatelessWidget {
+ final LibraryProvider library;
+ const _StorageCard({required this.library});
+
+ @override
+ Widget build(BuildContext context) {
+ final used = library.localStorageUsedMb;
+ final total = 512;
+ final ratio = (used / total).clamp(0.0, 1.0);
+
+ return Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: LibreBeatsTheme.border),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text('Cached Audio',
+ style: TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w600)),
+ Text('${used}MB / ${total}MB',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12)),
+ ],
+ ),
+ const SizedBox(height: 10),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(4),
+ child: LinearProgressIndicator(
+ value: ratio,
+ minHeight: 6,
+ backgroundColor: LibreBeatsTheme.border,
+ valueColor: AlwaysStoppedAnimation(
+ ratio > 0.8 ? Colors.orange : LibreBeatsTheme.accent,
+ ),
+ ),
+ ),
+ const SizedBox(height: 14),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton.icon(
+ onPressed: library.isLoading
+ ? null
+ : () async {
+ final confirm = await showDialog(
+ context: context,
+ builder: (_) => AlertDialog(
+ backgroundColor: LibreBeatsTheme.surface,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ title: const Text('Clear Cache',
+ style: TextStyle(color: LibreBeatsTheme.textPrimary)),
+ content: const Text(
+ 'This will delete all cached audio. You will need an internet connection to stream again.',
+ style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context, false),
+ child: const Text('Cancel',
+ style: TextStyle(color: LibreBeatsTheme.textSecondary)),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(context, true),
+ child: const Text('Clear',
+ style: TextStyle(color: Colors.red)),
+ ),
+ ],
+ ),
+ );
+ if (confirm == true) await library.clearLocalStorage();
+ },
+ icon: library.isLoading
+ ? const SizedBox(
+ width: 14, height: 14,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
+ : const Icon(Icons.delete_sweep_rounded, size: 16),
+ label: Text(library.isLoading ? 'Clearing...' : 'Clear Cache'),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: const Color(0xFF2A1A1A),
+ foregroundColor: Colors.red,
+ elevation: 0,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ── Add Server Form ────────────────────────────────────────────────────────
+class _AddServerForm extends StatefulWidget {
+ final LibraryProvider library;
+ const _AddServerForm({required this.library});
+
+ @override
+ State<_AddServerForm> createState() => _AddServerFormState();
+}
+
+class _AddServerFormState extends State<_AddServerForm> {
+ final _name = TextEditingController();
+ final _url = TextEditingController();
+ final _user = TextEditingController();
+ final _pass = TextEditingController();
+ ServerType _type = ServerType.librebeats;
+ bool _obscure = true;
+ bool _saving = false;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: EdgeInsets.fromLTRB(
+ 20, 20, 20, MediaQuery.of(context).viewInsets.bottom + 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Add Server',
+ style: TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 18,
+ fontWeight: FontWeight.w700)),
+ const SizedBox(height: 16),
+
+ // Type selector
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: ServerType.values.map((t) {
+ final sel = t == _type;
+ return GestureDetector(
+ onTap: () => setState(() => _type = t),
+ child: Container(
+ margin: const EdgeInsets.only(right: 8),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: sel ? LibreBeatsTheme.accent : LibreBeatsTheme.surfaceVariant,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ t.name[0].toUpperCase() + t.name.substring(1),
+ style: TextStyle(
+ color: sel ? Colors.white : LibreBeatsTheme.textSecondary,
+ fontSize: 12,
+ fontWeight: sel ? FontWeight.w700 : FontWeight.w500),
+ ),
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ const SizedBox(height: 14),
+
+ TextField(controller: _name, style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: const InputDecoration(hintText: 'Server name')),
+ const SizedBox(height: 10),
+ TextField(controller: _url, style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: const InputDecoration(hintText: 'URL (http://...)'),
+ keyboardType: TextInputType.url),
+ const SizedBox(height: 10),
+ TextField(controller: _user, style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: const InputDecoration(hintText: 'Username')),
+ const SizedBox(height: 10),
+ TextField(
+ controller: _pass,
+ obscureText: _obscure,
+ style: const TextStyle(color: LibreBeatsTheme.textPrimary),
+ decoration: InputDecoration(
+ hintText: 'Password',
+ suffixIcon: IconButton(
+ icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility,
+ color: LibreBeatsTheme.textSecondary, size: 18),
+ onPressed: () => setState(() => _obscure = !_obscure),
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton(
+ onPressed: _saving ? null : _save,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: LibreBeatsTheme.accent,
+ foregroundColor: Colors.white,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ ),
+ child: _saving
+ ? const SizedBox(width: 18, height: 18,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
+ : const Text('Connect', style: TextStyle(fontWeight: FontWeight.w700)),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _save() async {
+ if (_name.text.isEmpty || _url.text.isEmpty) return;
+ setState(() => _saving = true);
+ final server = MusicServer(
+ id: _uuid.v4(),
+ name: _name.text,
+ url: _url.text,
+ username: _user.text,
+ password: _pass.text,
+ type: _type,
+ );
+ await widget.library.addServer(server);
+ if (mounted) Navigator.pop(context);
+ }
+}
+
+// ── Shared Small Widgets ───────────────────────────────────────────────────
+class _AddButton extends StatelessWidget {
+ final String label;
+ final IconData icon;
+ final VoidCallback onTap;
+ const _AddButton({required this.label, required this.icon, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: LibreBeatsTheme.border, style: BorderStyle.solid),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(icon, color: LibreBeatsTheme.accent, size: 18),
+ const SizedBox(width: 8),
+ Text(label,
+ style: const TextStyle(
+ color: LibreBeatsTheme.accent,
+ fontSize: 14,
+ fontWeight: FontWeight.w600)),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SettingsTile extends StatelessWidget {
+ final IconData icon;
+ final String title;
+ final String? subtitle;
+ final VoidCallback? onTap;
+ const _SettingsTile({required this.icon, required this.title, this.subtitle, this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ margin: const EdgeInsets.only(bottom: 8),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: LibreBeatsTheme.border),
+ ),
+ child: ListTile(
+ leading: Icon(icon, color: LibreBeatsTheme.textSecondary, size: 20),
+ title: Text(title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary, fontSize: 14, fontWeight: FontWeight.w500)),
+ subtitle: subtitle != null
+ ? Text(subtitle!, style: const TextStyle(color: LibreBeatsTheme.textSecondary, fontSize: 12))
+ : null,
+ trailing: const Icon(Icons.chevron_right, color: LibreBeatsTheme.textDim, size: 18),
+ onTap: onTap,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/theme/app_theme.dart b/src/frontend/lib/theme/app_theme.dart
new file mode 100644
index 0000000..0885e36
--- /dev/null
+++ b/src/frontend/lib/theme/app_theme.dart
@@ -0,0 +1,84 @@
+import 'package:flutter/material.dart';
+
+class LibreBeatsTheme {
+// Core palette
+static const Color background = Color(0xFF0A0F0A);
+static const Color surface = Color(0xFF111611);
+static const Color surfaceVariant = Color(0xFF181F18);
+static const Color card = Color(0xFF0F140F);
+static const Color border = Color(0xFF263026);
+
+// Accent (deep emerald)
+static const Color accent = Color(0xFF1DB954);
+static const Color accentDim = Color(0x221DB954);
+static const Color accentGlow = Color(0x441DB954);
+
+// Text
+static const Color textPrimary = Color(0xFFE6EEE6);
+static const Color textSecondary = Color(0xFF8C968C);
+static const Color textDim = Color(0xFF566156);
+
+// Status
+static const Color online = Color(0xFF1DB954);
+static const Color offline = Color(0xFF7A847A);
+
+ static ThemeData get theme => ThemeData(
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: background,
+ colorScheme: const ColorScheme.dark(
+ primary: accent,
+ secondary: accent,
+ surface: surface,
+ onPrimary: Colors.white,
+ onSurface: textPrimary,
+ ),
+ appBarTheme: const AppBarTheme(
+ backgroundColor: background,
+ elevation: 0,
+ centerTitle: false,
+ titleTextStyle: TextStyle(
+ color: textPrimary,
+ fontSize: 20,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.3,
+ ),
+ iconTheme: IconThemeData(color: textSecondary),
+ ),
+ bottomNavigationBarTheme: const BottomNavigationBarThemeData(
+ backgroundColor: Color(0xFF121212),
+ selectedItemColor: textPrimary,
+ unselectedItemColor: textDim,
+ type: BottomNavigationBarType.fixed,
+ elevation: 0,
+ selectedLabelStyle: TextStyle(fontSize: 10, fontWeight: FontWeight.w600),
+ unselectedLabelStyle: TextStyle(fontSize: 10),
+ ),
+ cardTheme: CardThemeData(
+ color: card,
+ elevation: 0,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: surface,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(10),
+ borderSide: BorderSide.none,
+ ),
+ hintStyle: const TextStyle(color: textSecondary, fontSize: 14),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ ),
+ textTheme: const TextTheme(
+ displayLarge: TextStyle(color: textPrimary, fontWeight: FontWeight.w800),
+ headlineMedium: TextStyle(color: textPrimary, fontWeight: FontWeight.w700, fontSize: 22),
+ titleLarge: TextStyle(color: textPrimary, fontWeight: FontWeight.w700, fontSize: 16),
+ titleMedium: TextStyle(color: textPrimary, fontWeight: FontWeight.w600, fontSize: 14),
+ bodyMedium: TextStyle(color: textSecondary, fontSize: 13),
+ labelSmall: TextStyle(color: textDim, fontSize: 11),
+ ),
+ dividerTheme: const DividerThemeData(color: border, thickness: 1),
+ iconTheme: const IconThemeData(color: textSecondary),
+ splashColor: accentDim,
+ highlightColor: Colors.transparent,
+ );
+}
\ No newline at end of file
diff --git a/src/frontend/lib/widgets/player_widgets.dart b/src/frontend/lib/widgets/player_widgets.dart
new file mode 100644
index 0000000..944de65
--- /dev/null
+++ b/src/frontend/lib/widgets/player_widgets.dart
@@ -0,0 +1,417 @@
+// TODO Implement this library.import 'package:flutter/material.dart';
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import 'package:provider/provider.dart';
+import '../providers/player_provider.dart';
+import '../theme/app_theme.dart';
+import 'shared_widgets.dart';
+
+// ── Mini Player ────────────────────────────────────────────────────────────
+class MiniPlayer extends StatelessWidget {
+ const MiniPlayer({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, player, _) {
+ if (!player.miniPlayerVisible || player.currentSong == null) {
+ return const SizedBox.shrink();
+ }
+ final song = player.currentSong!;
+ return GestureDetector(
+ onTap: player.showFullPlayer,
+ child: Container(
+ margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: LibreBeatsTheme.border),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.5),
+ blurRadius: 20,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Progress bar at top
+ ClipRRect(
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
+ child: LinearProgressIndicator(
+ value: player.progress,
+ backgroundColor: LibreBeatsTheme.border,
+ valueColor: const AlwaysStoppedAnimation(LibreBeatsTheme.accent),
+ minHeight: 2,
+ ),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ child: Row(
+ children: [
+ AlbumArt(size: 40, radius: 8),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(song.title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 13,
+ fontWeight: FontWeight.w600),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ Text(song.artist,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary,
+ fontSize: 11),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ],
+ ),
+ ),
+ _MiniBtn(
+ icon: Icons.skip_previous_rounded,
+ onTap: player.skipPrev,
+ ),
+ _MiniBtn(
+ icon: player.isPlaying
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ size: 32,
+ onTap: player.togglePlayPause,
+ color: LibreBeatsTheme.textPrimary,
+ ),
+ _MiniBtn(
+ icon: Icons.skip_next_rounded,
+ onTap: player.skipNext,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _MiniBtn extends StatelessWidget {
+ final IconData icon;
+ final VoidCallback? onTap;
+ final double size;
+ final Color? color;
+
+ const _MiniBtn({
+ required this.icon,
+ this.onTap,
+ this.size = 22,
+ this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return IconButton(
+ icon: Icon(icon, size: size, color: color ?? LibreBeatsTheme.textSecondary),
+ onPressed: onTap,
+ splashRadius: 20,
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ constraints: const BoxConstraints(),
+ );
+ }
+}
+
+// ── Full Player Screen ─────────────────────────────────────────────────────
+class FullPlayerSheet extends StatefulWidget {
+ const FullPlayerSheet({super.key});
+
+ static void show(BuildContext context) {
+ showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ builder: (_) => const FullPlayerSheet(),
+ );
+ }
+
+ @override
+ State createState() => _FullPlayerSheetState();
+}
+
+class _FullPlayerSheetState extends State {
+ bool _liked = false;
+
+ @override
+ Widget build(BuildContext context) {
+ return Consumer(
+ builder: (context, player, _) {
+ final song = player.currentSong;
+ if (song == null) return const SizedBox.shrink();
+
+ return Container(
+ height: MediaQuery.of(context).size.height * 0.92,
+ decoration: const BoxDecoration(
+ color: Color(0xFF141414),
+ borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ child: Column(
+ children: [
+ // Handle
+ Container(
+ margin: const EdgeInsets.only(top: 12, bottom: 8),
+ width: 36,
+ height: 4,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.border,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ // Top bar
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: Row(
+ children: [
+ IconButton(
+ icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 28),
+ color: LibreBeatsTheme.textSecondary,
+ onPressed: () => Navigator.pop(context),
+ ),
+ Expanded(
+ child: Column(
+ children: [
+ Text(
+ player.currentPlaylist?.name ?? 'Now Playing',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary,
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.5),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.more_vert, size: 22),
+ color: LibreBeatsTheme.textSecondary,
+ onPressed: () {},
+ ),
+ ],
+ ),
+ ),
+
+ // Album Art (large)
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
+ child: AspectRatio(
+ aspectRatio: 1,
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(16),
+ gradient: const LinearGradient(
+ colors: [Color(0xFF2A1A1A), Color(0xFF0D0808)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: LibreBeatsTheme.accent.withOpacity(0.2),
+ blurRadius: 40,
+ offset: const Offset(0, 20),
+ ),
+ ],
+ ),
+ child: const Center(
+ child: Text('🎵', style: TextStyle(fontSize: 80)),
+ ),
+ ),
+ ),
+ ),
+ ),
+
+ // Song info + like
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(song.title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ letterSpacing: -0.3)),
+ const SizedBox(height: 4),
+ Text(song.artist,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary,
+ fontSize: 15)),
+ ],
+ ),
+ ),
+ GestureDetector(
+ onTap: () => setState(() => _liked = !_liked),
+ child: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 200),
+ child: Icon(
+ _liked ? Icons.favorite : Icons.favorite_border,
+ key: ValueKey(_liked),
+ color: _liked
+ ? LibreBeatsTheme.accent
+ : LibreBeatsTheme.textSecondary,
+ size: 26,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ // Progress
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
+ child: Column(
+ children: [
+ SliderTheme(
+ data: SliderThemeData(
+ trackHeight: 3,
+ thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
+ activeTrackColor: LibreBeatsTheme.accent,
+ inactiveTrackColor: LibreBeatsTheme.border,
+ thumbColor: Colors.white,
+ overlayColor: LibreBeatsTheme.accentDim,
+ ),
+ child: Slider(
+ value: player.progress.clamp(0.0, 1.0),
+ onChanged: player.seek,
+ ),
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(_formatDuration(player.position),
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12)),
+ Text(_formatDuration(player.duration),
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12)),
+ ],
+ ),
+ ],
+ ),
+ ),
+
+ // Controls
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ // Shuffle
+ IconButton(
+ icon: Icon(
+ Icons.shuffle_rounded,
+ color: player.shuffle == ShuffleMode.on
+ ? LibreBeatsTheme.accent
+ : LibreBeatsTheme.textSecondary,
+ ),
+ onPressed: player.toggleShuffle,
+ ),
+ // Prev
+ IconButton(
+ icon: const Icon(Icons.skip_previous_rounded,
+ size: 36, color: LibreBeatsTheme.textPrimary),
+ onPressed: player.skipPrev,
+ ),
+ // Play/Pause
+ GestureDetector(
+ onTap: player.togglePlayPause,
+ child: Container(
+ width: 64,
+ height: 64,
+ decoration: const BoxDecoration(
+ color: LibreBeatsTheme.textPrimary,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ player.isPlaying
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ color: LibreBeatsTheme.background,
+ size: 32,
+ ),
+ ),
+ ),
+ // Next
+ IconButton(
+ icon: const Icon(Icons.skip_next_rounded,
+ size: 36, color: LibreBeatsTheme.textPrimary),
+ onPressed: player.skipNext,
+ ),
+ // Repeat
+ IconButton(
+ icon: Icon(
+ player.repeat == RepeatMode.one
+ ? Icons.repeat_one_rounded
+ : Icons.repeat_rounded,
+ color: player.repeat != RepeatMode.none
+ ? LibreBeatsTheme.accent
+ : LibreBeatsTheme.textSecondary,
+ ),
+ onPressed: player.toggleRepeat,
+ ),
+ ],
+ ),
+ ),
+
+ // Volume
+ Padding(
+ padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
+ child: Row(
+ children: [
+ const Icon(Icons.volume_mute_rounded,
+ color: LibreBeatsTheme.textSecondary, size: 18),
+ Expanded(
+ child: SliderTheme(
+ data: SliderThemeData(
+ trackHeight: 2,
+ thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
+ overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
+ activeTrackColor: LibreBeatsTheme.textSecondary,
+ inactiveTrackColor: LibreBeatsTheme.border,
+ thumbColor: LibreBeatsTheme.textSecondary,
+ overlayColor: LibreBeatsTheme.accentDim,
+ ),
+ child: Slider(
+ value: player.volume,
+ onChanged: player.setVolume,
+ ),
+ ),
+ ),
+ const Icon(Icons.volume_up_rounded,
+ color: LibreBeatsTheme.textSecondary, size: 18),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ String _formatDuration(Duration d) {
+ final m = d.inMinutes;
+ final s = d.inSeconds % 60;
+ return '$m:${s.toString().padLeft(2, '0')}';
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/lib/widgets/shared_widgets.dart b/src/frontend/lib/widgets/shared_widgets.dart
new file mode 100644
index 0000000..d24d965
--- /dev/null
+++ b/src/frontend/lib/widgets/shared_widgets.dart
@@ -0,0 +1,359 @@
+import 'package:flutter/material.dart';
+import 'package:librebeats/data/models.dart';
+import '../theme/app_theme.dart';
+
+// ── Album Art Placeholder ──────────────────────────────────────────────────
+class AlbumArt extends StatelessWidget {
+ final String? url;
+ final double size;
+ final double radius;
+ final String? fallbackEmoji;
+
+ const AlbumArt({
+ super.key,
+ this.url,
+ this.size = 52,
+ this.radius = 8,
+ this.fallbackEmoji,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: size,
+ height: size,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(radius),
+ gradient: const LinearGradient(
+ colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ child: Center(
+ child: Text(
+ fallbackEmoji ?? '🎵',
+ style: TextStyle(fontSize: size * 0.45),
+ ),
+ ),
+ );
+ }
+}
+
+// ── Song Tile ──────────────────────────────────────────────────────────────
+class SongTile extends StatelessWidget {
+ final Song song;
+ final VoidCallback? onTap;
+ final VoidCallback? onMore;
+ final bool showDuration;
+
+ const SongTile({
+ super.key,
+ required this.song,
+ this.onTap,
+ this.onMore,
+ this.showDuration = true,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ AlbumArt(size: 48, fallbackEmoji: '🎵'),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(song.title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 14,
+ fontWeight: FontWeight.w600),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ const SizedBox(height: 2),
+ Text('${song.artist} • ${song.album}',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 12),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ ],
+ ),
+ ),
+ if (showDuration)
+ Text(song.durationString,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textDim, fontSize: 12)),
+ const SizedBox(width: 4),
+ if (onMore != null)
+ IconButton(
+ icon: const Icon(Icons.more_vert, size: 18),
+ color: LibreBeatsTheme.textSecondary,
+ onPressed: onMore,
+ splashRadius: 20,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ── Playlist Card (vertical) ───────────────────────────────────────────────
+class PlaylistCard extends StatelessWidget {
+ final Playlist playlist;
+ final VoidCallback? onTap;
+ final double width;
+
+ const PlaylistCard({
+ super.key,
+ required this.playlist,
+ this.onTap,
+ this.width = 140,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: SizedBox(
+ width: width,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Stack(
+ children: [
+ Container(
+ width: width,
+ height: width,
+ decoration: BoxDecoration(
+ color: LibreBeatsTheme.surface,
+ borderRadius: BorderRadius.circular(10),
+ gradient: LinearGradient(
+ colors: playlist.isServer
+ ? [const Color(0xFF1A2A3A), const Color(0xFF0D1520)]
+ : [const Color(0xFF2A1A1A), const Color(0xFF150D0D)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ child: Center(
+ child: Text(
+ playlist.isServer ? '📡' : '🎵',
+ style: TextStyle(fontSize: width * 0.35),
+ ),
+ ),
+ ),
+ if (playlist.isServer)
+ Positioned(
+ top: 8,
+ right: 8,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: const Color(0xFF1DB95422),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: const Color(0xFF1DB954), width: 1),
+ ),
+ child: const Text('SERVER',
+ style: TextStyle(
+ color: Color(0xFF1DB954),
+ fontSize: 9,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 0.5)),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(playlist.name,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 13,
+ fontWeight: FontWeight.w600),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ Text('${playlist.songCount} songs',
+ style: const TextStyle(
+ color: LibreBeatsTheme.textSecondary, fontSize: 11)),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ── Section Header ─────────────────────────────────────────────────────────
+class SectionHeader extends StatelessWidget {
+ final String title;
+ final String? actionLabel;
+ final VoidCallback? onAction;
+
+ const SectionHeader({
+ super.key,
+ required this.title,
+ this.actionLabel,
+ this.onAction,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(title,
+ style: const TextStyle(
+ color: LibreBeatsTheme.textPrimary,
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ letterSpacing: -0.3)),
+ if (actionLabel != null)
+ GestureDetector(
+ onTap: onAction,
+ child: Text(actionLabel!,
+ style: const TextStyle(
+ color: LibreBeatsTheme.accent,
+ fontSize: 13,
+ fontWeight: FontWeight.w600)),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ── Accent Button ──────────────────────────────────────────────────────────
+class AccentButton extends StatelessWidget {
+ final String label;
+ final IconData? icon;
+ final VoidCallback? onTap;
+ final bool outlined;
+
+ const AccentButton({
+ super.key,
+ required this.label,
+ this.icon,
+ this.onTap,
+ this.outlined = false,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
+ decoration: BoxDecoration(
+ color: outlined ? Colors.transparent : LibreBeatsTheme.accent,
+ borderRadius: BorderRadius.circular(24),
+ border: outlined
+ ? Border.all(color: LibreBeatsTheme.accent, width: 1.5)
+ : null,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(icon,
+ color: outlined ? LibreBeatsTheme.accent : Colors.white,
+ size: 16),
+ const SizedBox(width: 6),
+ ],
+ Text(label,
+ style: TextStyle(
+ color: outlined ? LibreBeatsTheme.accent : Colors.white,
+ fontSize: 14,
+ fontWeight: FontWeight.w700)),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+// ── Chip Tag ───────────────────────────────────────────────────────────────
+class ChipTag extends StatelessWidget {
+ final String label;
+ final Color? color;
+
+ const ChipTag({super.key, required this.label, this.color});
+
+ @override
+ Widget build(BuildContext context) {
+ final c = color ?? LibreBeatsTheme.accent;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
+ decoration: BoxDecoration(
+ color: c.withOpacity(0.15),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: c.withOpacity(0.5), width: 1),
+ ),
+ child: Text(label,
+ style: TextStyle(
+ color: c, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.5)),
+ );
+ }
+}
+
+// ── Shimmer Loader ─────────────────────────────────────────────────────────
+class ShimmerBox extends StatefulWidget {
+ final double width;
+ final double height;
+ final double radius;
+
+ const ShimmerBox({
+ super.key,
+ required this.width,
+ required this.height,
+ this.radius = 8,
+ });
+
+ @override
+ State createState() => _ShimmerBoxState();
+}
+
+class _ShimmerBoxState extends State
+ with SingleTickerProviderStateMixin {
+ late AnimationController _ctrl;
+ late Animation _anim;
+
+ @override
+ void initState() {
+ super.initState();
+ _ctrl = AnimationController(
+ vsync: this, duration: const Duration(milliseconds: 1200))
+ ..repeat(reverse: true);
+ _anim = Tween(begin: 0.3, end: 0.7).animate(_ctrl);
+ }
+
+ @override
+ void dispose() {
+ _ctrl.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: _anim,
+ builder: (_, __) => Container(
+ width: widget.width,
+ height: widget.height,
+ decoration: BoxDecoration(
+ color: Color.lerp(
+ LibreBeatsTheme.surface, LibreBeatsTheme.surfaceVariant, _anim.value),
+ borderRadius: BorderRadius.circular(widget.radius),
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift
index cccf817..f774dc0 100644
--- a/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/src/frontend/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,6 +5,16 @@
import FlutterMacOS
import Foundation
+import audio_service
+import audio_session
+import just_audio
+import shared_preferences_foundation
+import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
+ AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
+ JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
+ SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
+ SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
diff --git a/src/frontend/pubspec.lock b/src/frontend/pubspec.lock
index 688d871..6b58def 100644
--- a/src/frontend/pubspec.lock
+++ b/src/frontend/pubspec.lock
@@ -9,6 +9,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
+ audio_service:
+ dependency: transitive
+ description:
+ name: audio_service
+ sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.18.18"
+ audio_service_platform_interface:
+ dependency: transitive
+ description:
+ name: audio_service_platform_interface
+ sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.3"
+ audio_service_web:
+ dependency: transitive
+ description:
+ name: audio_service_web
+ sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.4"
+ audio_session:
+ dependency: transitive
+ description:
+ name: audio_session
+ sha256: "2b7fff16a552486d078bfc09a8cde19f426dc6d6329262b684182597bec5b1ac"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.25"
boolean_selector:
dependency: transitive
description:
@@ -17,6 +49,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
+ cached_network_image:
+ dependency: "direct main"
+ description:
+ name: cached_network_image
+ sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
+ cached_network_image_platform_interface:
+ dependency: transitive
+ description:
+ name: cached_network_image_platform_interface
+ sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.1"
+ cached_network_image_web:
+ dependency: transitive
+ description:
+ name: cached_network_image_web
+ sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.1"
characters:
dependency: transitive
description:
@@ -33,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
+ code_assets:
+ dependency: transitive
+ description:
+ name: code_assets
+ sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
collection:
dependency: transitive
description:
@@ -41,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@@ -57,11 +129,43 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
+ ffi:
+ dependency: transitive
+ description:
+ name: ffi
+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_cache_manager:
+ dependency: transitive
+ description:
+ name: flutter_cache_manager
+ sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -75,6 +179,91 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ glob:
+ dependency: transitive
+ description:
+ name: glob
+ sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.3"
+ hooks:
+ dependency: transitive
+ description:
+ name: hooks
+ sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
+ http:
+ dependency: "direct main"
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.2"
+ intl:
+ dependency: "direct main"
+ description:
+ name: intl
+ sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.19.0"
+ js:
+ dependency: transitive
+ description:
+ name: js
+ sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.2"
+ just_audio:
+ dependency: "direct main"
+ description:
+ name: just_audio
+ sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.46"
+ just_audio_background:
+ dependency: "direct main"
+ description:
+ name: just_audio_background
+ sha256: "3900825701164577db65337792bc122b66f9eb1245ee47e7ae8244ae5ceb8030"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.1-beta.17"
+ just_audio_platform_interface:
+ dependency: transitive
+ description:
+ name: just_audio_platform_interface
+ sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.6.0"
+ just_audio_web:
+ dependency: transitive
+ description:
+ name: just_audio_web
+ sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.16"
leak_tracker:
dependency: transitive
description:
@@ -107,6 +296,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -131,6 +328,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
+ native_toolchain_c:
+ dependency: transitive
+ description:
+ name: native_toolchain_c
+ sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.17.5"
+ nested:
+ dependency: transitive
+ description:
+ name: nested
+ sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
+ objective_c:
+ dependency: transitive
+ description:
+ name: objective_c
+ sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.3.0"
+ octo_image:
+ dependency: transitive
+ description:
+ name: octo_image
+ sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
path:
dependency: transitive
description:
@@ -139,6 +368,150 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
+ path_provider:
+ dependency: "direct main"
+ description:
+ name: path_provider
+ sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.5"
+ path_provider_android:
+ dependency: transitive
+ description:
+ name: path_provider_android
+ sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.22"
+ path_provider_foundation:
+ dependency: transitive
+ description:
+ name: path_provider_foundation
+ sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.6.0"
+ path_provider_linux:
+ dependency: transitive
+ description:
+ name: path_provider_linux
+ sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.1"
+ path_provider_platform_interface:
+ dependency: transitive
+ description:
+ name: path_provider_platform_interface
+ sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ path_provider_windows:
+ dependency: transitive
+ description:
+ name: path_provider_windows
+ sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.0"
+ platform:
+ dependency: transitive
+ description:
+ name: platform
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.6"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.8"
+ provider:
+ dependency: "direct main"
+ description:
+ name: provider
+ sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.5+1"
+ pub_semver:
+ dependency: transitive
+ description:
+ name: pub_semver
+ sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ rxdart:
+ dependency: transitive
+ description:
+ name: rxdart
+ sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.28.0"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.4"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.21"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.6"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -152,6 +525,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
+ sqflite:
+ dependency: "direct main"
+ description:
+ name: sqflite
+ sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_android:
+ dependency: transitive
+ description:
+ name: sqflite_android
+ sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2+2"
+ sqflite_common:
+ dependency: transitive
+ description:
+ name: sqflite_common
+ sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.6"
+ sqflite_darwin:
+ dependency: transitive
+ description:
+ name: sqflite_darwin
+ sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ sqflite_platform_interface:
+ dependency: transitive
+ description:
+ name: sqflite_platform_interface
+ sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -176,6 +589,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -192,6 +613,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ uuid:
+ dependency: "direct main"
+ description:
+ name: uuid
+ sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -208,6 +645,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
+ web:
+ dependency: transitive
+ description:
+ name: web
+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ xdg_directories:
+ dependency: transitive
+ description:
+ name: xdg_directories
+ sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ yaml:
+ dependency: transitive
+ description:
+ name: yaml
+ sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.3"
sdks:
dart: ">=3.10.7 <4.0.0"
- flutter: ">=3.18.0-18.0.pre.54"
+ flutter: ">=3.38.4"
diff --git a/src/frontend/pubspec.yaml b/src/frontend/pubspec.yaml
index b54c581..3206678 100644
--- a/src/frontend/pubspec.yaml
+++ b/src/frontend/pubspec.yaml
@@ -30,6 +30,16 @@ environment:
dependencies:
flutter:
sdk: flutter
+ provider: ^6.1.1
+ shared_preferences: ^2.2.2
+ http: ^1.2.0
+ just_audio: ^0.9.36
+ just_audio_background: any
+ cached_network_image: ^3.3.1
+ uuid: ^4.3.3
+ path_provider: ^2.1.2
+ sqflite: ^2.3.2
+ intl: ^0.19.0
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.