updates
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 329 B |
|
|
@ -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>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1,019 B |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 14 KiB |
6
android/app/src/main/res/values/colors.xml
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 670 KiB |
BIN
assets/icon/icon_transparent.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 661 B |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 617 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
223
lib/widgets/splash_screen.dart
Normal 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;
|
||||
}
|
||||
14
pubspec.yaml
|
|
@ -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
|
||||
|
|
|
|||