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.