This commit is contained in:
Forrest 2026-08-04 20:19:10 -04:00
parent d6144c4483
commit 4493bcea5e
47 changed files with 371 additions and 8 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<item android:drawable="@color/launch_background" />
<!-- You can insert your own image assets here -->
<!-- <item>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,019 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item android:drawable="@color/launch_background" />
<!-- You can insert your own image assets here -->
<!-- <item>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#191919</color>
<!-- Timbre canvas — used for the launch splash so it matches the app. -->
<color name="launch_background">#1A1A1A</color>
</resources>

116
app-store-description.md Normal file
View file

@ -0,0 +1,116 @@
# Timbre — App Store Listing
## Promotional Text
*(170-char limit)*
Your music, your server. Stream and download from any Subsonic or Navidrome library, control playback across your devices, and take it all offline.
---
## Description
*(4,000-char limit)*
**Timbre is a fast, beautiful music player for your own Subsonic or Navidrome server.** Connect to the library you host, and your entire collection comes with you — streamed on Wi‑Fi and cellular, or saved for offline listening wherever you go.
Timbre pairs a distinctive, tactile design — an animated cassette on the Now Playing screen and accent colors drawn from each album's artwork — with the everyday features you actually reach for.
**STREAM YOUR LIBRARY**
• Works with any Subsonic-compatible server, including Navidrome
• Browse albums, artists, tracks, and playlists
• Save multiple servers and switch between them in a tap
• Home screen with Resume, Recently Played, and fresh picks
**FIND IT FAST**
• Search across your whole collection
• Filter and sort albums and tracks by genre, release year, and date added
• Mark favorites and organize with custom tags
**LISTEN OFFLINE**
• Download individual tracks, albums, or whole playlists
• "Download all" for one-tap saving
• Set how many tracks download at once to match your connection
• Keeps playing when you lose signal
**PLAYLISTS & TAGS**
• Create, rename, and reorder playlists
• Drag and drop to reorder your play queue
• Share playlists with other users on the same server
• Tag songs to group them your way
**CONTROL ACROSS DEVICES**
• Use one device as a remote for another on the same Wi‑Fi
• Mirror the queue and browse, enqueue, and reorder from either device
• Secure, automatic pairing — no codes to type
**BUILT FOR EVERYDAY PLAY**
• Background playback with lock screen and Control Center controls
• Gapless, high-quality audio
• Resume right where you left off
Timbre never locks you in. There's no subscription and no catalog to rent — just a clean, thoughtful player for the music you already own and host.
**Requires a Subsonic-compatible server (such as Navidrome). Timbre is a client only; you'll sign in with your own server details.**
---
## Keywords
*(100-char limit, comma-separated, no spaces)*
subsonic,navidrome,music,player,streaming,offline,download,playlist,FLAC,audio,server,remote,gapless
---
## App Review Information
### Sign-In Required
Yes — Timbre is a client for a self-hosted music server and requires server
credentials to sign in.
**Demo server (fill in before submitting):**
- Server URL: `https://<your-demo-server>`
- Username: `<demo-username>`
- Password: `<demo-password>`
### Contact Information
- First name: `<first name>`
- Last name: `<last name>`
- Phone: `<phone number>`
- Email: `<contact email>`
### Notes for the Reviewer
Thank you for reviewing Timbre.
WHAT THIS APP IS
Timbre is a client for self-hosted, Subsonic-compatible music servers (such as
Navidrome). It does not host any music itself — it plays back a library that the
user runs on their own server. We have provided demo server credentials above so
you can review full functionality.
HOW TO GET STARTED
1. Launch the app. On first run you will see the "Add server" / connect screen.
2. Enter the Server URL, Username, and Password from the demo credentials above.
3. Tap connect. The library (albums, artists, playlists) will load.
WHAT TO TRY
- Browse the Home, Library, Playlists, and Favorites tabs.
- Tap any album or track to start playback; open Now Playing.
- Search the library and apply genre / year / date-added filters.
- Download a track for offline playback (Downloads tab).
- Create a playlist or tag a song.
ABOUT PERMISSIONS / LOCAL NETWORK
Timbre includes an optional cross-device control feature: with two devices signed
into the same server on the same Wi-Fi network, one device can act as a remote for
the other. This is why the app requests Local Network access. The feature is
entirely optional — the app is fully functional on a single device without it, and
you may decline the Local Network prompt.
PRIVACY / DATA
Timbre collects no analytics and has no third-party accounts. The password is used
only to authenticate to the user's own server (via Subsonic's standard token-based
auth) and is stored securely on-device. There are no in-app purchases or ads in
this version.
Please contact us at the email above if you have any trouble connecting to the
demo server or need additional information.

BIN
assets/icon/icon_full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 661 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 617 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

@ -19,7 +19,7 @@
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="0.10196078431372549" green="0.10196078431372549" blue="0.10196078431372549" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>

View file

@ -10,6 +10,7 @@ import 'shell/app_shell.dart';
import 'state/providers.dart';
import 'theme/accent.dart';
import 'theme/app_theme.dart';
import 'widgets/splash_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -50,7 +51,9 @@ class TimbreApp extends ConsumerWidget {
title: 'Timbre',
debugShowCheckedModeBanner: false,
theme: buildTimbreTheme(accent),
home: const AppShell(),
// AppShell mounts under the splash and boots behind it; the splash fades
// out after a couple of seconds.
home: const SplashGate(child: AppShell()),
);
}
}

View file

@ -0,0 +1,223 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../theme/tokens.dart';
/// How long the splash stays up before it fades away. The app mounts *behind*
/// the splash (see [SplashGate]), so this window also masks real cold-start
/// work (settings load, discovery, reconnect).
const _splashHold = Duration(seconds: 2);
const _splashFade = Duration(milliseconds: 350);
/// Holds the [child] app under a cassette splash overlay, then fades the
/// overlay out once [_splashHold] elapses. The child is live the whole time,
/// so it finishes booting behind the splash rather than after it.
class SplashGate extends StatefulWidget {
const SplashGate({super.key, required this.child});
final Widget child;
@override
State<SplashGate> createState() => _SplashGateState();
}
class _SplashGateState extends State<SplashGate> {
Timer? _timer;
bool _fading = false; // began fading out
bool _gone = false; // fully removed from the tree
@override
void initState() {
super.initState();
_timer = Timer(_splashHold, () {
if (mounted) setState(() => _fading = true);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (!_gone)
// Absorb taps while opaque; let them through once we start fading.
IgnorePointer(
ignoring: _fading,
child: AnimatedOpacity(
opacity: _fading ? 0 : 1,
duration: _splashFade,
curve: Curves.easeOut,
onEnd: () {
if (mounted && _fading) setState(() => _gone = true);
},
child: const SplashScreen(),
),
),
],
);
}
}
/// The full-screen cassette splash: a spinning cassette over the wordmark, on
/// Timbre's near-black canvas. Rebuilds nothing per frame beyond the two
/// spindle transforms.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
// One full turn every 3s; both reels spin clockwise like a real cassette.
late final AnimationController _spin = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: TimbreColors.background,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 240,
child: _SplashCassette(spin: _spin),
),
const SizedBox(height: TimbreSpacing.xl),
Text(
'TIMBRE',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: TimbreColors.foreground,
fontWeight: FontWeight.w600,
letterSpacing: 8,
),
),
const SizedBox(height: TimbreSpacing.md),
// Accent hairline — the one lively color, kept small.
Container(
width: 32,
height: 2,
color: TimbreColors.accentDefault,
),
const SizedBox(height: TimbreSpacing.md),
Text(
'loading…',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TimbreColors.dimmed,
letterSpacing: 2,
),
),
],
),
),
);
}
}
/// The cassette itself: static reels + shell (from the shared assets) with two
/// spinning spindles. Mirrors [CassetteView]'s layering and geometry but drops
/// the playback model — the reels sit at a fixed half-wound radius.
class _SplashCassette extends StatelessWidget {
const _SplashCassette({required this.spin});
final Animation<double> spin;
// Geometry measured off casette_shell.svg (viewBox 469×298), matching
// CassetteView's _CassetteConfig.
static const _viewBox = Size(469, 298);
static const _leftReel = Offset(135, 135);
static const _rightReel = Offset(334, 135);
static const _spindleSize = 36.0;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: _viewBox.width / _viewBox.height,
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: _viewBox.width,
height: _viewBox.height,
child: Stack(
children: [
// 1. Static tape discs behind the reel holes.
Positioned.fill(
child: CustomPaint(painter: _ReelPainter()),
),
// 2. Spinning cogs.
_spindle(_leftReel),
_spindle(_rightReel),
// 3. Static shell on top, framing the reels through its holes.
Positioned.fill(
child: SvgPicture.asset(
'assets/casette/casette_shell.svg',
fit: BoxFit.fill,
),
),
],
),
),
),
);
}
Widget _spindle(Offset center) {
final cog = SvgPicture.asset(
'assets/casette/spindle.svg',
width: _spindleSize,
height: _spindleSize,
);
return Positioned(
left: center.dx - _spindleSize / 2,
top: center.dy - _spindleSize / 2,
width: _spindleSize,
height: _spindleSize,
child: RotationTransition(turns: spin, child: cog),
);
}
}
/// Paints the two wound-tape discs at a fixed half-wound radius, clipped to the
/// window band so they never touch the label. The shell (on top) masks them to
/// the reel holes + centre window.
class _ReelPainter extends CustomPainter {
static const _windowRect = Rect.fromLTRB(62.5, 98.5, 405.5, 172);
static const _leftReel = Offset(135, 135);
static const _rightReel = Offset(334, 135);
static const _radius = 62.0; // fixed ~half-wound (see CassetteView physics)
static const _tapeColor = Color(0xFF121212);
static const _padColor = Color(0xFF1A1A1A);
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.clipRect(_windowRect);
canvas.drawRect(_windowRect, Paint()..color = _padColor);
final tape = Paint()..color = _tapeColor;
canvas.drawCircle(_leftReel, _radius, tape);
canvas.drawCircle(_rightReel, _radius, tape);
canvas.restore();
}
@override
bool shouldRepaint(_ReelPainter oldDelegate) => false;
}

View file

@ -60,13 +60,19 @@ dev_dependencies:
flutter_launcher_icons: ^0.14.4
flutter_launcher_icons:
image_path: "assets/icon/icon.png"
# Full-bleed square used for iOS and the Android legacy (pre-adaptive) icon.
image_path_ios: "assets/icon/icon_full.png"
image_path_android: "assets/icon/icon_full.png"
android: true
ios: true
remove_alpha_ios: true
# Optional adaptive icon for Android (uncomment and provide a foreground PNG):
# adaptive_icon_background: "#FFFFFF"
# adaptive_icon_foreground: "assets/icon/icon_foreground.png"
min_sdk_android: 21
# Android adaptive icon: the full-bleed artwork goes in the BACKGROUND layer,
# which fills the whole canvas and is only clipped by the launcher's mask shape
# (no inset). The foreground is transparent. This gives edge-to-edge coverage;
# round/squircle masks will clip the cassette's rotated corners (accepted).
adaptive_icon_background: "assets/icon/icon_full.png"
adaptive_icon_foreground: "assets/icon/icon_transparent.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec